资讯详情 C# AES填充模式详解:PKCS7、Zeros、None选型与跨语言对接避坑指南
📅 2026/10/13 1:22:54
1. 从一个踩坑现场说起为什么AES加解密总在“填充”上翻车做过C#后端对接的人大概率都遇到过这种场景本地用一套代码加密出来的密文发给对方系统对方解密直接抛异常报的是“Padding is invalid and cannot be removed”或者更隐蔽一点解密出来一堆乱码前几个字节对得上后面全是垃圾。你反复核对密钥、IV、模式全都一致最后发现问题出在一个最不起眼的地方——填充模式Padding Mode。AES本身是分组加密算法分组长度固定128位也就是16字节。它只能处理长度恰好是16字节整数倍的数据。但现实里的明文长度是随机的一段JSON可能37字节一个手机号可能11字节一条订单备注可能203字节。多出来的零头怎么办这就是填充Padding要解决的问题。填充规则选错了或者加密端和解密端对填充的理解不一致密文就废了。这篇内容围绕C#里的AES填充加密解密展开把填充模式的选择逻辑、代码实现、参数计算、跨语言对接的坑、以及实际排查问题的思路全部拆开讲。适合正在做接口加密对接的后端开发、需要和Java/Python/Go系统互通的C#工程师以及被“填充异常”折磨过想彻底搞明白的人。读完你至少能做到三件事第一知道五种填充模式各自在什么场景用第二能写出加密端和解密端完全对齐的C#代码第三遇到填充报错时有一套可执行的排查路径而不是靠猜。我先把结论放在前面绝大多数跨系统对接场景用PKCS7填充只有在对接某些历史遗留系统、且对方明确要求时才考虑Zeros或NoneISO10126和ANSIX923在国内对接里出现频率极低但你必须知道它们的存在否则看到别人的密文会一脸懵。2. AES填充模式全解析五种模式到底差在哪2.1 为什么必须有填充从16字节分组说起AES的分组是16字节这是算法层面的硬约束改不了。加密时明文按16字节切块最后一块如果不足16字节就必须补到16字节如果明文长度恰好是16的整数倍那还要不要再补一整块这取决于填充模式的定义。这里有个很多人忽略的细节PKCS7在明文长度恰好为16整数倍时会额外补一整块16字节的填充数据每个字节的值都是0x10即16。而Zeros填充在同样情况下不补任何东西。这个差异直接导致同一个明文、同一个密钥用不同填充模式加密出来的密文长度可能差16字节。对接时如果对方按固定长度解析长度对不上就会出问题。我用一个具体例子说明。明文是ABCDEFGHIJKLMNOP正好16字节PKCS7填充后变成32字节后16字节全是0x10Zeros填充后还是16字节不补None填充后还是16字节但要求明文本身必须是16整数倍否则直接报错你看同样一句明文填充策略不同密文长度差一倍。这就是为什么对接文档里必须明确写清楚填充模式含糊不得。2.2 五种填充模式逐一拆解C#的System.Security.Cryptography命名空间下PaddingMode枚举定义了五种值我逐个说清楚它们的规则和适用场景。PKCS7最通用、最推荐。填充字节的值等于填充的字节数。比如缺3字节就补03 03 03缺5字节就补05 05 05 05 05。如果明文正好16整数倍补一整块16个0x10。解密时读最后一个字节的值就知道要去掉多少填充。这个模式跨语言支持最好Java的AES/CBC/PKCS5Padding实际上就是PKCS7Java里PKCS5和PKCS7在AES场景下等价Python的padding.PKCS7(128)也是它。Zeros缺多少字节就补多少个0x00。问题在于如果明文本身末尾就有0x00字节解密后无法区分哪些是填充、哪些是原始数据会误删。所以Zeros只适合明文确定不含0x00结尾的场景比如纯文本、纯数字。对接老系统时偶尔会遇到。ANSIX923填充的最后一个字节是填充长度前面的填充字节全是0x00。比如缺3字节补00 00 03。它比Zeros多了一个长度标记解密时能准确知道去掉多少解决了Zeros的歧义问题。但在国内对接里用得少。ISO10126填充的最后一个字节是填充长度前面的填充字节是随机数。随机填充增加了密文的随机性理论上更安全但解密时只读最后一个字节前面的随机数直接丢弃。因为引入了随机性同样的明文每次加密结果都不同调试时不好对比。实际项目里我几乎没用过。None不填充。要求明文长度必须是16的整数倍否则加密直接抛CryptographicException。什么时候用当你的数据本身就是固定16字节对齐的比如某些二进制协议包、或者你自己在上层已经做好了填充。用None必须自己保证长度否则就是给自己挖坑。为了让你一眼看清差异我列个对照表填充模式填充规则明文16整数倍时跨语言兼容性推荐场景PKCS7补N个值为N的字节额外补16个0x10极好绝大多数对接场景Zeros补N个0x00不补一般明文不含0x00结尾的老系统ANSIX923前N-1个0x00最后1个为N额外补一整块一般特定规范要求ISO10126前N-1个随机最后1个为N额外补一整块较差几乎不用None不填充不补取决于上层数据已16字节对齐2.3 填充模式选型的三个判断依据选填充模式不是拍脑袋我一般按这三条来判断。第一条看对接方文档。如果对方是Java系统文档写AES/CBC/PKCS5Padding那C#这边就用PKCS7因为Java的PKCS5在AES下就是PKCS7的行为。如果对方是Python用的pycryptodome库默认也是PKCS7。这条最省事照抄就行。第二条看数据特征。如果明文是二进制数据可能包含任意字节绝对不能用Zeros因为无法区分原始0x00和填充0x00。这时候PKCS7是唯一安全的选择。如果明文是纯ASCII文本Zeros勉强能用但我还是建议PKCS7。第三条看历史包袱。有些运行了十几年的老系统当年用的是Zeros或None现在改不动。这时候C#这边必须迁就对方用对应的模式同时在代码注释里写清楚原因避免后人误改。注意填充模式一旦确定加密端和解密端必须完全一致。我见过一个团队加密用PKCS7解密代码复制粘贴时手滑改成了Zeros结果测试环境一直报填充异常查了两天才发现。这种低级错误在赶工期时特别容易犯。3. C#实现AES填充加解密的完整代码与参数计算3.1 核心API选型Aes vs AesManaged vs AesCryptoServiceProviderC#里做AES有三个常用类Aes、AesManaged、AesCryptoServiceProvider。很多人纠结用哪个我直接给结论。Aes是抽象基类从.NET Core开始推荐用Aes.Create()来创建实例它会根据当前平台返回最优实现。AesManaged是纯托管实现跨平台一致性好但在某些平台上性能不如系统原生实现。AesCryptoServiceProvider底层调用Windows的CryptoAPI性能好但跨平台受限。我的建议是新项目一律用Aes.Create()。它自动选择当前平台最优实现代码也简洁。只有在需要严格控制实现版本、或者对接特定加密硬件时才考虑显式指定具体类。using System; using System.Security.Cryptography; using System.Text; public class AesHelper { // 加密 public static string Encrypt(string plainText, byte[] key, byte[] iv) { using (Aes aes Aes.Create()) { aes.Key key; aes.IV iv; aes.Mode CipherMode.CBC; aes.Padding PaddingMode.PKCS7; ICryptoTransform encryptor aes.CreateEncryptor(aes.Key, aes.IV); byte[] plainBytes Encoding.UTF8.GetBytes(plainText); byte[] cipherBytes encryptor.TransformFinalBlock(plainBytes, 0, plainBytes.Length); return Convert.ToBase64String(cipherBytes); } } // 解密 public static string Decrypt(string cipherText, byte[] key, byte[] iv) { using (Aes aes Aes.Create()) { aes.Key key; aes.IV iv; aes.Mode CipherMode.CBC; aes.Padding PaddingMode.PKCS7; ICryptoTransform decryptor aes.CreateDecryptor(aes.Key, aes.IV); byte[] cipherBytes Convert.FromBase64String(cipherText); byte[] plainBytes decryptor.TransformFinalBlock(cipherBytes, 0, cipherBytes.Length); return Encoding.UTF8.GetString(plainBytes); } } }这段代码是骨架但直接用会踩坑。下面我把每个参数的计算和选择讲透。3.2 密钥长度与IV的生成逻辑AES支持三种密钥长度128位16字节、192位24字节、256位32字节。选哪个256位是当前主流128位在合规要求下也够用。192位用得少因为它的密钥调度算法和另外两种不同某些硬件加速不支持。密钥不能随便写个字符串就用。很多人图省事直接Encoding.UTF8.GetBytes(1234567890123456)这样得到的16字节确实能当128位密钥但安全性极差因为可猜测。正确做法是用RandomNumberGenerator生成随机密钥byte[] key new byte[32]; // 256位 using (var rng RandomNumberGenerator.Create()) { rng.GetBytes(key); }IV初始化向量的长度必须等于分组长度也就是16字节这个没得选。IV的作用是让同样的明文在同样的密钥下每次加密结果不同。CBC模式下IV必须是随机的且每次加密都换新的。IV不需要保密可以和密文一起传输通常拼在密文前面或者单独一个字段。这里有个实操细节IV每次加密都变那解密方怎么知道用的哪个IV答案是加密方把IV和密文一起发过去。常见做法是IV 密文拼接后整体Base64解密方取前16字节当IV剩下的当密文。我一般这么写// 加密时IV拼在密文前 byte[] combined new byte[iv.Length cipherBytes.Length]; Buffer.BlockCopy(iv, 0, combined, 0, iv.Length); Buffer.BlockCopy(cipherBytes, 0, combined, iv.Length, cipherBytes.Length); return Convert.ToBase64String(combined); // 解密时拆出IV和密文 byte[] combined Convert.FromBase64String(cipherText); byte[] iv new byte[16]; byte[] cipherBytes new byte[combined.Length - 16]; Buffer.BlockCopy(combined, 0, iv, 0, 16); Buffer.BlockCopy(combined, 16, cipherBytes, 0, cipherBytes.Length);提示如果对接方要求IV固定不变有些老系统这么设计那安全性会打折扣但为了兼容只能照做。这种情况下要在文档里标注风险并建议对方升级。3.3 加密模式的选择CBC还是ECBAES有多种工作模式C#的CipherMode枚举里有CBC、ECB、CFB、CTS等。实际对接中CBC是绝对主流ECB要尽量避免。ECB模式的问题在于相同的明文块加密后得到相同的密文块。如果你加密一张图片ECB模式下能看出原图的轮廓因为相同颜色的像素块加密结果一样。这就是所谓的“ECB企鹅图”现象。所以ECB只适合加密极短且无规律的数据比如单个密钥。CBC模式通过IV把前一块的密文混入下一块的加密相同明文块在不同位置加密结果不同安全性好得多。代价是加密必须串行不能并行性能略低。但在绝大多数业务场景下这个性能差异可以忽略。CFB和CTS属于流式或特殊模式对接中很少见。如果对方文档没写模式默认按CBC处理这是行业惯例。3.4 完整可运行的加解密示例把上面的点串起来给一个完整可运行的例子包含密钥生成、加密、解密全流程using System; using System.Security.Cryptography; using System.Text; public class AesFullDemo { public static void Run() { // 1. 生成256位密钥 byte[] key new byte[32]; using (var rng RandomNumberGenerator.Create()) { rng.GetBytes(key); } // 2. 生成16字节IV byte[] iv new byte[16]; using (var rng RandomNumberGenerator.Create()) { rng.GetBytes(iv); } string plainText 订单号:20240501,金额:199.00,备注:测试数据; Console.WriteLine($明文: {plainText}); Console.WriteLine($明文字节数: {Encoding.UTF8.GetBytes(plainText).Length}); // 3. 加密 string cipherText Encrypt(plainText, key, iv); Console.WriteLine($密文(Base64): {cipherText}); // 4. 解密 string decrypted Decrypt(cipherText, key, iv); Console.WriteLine($解密结果: {decrypted}); } public static string Encrypt(string plainText, byte[] key, byte[] iv) { using (Aes aes Aes.Create()) { aes.Key key; aes.IV iv; aes.Mode CipherMode.CBC; aes.Padding PaddingMode.PKCS7; byte[] plainBytes Encoding.UTF8.GetBytes(plainText); Console.WriteLine($加密前字节数: {plainBytes.Length}); Console.WriteLine($PKCS7填充后字节数: {((plainBytes.Length / 16) 1) * 16}); using (ICryptoTransform encryptor aes.CreateEncryptor()) { byte[] cipherBytes encryptor.TransformFinalBlock(plainBytes, 0, plainBytes.Length); return Convert.ToBase64String(cipherBytes); } } } public static string Decrypt(string cipherText, byte[] key, byte[] iv) { using (Aes aes Aes.Create()) { aes.Key key; aes.IV iv; aes.Mode CipherMode.CBC; aes.Padding PaddingMode.PKCS7; byte[] cipherBytes Convert.FromBase64String(cipherText); using (ICryptoTransform decryptor aes.CreateDecryptor()) { byte[] plainBytes decryptor.TransformFinalBlock(cipherBytes, 0, cipherBytes.Length); return Encoding.UTF8.GetString(plainBytes); } } } }跑一遍你会看到明文37字节PKCS7填充后变成48字节37除以16得2余5缺11字节补11个0x0B总共48字节。密文Base64长度是64字符48字节Base64编码后是64字符。这些数字对上了说明填充逻辑正确。4. 跨语言对接实战C#与Java/Python的填充对齐4.1 C#与Java对接的填充陷阱Java的AES加密标准写法是Cipher.getInstance(AES/CBC/PKCS5Padding)。很多人看到PKCS5就以为和C#的PKCS7不一样其实在AES场景下Java的PKCS5Padding就是PKCS7的行为因为AES分组是16字节PKCS5原本定义是8字节分组Java沿用了这个名字但实现按16字节走。对接时Java端代码通常长这样Cipher cipher Cipher.getInstance(AES/CBC/PKCS5Padding); SecretKeySpec keySpec new SecretKeySpec(keyBytes, AES); IvParameterSpec ivSpec new IvParameterSpec(ivBytes); cipher.init(Cipher.ENCRYPT_MODE, keySpec, ivSpec); byte[] encrypted cipher.doFinal(plainBytes);C#这边只要用PaddingMode.PKCS7两边结果完全一致。我实测过同样的密钥、IV、明文Java加密出来的Base64和C#加密出来的Base64一模一样。但有个坑Java的doFinal在明文为空时会抛异常而C#的TransformFinalBlock对空数组返回空数组。如果业务里可能出现空字符串Java端要特殊处理C#端也要判断。我一般约定空字符串不加密直接传空避免这个边界问题。4.2 C#与Python对接的填充对齐Python用pycryptodome库时填充需要手动做from Crypto.Cipher import AES from Crypto.Util.Padding import pad, unpad import base64 key b... # 32字节 iv b... # 16字节 cipher AES.new(key, AES.MODE_CBC, iv) padded pad(plain_bytes, AES.block_size) # 默认就是PKCS7 encrypted cipher.encrypt(padded) result base64.b64encode(encrypted).decode()pad函数默认用的就是PKCS7AES.block_size是16。C#这边用PKCS7两边对齐。解密时Python用unpad去掉填充C#用TransformFinalBlock自动去填充。如果Python端用的是cryptography库填充写法不同from cryptography.hazmat.primitives.padding import PKCS7 padder PKCS7(128).padder() # 128位16字节 padded_data padder.update(plain_bytes) padder.finalize()注意这里的128是位数不是字节数。我见过有人写成PKCS7(16)结果报错因为参数是位。这种细节在对接文档里经常被忽略但错了就是解密失败。4.3 跨语言对接的验证方法对接时怎么快速验证两边是否一致我的做法是用固定密钥、固定IV、固定明文两边各加密一次对比Base64结果。如果Base64完全一样说明填充、模式、编码全对齐了。如果不一样再逐步排查。排查顺序我一般这么走先确认密钥长度是否一致。C#的32字节密钥Java那边是不是也32字节Python是不是也32字节长度不对直接报错。再确认IV是否一致。IV是16字节两边传的是不是同一个再确认模式。都是CBC吗有没有一边用了ECB最后确认填充。都是PKCS7吗如果以上都对检查编码。明文是UTF-8编码吗Base64是标准Base64还是URL安全的Base64这个顺序是从粗到细能快速定位问题。我遇到过最隐蔽的一次是Java端用了Base64.getUrlEncoder()把和/换成了-和_C#端用标准Base64解码就失败了。这种问题看密文特征能发现标准Base64里出现-或_就是URL安全变体。5. 填充异常排查实录常见报错与解决路径5.1 “Padding is invalid and cannot be removed”的六种成因这个异常是C# AES解密时最常见的报错字面意思是“填充无效无法移除”。它可能由多种原因引起我按出现频率从高到低排列第一种密钥或IV不对。这是最常见的原因。密钥错一个字节解密出来的最后一个字节就不是合法的填充值直接抛异常。排查方法确认密钥和IV的字节数组完全一致可以用BitConverter.ToString(key)打印出来对比。第二种填充模式不匹配。加密用PKCS7解密用Zeros必然报错。排查方法检查两边代码的Padding属性。第三种密文被截断或篡改。Base64传输过程中如果少了字符解码后的字节数不对解密也会失败。排查方法对比密文的Base64长度或者用MD5校验密文完整性。第四种编码问题。明文用UTF-8编码解密后却用GBK解码虽然不会直接报填充异常但会得到乱码。如果乱码里恰好包含非法填充字节也可能触发异常。排查方法统一用UTF-8。第五种IV拼接顺序错误。如果IV拼在密文前面解密时却从后面取IV那IV就是错的。排查方法打印拼接后的字节数组确认IV的位置。第六种密钥派生方式不同。有些系统不直接用原始密钥而是用PBKDF2、MD5等派生。如果一边用原始密钥一边用派生密钥必然对不上。排查方法确认密钥是直接使用还是派生得到。我把这六种成因和排查方法整理成速查表报错成因典型现象排查方法解决方式密钥/IV不对必现异常打印字节数组对比统一密钥和IV填充模式不匹配必现异常检查Padding属性两边改成一致密文截断偶现或必现校验Base64长度检查传输环节编码不一致乱码或异常统一UTF-8修改编码设置IV位置错误必现异常打印拼接字节修正拼接顺序密钥派生不同必现异常确认派生算法统一派生方式5.2 解密出来是乱码但没报异常这种情况比直接报异常更麻烦因为程序不报错但数据是错的。常见原因有三个。第一个IV不对但恰好能解密。CBC模式下IV只影响第一个明文块。如果IV错了第一个16字节的明文块会乱但后面的块可能正常。如果业务数据的前16字节恰好不重要或者被截掉了就发现不了。排查方法对比解密后完整数据的第一个块。第二个填充模式用了Zeros但明文末尾有0x00。Zeros填充去掉末尾所有0x00如果原始数据末尾本来就有0x00会被误删。排查方法改用PKCS7或者确认明文不含0x00结尾。第三个字符编码不一致。加密端用UTF-8解密端用GBK中文会乱码。排查方法统一编码或者在解密后打印字节数组的十六进制对比原始明文的十六进制。实操心得我习惯在解密方法里加一个可选的日志开关把解密后的字节数组以十六进制打印出来。这样即使出现乱码也能通过对比十六进制快速定位是编码问题还是数据问题。这个习惯帮我省了大量排查时间。5.3 填充相关的性能考量填充本身的计算量极小对性能的影响可以忽略。但有一个场景需要注意如果加密大量短数据PKCS7在明文恰好16整数倍时会多补一整块导致密文长度增加16字节。如果业务对密文长度敏感比如有字段长度限制这个额外开销要算进去。举个例子明文是32字节PKCS7填充后变成48字节Base64后是64字符。如果数据库字段限制是60字符就会截断。这种情况要么改字段长度要么换用Zeros填充但要注意0x00问题要么在上层做压缩。另外Aes.Create()每次调用都会创建新实例如果高频调用建议复用Aes实例或者用对象池。不过Aes实例不是线程安全的复用时要加锁或者用ThreadLocal。我一般在高频场景下用ThreadLocalAes来避免重复创建的开销。6. 进阶技巧与安全加固建议6.1 密钥管理别把密钥硬编码在代码里我见过太多项目把AES密钥直接写在代码里private static readonly byte[] Key { 0x01, 0x02, ... }。这样做的风险是代码一旦泄露密钥就泄露了。更糟的是如果代码进了版本控制密钥就永久留在历史记录里了。正确的做法是把密钥放在配置文件或环境变量里并且配置文件不纳入版本控制。如果条件允许用密钥管理服务KMS来托管密钥。C#里可以用Environment.GetEnvironmentVariable读取环境变量或者用ConfigurationManager读取加密后的配置文件。如果非要在代码里存密钥至少做一层混淆比如把密钥拆成几段运行时拼接。但这只是提高门槛不能从根本上解决问题。安全的核心是密钥不落地、不硬编码。6.2 完整性校验加密不等于防篡改AES的CBC模式只保证机密性不保证完整性。也就是说攻击者可以在不知道密钥的情况下翻转密文中的某些位导致解密后的明文发生可预测的变化。这在某些业务场景下是危险的比如加密的是转账金额攻击者可能通过篡改密文把金额改大。解决办法是加一个消息认证码MAC比如HMAC-SHA256。加密后用另一个密钥对密文计算HMAC把HMAC和密文一起传输。解密方先验证HMAC再解密。这样任何篡改都会导致HMAC验证失败。C#里可以用HMACSHA256类来实现using (var hmac new HMACSHA256(macKey)) { byte[] mac hmac.ComputeHash(cipherBytes); // 把mac和cipherBytes一起传输 }注意MAC密钥必须和加密密钥不同否则会引入安全漏洞。这是密码学的基本要求别图省事用同一个。6.3 大数据的填充处理策略如果要加密的数据很大比如一个几百MB的文件一次性读入内存加密会导致内存暴涨。这时候要用流式加密分块读取、分块加密。C#的CryptoStream就是干这个的using (FileStream fsInput File.OpenRead(plain.dat)) using (FileStream fsOutput File.Create(cipher.dat)) using (Aes aes Aes.Create()) { aes.Key key; aes.IV iv; aes.Mode CipherMode.CBC; aes.Padding PaddingMode.PKCS7; using (CryptoStream cs new CryptoStream(fsOutput, aes.CreateEncryptor(), CryptoStreamMode.Write)) { fsInput.CopyTo(cs); } }CryptoStream会自动处理分块和填充最后一块自动补PKCS7。解密时同理用CreateDecryptor。这种方式内存占用恒定适合大文件。但要注意流式加密时IV只能影响第一个块后续块靠前一块的密文链接。如果中途某一块损坏后面的块全部解密失败。所以流式加密适合对完整性要求不极端的场景极端场景还是要配合MAC。6.4 填充模式None的正确使用姿势PaddingMode.None不是不能用而是要知道什么时候用。我总结了两类适用场景。第一类数据本身已经16字节对齐。比如某些二进制协议包头固定16字节包体也是16字节的整数倍。这时候用None可以省去填充开销密文长度和明文长度完全一致。第二类上层已经做了填充。比如你自己实现了一套填充规则或者用了某种编码如Base64把数据长度补齐到16的倍数。这时候AES层用None避免双重填充。用None时加密前必须校验数据长度if (plainBytes.Length % 16 ! 0) { throw new ArgumentException(明文长度必须是16的整数倍); }这个校验不能省否则TransformFinalBlock会直接抛异常错误信息不如自定义的清晰。7. 我踩过的坑与实战经验汇总7.1 一个真实对接案例的完整复盘之前对接一个外部系统对方文档写的是“AES加密CBC模式PKCS5填充密钥32字节IV16字节”。我按这个写了C#代码用PKCS7填充测试环境一直报填充异常。查了半天发现对方实际用的是ECB模式文档写错了。ECB模式下IV被忽略我传的IV根本没起作用解密时对方用ECB解自然对不上。这个案例的教训是文档不一定准一定要用固定向量验证。我后来养成了习惯对接前先让对方提供一个加密后的样例密文和对应的明文我用同样的密钥和IV加密对比结果。如果对不上再逐项排查。这个步骤花不了几分钟但能避免几天的扯皮。7.2 填充模式选择的经验法则经过多个项目我总结了一个简单的选择法则新项目、无历史包袱PKCS7没有例外。对接Java系统PKCS7因为Java的PKCS5Padding在AES下就是PKCS7。对接Python系统PKCS7pycryptodome和cryptography默认都是PKCS7。对接老系统且文档明确写ZerosZeros但要确认明文不含0x00结尾。对接硬件设备或二进制协议None但要自己保证16字节对齐。其他情况先问对方别猜。这个法则覆盖了我遇到过的95%以上的场景。剩下的5%基本都是文档写错或者对方也不清楚需要实际验证。7.3 调试AES加解密的实用技巧最后分享几个我常用的调试技巧。技巧一用固定向量测试。密钥全0IV全0明文Hello AES Test!!加密后对比Base64。这个向量可以在不同语言、不同平台间快速验证实现是否一致。技巧二打印中间结果。加密前打印明文字节数组的十六进制填充后打印填充后的字节数组加密后打印密文的十六进制。这样每一步都能看到数据变化定位问题很快。技巧三用在线工具交叉验证。有些在线AES工具可以指定模式、填充、密钥、IV输入明文输出密文。用同样的参数在C#里跑一遍对比结果。但要注意在线工具的安全性无法保证绝对不要用真实业务数据去测试用测试数据即可。技巧四写单元测试。把加解密逻辑写成单元测试用固定的密钥、IV、明文断言加密结果等于预期的Base64解密结果等于原始明文。这样每次改代码都能快速回归避免改出填充问题。[TestMethod] public void TestAesPkcs7RoundTrip() { byte[] key new byte[32]; // 全0密钥仅测试用 byte[] iv new byte[16]; // 全0 IV仅测试用 string plainText Hello AES Test!!; string cipher AesHelper.Encrypt(plainText, key, iv); string decrypted AesHelper.Decrypt(cipher, key, iv); Assert.AreEqual(plainText, decrypted); }这个测试虽然简单但能覆盖填充的核心逻辑。如果哪天有人改了填充模式测试立刻就会失败。7.4 关于填充模式None的一个补充用None时如果明文长度不是16的整数倍TransformFinalBlock会抛CryptographicException错误信息是“Length of the data to decrypt is invalid”。这个错误和填充异常不同它发生在解密阶段因为密文长度不是16的整数倍。排查时看到这个错误先检查密文长度而不是填充模式。另外用None加密时如果明文长度是16的整数倍密文长度等于明文长度。但如果明文长度不是16的整数倍加密直接失败。所以用None的场景数据长度必须是可控的、确定的。我在实际项目里用None的次数屈指可数基本都是对接特定的二进制协议。绝大多数业务场景PKCS7是唯一正确的选择。记住这一点能帮你避开大部分填充相关的坑。