bigywater.tech
Python · 77

HMAC:为什么“哈希加密钥”不能随便自己拼

「密钥拼在消息前面再取哈希」看起来很合理,却是一个经典的错误。这篇不只是说它错——而是真的写了一个 SHA-256、真的伪造出一个服务端认可的签名,然后换成 HMAC 再试一遍。

16 字节密钥
攻击者从未见过它,却伪造出通过验证的 role=admin 签名
False
同样的攻击对 HMAC-SHA256 验证失败
0.04 vs 632 µs
== 比较第 1 字节不同与最后 1 字节不同的耗时

做的事:服务端用 sha256(secret + message) 签名

服务端(有缺陷的做法) secret = os.urandom(16) def server_sign(msg): return hashlib.sha256(secret + msg).hexdigest() def server_verify(msg, tag): return hmac.compare_digest(server_sign(msg), tag) msg = b"user=ann&role=user" tag = server_sign(msg) # 发给客户端

要做长度扩展攻击,得能「从别人给的哈希值继续算」。标准库的 hashlib 不提供这个接口,所以我自己写了一个纯 Python 的 SHA-256,允许指定初始状态。先确认它没写错:

实测 · 本机执行输出
  与 hashlib.sha256 对拍 6 组输入, 全部一致? True

验证一:伪造出一个服务端认可的 role=admin

攻击者只知道 message、tag、密钥长度
实测 · 本机执行输出
  原始 message: b'user=ann&role=user'
  服务端给的 tag: 7ff86db3fff3b663bcdd136ec4a389f0006e34182fcd37d0bb883d6c7571b861
  攻击者不知道 secret, 只知道它的长度 = 16 字节
  中间填充 glue (30 字节): b'\x80\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x01\x10'
  伪造的 message: b'user=ann&role=user\x80\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x01\x10&role=admin'
  伪造的 tag: b6412ac7df383c7848efadeb7957c0660c616e6a93c908b97ae803bec3e9dec7
  服务端验证伪造消息 → True   (攻击者从没见过 secret)
  服务端自己给伪造消息签的 tag 与伪造的一致? True
这里的 tag 不是被「破解」了,密钥也没被泄露——攻击者只是利用了 SHA-256 的结构:输出的摘要就是它处理完输入后的内部状态,拿它当起点可以继续往后算。而服务端验证时也是同样从头算,两边的结果自然一样。伪造的消息里夹着 30 字节的 \x80\x00… 填充,这是能通过验证的代价——如果服务端解析消息时对这些字节不敏感(比如 URL 参数里后出现的 role 覆盖前面的),攻击就成立了。

验证二:HMAC 挡住了同样的攻击

实测 · 本机执行输出
  HMAC tag: 2d9fc117efec57e01780bf96310d8c302bc8fd095f8e1ef06c7b74e7298fa2f1
  同样构造的伪造 tag 通过 HMAC 验证? False
  HMAC 对伪造消息真实的 tag 前 16 位: 9e1851221e19136a vs 伪造的前 16 位: a034aa0783955e54
  hmac.new 忘记写 digestmod → TypeError: Missing required argument 'digestmod'.

同样的方法对 hmac.new(secret, msg, sha256) 的 tag 做,得到的伪造 tag 通不过验证——HMAC 会对「密钥+消息」再套一层哈希,输出的摘要不再等于可继续的内部状态。另外 hmac.new 在 3.14 里必须显式传 digestmod,忘了会直接 TypeError,不会悄悄用某个弱默认值。

验证三:比较 tag 也有讲究

== 会在第一个不同的字节处提前返回
实测 · 本机执行输出
  两个 20 MB 的 bytes, 分别在第 1 字节 / 最后 1 字节不同 (取 20 次最小值):
    a == b               第1字节不同       0.04 µs   最后1字节不同     632.12 µs
    hmac.compare_digest  第1字节不同    8108.42 µs   最后1字节不同    8105.88 µs

两段 20 MB 的数据,== 在第 1 个字节就不同时只要 0.04 µs,最后 1 个字节才不同时要 632 µs——耗时暴露了「前面有几个字节是对的」,攻击者可以据此逐字节猜出正确的 tag(本次只测了本机内存里的比较,没有在网络上真的猜)。hmac.compare_digest 不管哪里不同都是 8108 µs 上下,不泄露位置。代价是它总要比较完整个长度,所以只应该用于比较密钥相关的值。

分步演示:一次长度扩展攻击

哈希值取自上面实验(截断显示)
伪造签名,不需要密钥
这一步的关键数据

    本文用到的工具与边界

    • 本机 macOS 27 / Python 3.14.7,标准库 hashlib、hmac;SHA-256 为自己实现,已与 hashlib 对拍 6 组输入一致
    • 没有验证:SHA-3 / BLAKE2 等对长度扩展免疫的哈希、真实网络环境下用时间差猜 tag 的可行性(本次只测了本机内存比较的耗时差)、密钥长度未知时逐个尝试的成本
    京ICP备2026067502号-1京公网安备11010502063487号