一个看起来天经地义的"给消息签名"写法,如何让攻击者在不知道密钥的情况下伪造出合法签名。
哈希能证明数据"没被改过"——但只有当攻击者无法自己算出正确哈希时才成立。哈希没有密钥,人人可算,那怎么用它来认证(证明消息来自持有密钥的人)?第一个念头几乎人人都会想到,而它恰好是个漂亮的陷阱。
场景:App 给服务器发请求,想防止别人篡改参数。直觉做法是让双方共享一个密钥 secret,然后把密钥拼在消息前面一起哈希,附在请求后当"签名":
服务器收到后,用同样的 secret 重算一遍 MAC,对得上就认为消息可信。攻击者不知道 secret,似乎就算不出合法的 MAC……对吧?
大错特错。攻击者不需要知道 secret,就能在你的消息后面追加任意内容,并算出追加后消息的合法 MAC。这个漏洞叫长度扩展攻击(length extension attack),它的根源正是第 10 章埋下的伏笔:SHA-256 的 Merkle–Damgård 结构。
回忆第 10 章:SHA-256 逐块处理消息,维护 8 个 32 位寄存器作为"内部状态",处理完最后一块后,这个内部状态就直接当作输出吐出来。
问题就在这:哈希的输出,恰恰是它处理完 secret‖message 之后的完整内部状态。攻击者拿到这个 MAC,等于拿到了那台哈希机器的"存档点"。他可以:
&admin=true;SHA256(secret‖message‖padding‖&admin=true) 的合法值。他唯一需要补的是那段 padding(粘合字节)——即 SHA-256 在 secret‖message 末尾本会自动补上的填充(第 10 章说的"补 1、补 0、写长度")。这段填充只依赖 secret‖message 的长度,而长度攻击者可以猜(secret 长度不知道就从 1 试到 30)。攻击者甚至不需要知道 secret 的内容,只需要它的长度。
下面的场景:一个服务器用 MAC = SHA256(secret‖msg) 校验请求。你是攻击者,截获了一条合法请求和它的 MAC,但不知道 secret(它对你是隐藏的)。你的目标:构造一条以 &role=admin 结尾的新请求,并让它通过服务器校验。
想象一个用 SHA256(secret‖params) 签名的支付/权限接口。攻击者截获一条你自己发出的合法请求(比如 user=guest),就能在后面追加 &user=admin 或 &amount=0,并附上合法签名——服务器一验,通过。很多解析器"后出现的参数覆盖先出现的",于是权限提升或金额篡改就这么发生了。MD5、SHA-1、SHA-256、SHA-512 全都有这个问题,因为它们同属 Merkle–Damgård 结构。
修复的关键洞察:别让"密钥拼消息"的中间状态直接暴露成输出。HMAC(Hash-based MAC)用一个巧妙的嵌套结构做到这点:
其中 ipad、opad 是两个固定常量。它做了两层哈希:
H((K⊕ipad)‖m) 算出一个中间哈希;长度扩展攻击为什么失效了?因为攻击者拿到的 HMAC 是外层哈希的输出,而外层哈希的输入(内层结果)已经结束、被密钥"封了口"。攻击者想追加内容,得从内层继续算——可内层的中间状态他看不到(它被外层哈希吞掉了),更别说外层还需要他不知道的密钥。两层嵌套把那个致命的"存档点"藏死了。
① 需要"用共享密钥认证消息",用 HMAC-SHA256,别自己拼 hash(key+msg)。Android:Mac.getInstance("HmacSHA256")。
② 比较 MAC 时用常数时间比较(如 MessageDigest.isEqual),别用普通 == / equals——否则会泄露"前几个字节对了"的时间信息,形成时间侧信道(又一种 oracle)。
③ 如果你要的是"加密+认证",别手动拼 HMAC,直接用第 9 章的 AEAD——它内部已经把这些做对了。SHA-3 和 BLAKE2 天生免疫长度扩展,但 HMAC 是跨算法的通用保险。
至此,哈希家族的性质、弱点和正确用法都齐了。Phase 3 还剩最后一个高频实战问题:用户密码到底该怎么存进数据库,才能在被拖库之后依然扛得住破解?答案会让你重新理解"慢"这个词。下一章。
H(secret‖msg) 做 MAC 是陷阱:Merkle–Damgård 哈希的输出即内部状态,可被"续算"。