Skip to content

用了 AES,密文还是被改了 ​

一个转账请求在传输前用 AES 加了密,密钥只有两端的服务知道。安全测试的报告里写着:不需要密钥,把金额从 100 改成了 900,服务端正常解密并执行了。代码里用的是 JDK 自带的 Cipher,没有自己实现任何算法。

「不要自己发明加密算法」这句话大家都听过。真正出问题的地方通常在下一层:算法是标准的,库是正规的,但模式选错了、随机数重复了、只加密没有认证,或者把给文件校验用的哈希拿来存口令。这些写法都能编译、能运行,加密再解密也能得到原文,功能测试全部通过。

本文把几种常见的写法各攻破一次。所有实验都在本地用固定的演示密钥进行,见文末。

密钥 + 随机数 N密钥流 KS同一对(密钥,N)永远得到同一段 KS明文 1:pay 100 to alice⊕ KS =密文 1明文 2:pay 900 to carol⊕ KS =密文 2密文 1 ⊕ 密文 2= 明文 1 ⊕ 明文 2已知明文 1 → 明文 2 = 密文 1 ⊕ 密文 2 ⊕ 明文 1实验里算出的结果:pay 900 to carol, memo: bonusJDK 只在「同一个 Cipher 对象连续加密」时拦截;每次新建 Cipher、传入同样的随机数,没有任何报错
图 1 · 实测:GCM 的加密部分是「明文异或一段密钥流」,密钥流由密钥和随机数决定。两条消息用了同一个密钥和同一个随机数,密钥流就是同一段。把两段密文异或,密钥流被消掉,剩下两段明文的异或;攻击者只要知道其中一条的内容,就能直接算出另一条,全程不需要密钥。

一、Cipher.getInstance("AES") 是什么模式 ​

text
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)。它不需要保密,但同一个密钥下不能重复。重复之后的结果不是「安全性下降」,而是直接可解:

text
两条消息用了同一个随机数:两段密文异或 == 两段明文异或 = true
攻击者知道第一条的明文、截获两段密文,不需要密钥算出第二条:pay 900 to carol, memo: bonus

GCM 的加密是用密钥和随机数生成一段密钥流,再与明文异或。密钥和随机数都相同,密钥流就相同,两段密文一异或,密钥流被抵消。知道其中一条的明文(格式固定的报文、能自己触发的请求),就能算出另一条。随机数重复还会泄露认证用的密钥,使攻击者能够伪造密文。

JDK 有一道保护,但只管得了一种情况:

text
同一个 Cipher 对象不重新 init 就加密第二条:抛出 IllegalStateException(Must use either different key or  iv for GCM encryption)
每次 new 一个 Cipher、传入同样的随机数:45 字节,没有任何报错

常见的重复来源:

  • 随机数写成了常量,或者从配置里读一个固定值。
  • 用计数器生成,进程重启后计数器从头开始。
  • 多个实例共用一个密钥,各自从同一个起点计数。

做法是每次加密用 SecureRandom 生成 12 字节的随机数,与密文一起保存(通常放在密文前面)。随机生成的 96 位随机数在同一个密钥下大约 2 的 32 次方条消息之内,碰撞概率可以忽略;量更大时要轮换密钥。

三、只加密,不认证 ​

开头的转账请求用的是 CBC 模式。CBC 保证看不到内容,不保证内容没被改过:

text
AES/CBC:攻击者不知道密钥,把 IV 的一个字节异或一下,解密得到 amount=900&to=bob&ref=20261010

原文是 amount=100&…。CBC 解密第一个块时,会把解密结果与 IV 异或。攻击者把 IV 的某个字节异或上 '1' ^ '9',解密出来的对应字节就从 1 变成了 9,其余不变。不需要密钥,只需要知道(或猜到)那个位置原来是什么。服务端解密成功,拿到一个格式完全正确的请求。

加密解决的是保密,防篡改需要认证。GCM 把两件事一起做了:

text
AES/GCM:同样改一个字节后解密:抛出 AEADBadTagException(Tag mismatch)

密文被改动任何一位,解密都会失败。解密失败时不要使用任何已经解出来的内容,也不要把「填充错误」和「认证失败」作为两种不同的错误返回给调用方,后者会成为另一类攻击的入口。

四、密文没有绑定它属于谁 ​

数据库里每个用户的余额字段用 GCM 加了密。能写数据库的攻击者(或者一个有缺陷的批处理)把 alice 那一行的密文原样复制到 bob 那一行:

text
把 alice 那一行的密文和随机数原样复制到 bob 那一行,解密:balance=99000

密文是合法的,解密成功,bob 的余额变成了 alice 的。GCM 保证了这段密文没有被改过,但没有人告诉它这段密文应该出现在哪里。

GCM 的附加认证数据(AAD)就是用来做这件事的:它不被加密,但参与认证。加密时把「这是谁的、哪个字段」传进去,解密时传入当前上下文的同一份信息:

text
加密时把 user=alice 作为附加认证数据,在 bob 那一行用 user=bob 解密:抛出 AEADBadTagException(Tag mismatch)

密文被挪到别的行、别的字段、别的租户,解密就失败。

五、口令怎么存 ​

口令不应该加密存储(加密意味着能解密),而是存单向的哈希。但哈希函数不是随便哪个都行。

text
SHA-256 不加盐:两个用户都用 password123,存下来的值相同 = true
单线程每秒能算的 SHA-256 超过 50 万次 = true

实验的机器上,JDK 自带的 SHA-256 单线程每秒约 443 万次。SHA-256 是为快而设计的,这对校验文件是优点,对存口令是缺点:数据库泄露之后,攻击者拿着常见口令的字典,每秒可以试几百万个,专用硬件上再高几个数量级。不加盐时,相同的口令哈希相同,一张预先算好的表可以同时用于所有用户。

存口令要用专门设计得慢、并且每个用户带独立盐的算法:

text
PBKDF2-HMAC-SHA256、60 万次迭代、每人一个盐:同样的口令存下来的值相同 = false;算一次超过 30 ms = true;猜一次的代价是不加盐 SHA-256 的 1 万倍以上 = true

实验里算一次是 298 毫秒。对用户登录来说可以接受,对攻击者来说每猜一个口令的代价高了六个数量级。同类的算法还有 bcrypt、scrypt、Argon2id,优先用框架提供的实现,不要自己拼「加盐再多哈希几轮」。

BCrypt 只看前 72 个字节 ​

BCrypt 算法本身只处理输入的前 72 个字节:

text
jBCrypt 0.4:存入 72 个 a 加 -correct-horse,用 72 个 a 加 -wrong 校验:通过 = true
24 个汉字是 72 字节;jBCrypt 存入这 24 个字加「安全」,用这 24 个字加「随便」校验:通过 = true

前 72 个字节相同,后面写什么都能通过。72 字节对英文口令很长,对中文是 24 个字,对「口令前面拼上用户名或固定前缀再哈希」的做法则很容易超过。

新版本的 Spring Security 在编码时拒绝过长的口令,但校验时仍然按算法的方式处理:

text
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 位:

text
看到 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(验证记录)

参考资料

文章以 CC BY-NC-SA 4.0 授权 · 代码片段以 MIT 授权