「密钥拼在消息前面再取哈希」看起来很合理,却是一个经典的错误。这篇不只是说它错——而是真的写了一个 SHA-256、真的伪造出一个服务端认可的签名,然后换成 HMAC 再试一遍。
服务端(有缺陷的做法) 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
原始 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
\x80\x00… 填充,这是能通过验证的代价——如果服务端解析消息时对这些字节不敏感(比如 URL 参数里后出现的 role 覆盖前面的),攻击就成立了。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,不会悄悄用某个弱默认值。
两个 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 上下,不泄露位置。代价是它总要比较完整个长度,所以只应该用于比较密钥相关的值。
hashlib、hmac;SHA-256 为自己实现,已与 hashlib 对拍 6 组输入一致