用了 AES,密文还是被改了
一个转账请求在传输前用 AES 加了密,密钥只有两端的服务知道。安全测试的报告里写着:不需要密钥,把金额从 100 改成了 900,服务端正常解密并执行了。代码里用的是 JDK 自带的
Cipher,没有自己实现任何算法。
「不要自己发明加密算法」这句话大家都听过。真正出问题的地方通常在下一层:算法是标准的,库是正规的,但模式选错了、随机数重复了、只加密没有认证,或者把给文件校验用的哈希拿来存口令。这些写法都能编译、能运行,加密再解密也能得到原文,功能测试全部通过。
本文把几种常见的写法各攻破一次。所有实验都在本地用固定的演示密钥进行,见文末。
一、Cipher.getInstance("AES") 是什么模式
Cipher.getInstance("AES") 加密三段相同的 16 字节:第 1、2、3 个密文块 7136ed88…、7136ed88…、7136ed88…;三块完全相同 = true
同样的明文用 AES/GCM/NoPadding:前两个密文块相同 = false只写算法名、不写模式时,JDK 用的是 ECB:每 16 字节独立加密,相同的明文块得到相同的密文块。攻击者不需要解密,光看密文就知道哪些记录的内容相同、一条消息里哪些部分重复,还可以把密文块调换顺序或者替换成另一条消息里的块。
Cipher.getInstance 永远写完整的三段式,新代码用 AES/GCM/NoPadding。
二、GCM 的随机数重复了
GCM 每次加密需要一个随机数(nonce,JDK 的 API 里叫 IV)。它不需要保密,但同一个密钥下不能重复。重复之后的结果不是「安全性下降」,而是直接可解:
两条消息用了同一个随机数:两段密文异或 == 两段明文异或 = true
攻击者知道第一条的明文、截获两段密文,不需要密钥算出第二条:pay 900 to carol, memo: bonusGCM 的加密是用密钥和随机数生成一段密钥流,再与明文异或。密钥和随机数都相同,密钥流就相同,两段密文一异或,密钥流被抵消。知道其中一条的明文(格式固定的报文、能自己触发的请求),就能算出另一条。随机数重复还会泄露认证用的密钥,使攻击者能够伪造密文。
JDK 有一道保护,但只管得了一种情况:
同一个 Cipher 对象不重新 init 就加密第二条:抛出 IllegalStateException(Must use either different key or iv for GCM encryption)
每次 new 一个 Cipher、传入同样的随机数:45 字节,没有任何报错常见的重复来源:
- 随机数写成了常量,或者从配置里读一个固定值。
- 用计数器生成,进程重启后计数器从头开始。
- 多个实例共用一个密钥,各自从同一个起点计数。
做法是每次加密用 SecureRandom 生成 12 字节的随机数,与密文一起保存(通常放在密文前面)。随机生成的 96 位随机数在同一个密钥下大约 2 的 32 次方条消息之内,碰撞概率可以忽略;量更大时要轮换密钥。
三、只加密,不认证
开头的转账请求用的是 CBC 模式。CBC 保证看不到内容,不保证内容没被改过:
AES/CBC:攻击者不知道密钥,把 IV 的一个字节异或一下,解密得到 amount=900&to=bob&ref=20261010原文是 amount=100&…。CBC 解密第一个块时,会把解密结果与 IV 异或。攻击者把 IV 的某个字节异或上 '1' ^ '9',解密出来的对应字节就从 1 变成了 9,其余不变。不需要密钥,只需要知道(或猜到)那个位置原来是什么。服务端解密成功,拿到一个格式完全正确的请求。
加密解决的是保密,防篡改需要认证。GCM 把两件事一起做了:
AES/GCM:同样改一个字节后解密:抛出 AEADBadTagException(Tag mismatch)密文被改动任何一位,解密都会失败。解密失败时不要使用任何已经解出来的内容,也不要把「填充错误」和「认证失败」作为两种不同的错误返回给调用方,后者会成为另一类攻击的入口。
四、密文没有绑定它属于谁
数据库里每个用户的余额字段用 GCM 加了密。能写数据库的攻击者(或者一个有缺陷的批处理)把 alice 那一行的密文原样复制到 bob 那一行:
把 alice 那一行的密文和随机数原样复制到 bob 那一行,解密:balance=99000密文是合法的,解密成功,bob 的余额变成了 alice 的。GCM 保证了这段密文没有被改过,但没有人告诉它这段密文应该出现在哪里。
GCM 的附加认证数据(AAD)就是用来做这件事的:它不被加密,但参与认证。加密时把「这是谁的、哪个字段」传进去,解密时传入当前上下文的同一份信息:
加密时把 user=alice 作为附加认证数据,在 bob 那一行用 user=bob 解密:抛出 AEADBadTagException(Tag mismatch)密文被挪到别的行、别的字段、别的租户,解密就失败。
五、口令怎么存
口令不应该加密存储(加密意味着能解密),而是存单向的哈希。但哈希函数不是随便哪个都行。
SHA-256 不加盐:两个用户都用 password123,存下来的值相同 = true
单线程每秒能算的 SHA-256 超过 50 万次 = true实验的机器上,JDK 自带的 SHA-256 单线程每秒约 443 万次。SHA-256 是为快而设计的,这对校验文件是优点,对存口令是缺点:数据库泄露之后,攻击者拿着常见口令的字典,每秒可以试几百万个,专用硬件上再高几个数量级。不加盐时,相同的口令哈希相同,一张预先算好的表可以同时用于所有用户。
存口令要用专门设计得慢、并且每个用户带独立盐的算法:
PBKDF2-HMAC-SHA256、60 万次迭代、每人一个盐:同样的口令存下来的值相同 = false;算一次超过 30 ms = true;猜一次的代价是不加盐 SHA-256 的 1 万倍以上 = true实验里算一次是 298 毫秒。对用户登录来说可以接受,对攻击者来说每猜一个口令的代价高了六个数量级。同类的算法还有 bcrypt、scrypt、Argon2id,优先用框架提供的实现,不要自己拼「加盐再多哈希几轮」。
BCrypt 只看前 72 个字节
BCrypt 算法本身只处理输入的前 72 个字节:
jBCrypt 0.4:存入 72 个 a 加 -correct-horse,用 72 个 a 加 -wrong 校验:通过 = true
24 个汉字是 72 字节;jBCrypt 存入这 24 个字加「安全」,用这 24 个字加「随便」校验:通过 = true前 72 个字节相同,后面写什么都能通过。72 字节对英文口令很长,对中文是 24 个字,对「口令前面拼上用户名或固定前缀再哈希」的做法则很容易超过。
新版本的 Spring Security 在编码时拒绝过长的口令,但校验时仍然按算法的方式处理:
Spring Security 7.1.1 的 BCryptPasswordEncoder.encode 传入这个 86 字节的口令:抛出 IllegalArgumentException(password cannot be more than 72 bytes)
用它校验旧库存下来的哈希,输入原来那个 86 字节的正确口令:matches = true
输入 72 个 a 加 -wrong:matches = true升级到这样的版本时要注意两点:注册和改密的接口对超过 72 字节的口令会抛异常,要在这之前校验长度并给出明确的提示;已经存在库里的、由更长口令生成的哈希仍然可以登录,截断的性质也仍然存在。
六、用 java.util.Random 生成令牌
重置密码的链接、会话标识、验证码需要的是「猜不到」。java.util.Random 提供的是「分布均匀」,它的内部状态只有 48 位,而且每个输出直接暴露其中的 32 位:
看到 java.util.Random 连续输出的两个 int,最多试 65536 次就能算出下一个:预测值与实际值相同 = true攻击者给自己申请两次重置令牌,就能算出生成器的状态,进而预测下一个发给别人的令牌。Math.random()、ThreadLocalRandom 同样不是为安全用途设计的。
任何需要不可预测的值都用 SecureRandom;UUID.randomUUID() 内部用的也是它。
七、检查清单
Cipher.getInstance写完整的「算法/模式/填充」,对称加密默认用AES/GCM/NoPadding。- GCM 的随机数每次用
SecureRandom生成,随密文保存;同一个密钥下的加密量有上限,到了就轮换。 - 不使用只加密不认证的模式;解密失败时丢弃全部结果,对外只返回一种错误。
- 加密存储的字段用附加认证数据绑定所属的记录与字段。
- 口令用 Argon2id、bcrypt、scrypt 或 PBKDF2,每个用户独立的盐,参数按当前的推荐值;知道 bcrypt 的 72 字节上限。
- 令牌、验证码、盐、密钥用
SecureRandom。 - 密钥不写在代码和配置文件里,这一条不在本文的实验范围内,但前面的每一条都以它为前提。
传输中的数据优先交给 TLS,而不是在应用层自己加密一遍;TLS 的证书校验问题见浏览器能打开的 HTTPS 地址,Java 调用报 PKIX。
配套实验
- codesphere-labs/engineering/crypto-misuse:JDK 25.0.4.1 自带的 JCE、jBCrypt 0.4、Spring Security 7.1.1,固定的演示密钥:默认的 ECB、GCM 随机数重复、CBC 改 IV、密文换行与附加认证数据、SHA-256 与 PBKDF2 存口令、BCrypt 的 72 字节、预测
java.util.Random(验证记录)
参考资料
- NIST SP 800-38D:Galois/Counter Mode (GCM)(随机数唯一性的要求与使用上限)
- RFC 5116:An Interface and Algorithms for Authenticated Encryption(附加认证数据)
- Java SE 25:Java Cryptography Architecture 参考指南
- Java SE 25 API:SecureRandom
- OWASP:Password Storage Cheat Sheet(各算法的推荐参数、bcrypt 的输入长度限制)
- OWASP:Cryptographic Storage Cheat Sheet
- Spring Security 文档:Password Storage