HMAC 和普通哈希差在哪:从长度扩展攻击讲到常数时间比较

从一个支付回调说起

你做了个接收支付回调的接口。平台每次发请求过来,结构大概长这样:

POST /api/payment/callback HTTP/1.1
Content-Type: application/json
X-Timestamp: 1725148800
X-Signature: 9f86d081884c...(64 位十六进制)

{"order_id":"2024001","amount":100,"status":"paid"}

X-Signature 是签名。平台用你俩事先约定好的密钥,对请求体算了一个哈希值放在这里。你收到请求后用同样的密钥重新算一遍,跟 header 里的对上了,才相信这条消息确实是平台发的、中间没被改过。

签名的计算逻辑通常是:

sign := sha256.Sum256([]byte(secret + body))

密钥拼在前面、整体过一遍 SHA-256,看起来该有的都有了:不知道 secret 就算不出 sign,SHA-256 也没被破。

但这段代码是有洞的。攻击者可以在完全不知道 secret 的前提下,往 body 后面追加内容,并且算出一个服务端认可的新签名。

下面从这个洞出发,把 HMAC 和普通哈希的区别、性能代价、以及周边那一圈容易踩的坑(盐、pepper、慢哈希、时序攻击)过一遍。

1. 哈希和 HMAC 分别解决什么问题

先说一个最关键的区别:哈希谁都能算,HMAC 只有拿着密钥的人能算。

1.1 哈希:验完整性,不验身份

你从 GitHub 下了个安装包,Release 页面上写着 SHA-256 是 a1b2c3...。下完在本地跑一遍 sha256sum,对上了,说明文件在传输过程中没损坏。

但这里有个前提:你信任那个 SHA-256 确实是 GitHub 给的。因为 SHA-256 是公开算法,谁都能算。如果有人同时替换了安装包和校验值,你看不出来。

所以裸哈希只能防意外损坏(网络丢包、磁盘坏块)。要是有人蓄意篡改,光靠哈希是防不住的。

1.2 HMAC:验完整性,也验身份

HMAC 多了一个密钥。只有知道密钥的人才能算出正确的值。

回到支付回调的场景:平台和你事先约定了一个 secret,平台每次发请求时用 HMAC(secret, payload) 算一个签名带在 header 里。你收到后用同一个 secret 重新算一遍,对上了就说明两件事:

  1. 消息没被改过
  2. 消息来自知道 secret 的人(也就是平台自己)

攻击者就算在中间截到了请求、改了 payload,他也算不出新的 HMAC,因为他没有 secret。

需要密钥吗谁能算防什么
哈希 (SHA-256)不需要谁都能意外损坏
HMAC需要只有持钥方意外损坏 + 恶意篡改 + 身份伪造

一句话:你防的是手滑还是防的是人?防人就得有密钥,就该用 HMAC。具体什么场景对应什么方案,第 5.4 节有一张完整的对照表。

补充两个容易混的概念。加密管的是”别人看不见”(可逆),HMAC 管的是”别人改不了”(不可逆),两者解决的不是同一个问题。还有数字签名(Ed25519、RSA),和 HMAC 一样管”改不了”,但用的是非对称密钥:签名方和验证方拿的不是同一个密钥,所以可以对第三方举证。HMAC 是对称的,验证方自己也能伪造,当不了证据。

2. 那直接 hash(secret + message) 不就行了

上一节说了要有密钥。那把密钥拼在消息前面再 hash,不知道密钥就算不出来,问题解决了?

暴力猜密钥确实不现实。但攻击者有别的路。

2.1 不需要知道密钥就能伪造

先说攻击能做到什么,再解释为什么。

假设服务端的签名方式是 tag = SHA256(secret + "user=alice&role=guest")。攻击者不知道 secret,但他拿到了这个 tag(从 cookie、URL 参数、响应体里都有可能)。他能做到:

  1. 在消息尾部追加 &role=admin
  2. 纯靠已有的 tag 算出一个新 tag
  3. 把新消息和新 tag 发给服务端
  4. 服务端验签通过,攻击者提权成功

整个过程不需要知道 secret 的内容,只需要猜到它的长度。不知道长度就从 1 试到 64,几十次请求就够。

这就是长度扩展攻击(Length Extension Attack)。Flickr 的 API 签名在 2009 年就栽在这上面,之后 Vimeo 等一批用了同样方案的服务也中过招。

2.2 为什么能做到

要理解这个攻击,先要知道 SHA-256 内部是怎么工作的。不用懂数学细节,只需要知道三件事。

SHA-256 内部有 8 个数。 具体来说是 8 个 32 位整数,规范里叫 A、B、C、D、E、F、G、H,一共 256 位。“SHA-256”名字里的 256 就是这么来的。计算开始之前,这 8 个数被设成固定的初始值(所有人都一样)。

数据是一块一块喂进去的。 SHA-256 把输入切成 64 字节一块。每喂进去一块,内部那 8 个数就经过一轮复杂运算更新一次。全部喂完之后,还会补一段填充(padding),填充里包含了输入的总比特数。

最终输出就是这 8 个数拼在一起。 没有任何额外处理。A B C D E F G H 按顺序拼起来,就是你看到的那 64 个十六进制字符(32 字节)。

初始状态 A₀B₀C₀D₀E₀F₀G₀H₀
    ↓ 喂入 block0
更新为   A₁B₁C₁D₁E₁F₁G₁H₁
    ↓ 喂入 block1
更新为   A₂B₂C₂D₂E₂F₂G₂H₂
    ↓ 喂入 block2(含 padding)
最终状态 A₃B₃C₃D₃E₃F₃G₃H₃  →  直接拼起来输出 = 哈希值

问题就在第三条。拿到哈希值 = 拿到 A₃B₃C₃…H₃ = 拿到了这台机器处理完所有数据之后的完整状态。

攻击者可以造一台新的 SHA-256 机器,把 A B C D E F G H 直接设成从哈希值里拆出来的那 8 个数,然后继续往里喂新数据(&role=admin)。新机器不需要知道前面喂过什么(secret 是什么、message 是什么),它只需要知道”机器现在停在哪”,就能接着往下算。

这就是下面 PoC 里 forge 函数做的事:把 tag(也就是 A₃…H₃)塞回 sha256.New() 的内部状态,然后 Write(extra),得到的新哈希和”从头喂完 secret + message + padding + extra”完全一样。

为什么要猜 secret 的长度? SHA-256 补的 padding 里包含输入的总比特数。攻击者知道 message 有多长但不知道 secret 有多长,所以算不准 padding 的内容。猜错了 padding,伪造出来的消息跟服务端重算的就对不上。于是他从长度 1 开始往上试,每次用不同的 padding 构造伪造消息,哪个被服务端接受了就说明猜对了。密钥一般不超过 64 字节,几十次请求就能覆盖。

MD5、SHA-1、SHA-256、SHA-512 内部结构都一样(Merkle-Damgård),输出都是直接暴露内部状态,全部中招。

2.3 跑一遍看看

Go 的 crypto/sha256 实现了 encoding.BinaryUnmarshaler,正好可以直接把摘要塞回去当状态用,写出一个非常短的 PoC:

package main

import (
	"bytes"
	"crypto/sha256"
	"encoding"
	"encoding/binary"
	"encoding/hex"
	"fmt"
)

// glue 计算 SHA-256 对长度为 msgLen 的消息追加的那段填充
func glue(msgLen int) []byte {
	pad := []byte{0x80}
	for (msgLen+len(pad))%64 != 56 {
		pad = append(pad, 0)
	}
	var l [8]byte
	binary.BigEndian.PutUint64(l[:], uint64(msgLen)*8)
	return append(pad, l[:]...)
}

// forge 在只知道 tag = SHA256(secret||msg) 和 len(secret) 的前提下,
// 伪造出 SHA256(secret||msg||glue||extra)
func forge(tag []byte, secretLen, msgLen int, extra []byte) []byte {
	total := secretLen + msgLen
	padded := total + len(glue(total)) // 一定是 64 的倍数

	// 拼出 crypto/sha256 的内部状态:magic + 8 个 h 值 + 64 字节缓冲 + 已处理长度
	state := make([]byte, 0, 108)
	state = append(state, "sha\x03"...)
	state = append(state, tag...)              // 摘要就是 h[0..7]
	state = append(state, make([]byte, 64)...) // 缓冲为空
	state = binary.BigEndian.AppendUint64(state, uint64(padded))

	h := sha256.New()
	if err := h.(encoding.BinaryUnmarshaler).UnmarshalBinary(state); err != nil {
		panic(err)
	}
	h.Write(extra)
	return h.Sum(nil)
}

func main() {
	secret := []byte("s3cr3t-key-42") // 攻击者不知道内容,只需猜到长度
	msg := []byte("user=alice&role=guest")
	extra := []byte("&role=admin")

	// 服务端:tag = H(secret || msg)
	tag := sha256.Sum256(append(append([]byte{}, secret...), msg...))
	fmt.Printf("原始消息 : %s\n", msg)
	fmt.Printf("原始 tag : %s\n", hex.EncodeToString(tag[:]))

	// 攻击者:不知道 secret,直接伪造
	forged := forge(tag[:], len(secret), len(msg), extra)
	newMsg := append(append([]byte{}, msg...), glue(len(secret)+len(msg))...)
	newMsg = append(newMsg, extra...)
	fmt.Printf("伪造 tag : %s\n", hex.EncodeToString(forged))

	// 服务端拿到 newMsg 后重新计算,看看认不认
	real := sha256.Sum256(append(append([]byte{}, secret...), newMsg...))
	fmt.Printf("服务端算 : %s\n", hex.EncodeToString(real[:]))
	fmt.Printf("伪造成功 : %v\n", bytes.Equal(forged, real[:]))
	fmt.Printf("伪造消息 : %q\n", newMsg)
}

跑出来:

原始消息 : user=alice&role=guest
原始 tag : 408055b399eabef315f59cef82624346631116c2d9a1a3dc4522c36f9cc5e040
伪造 tag : d89831c23801294dac52c0ed43473bc212effe8fb331666a7369d39df06dd01c
服务端算 : d89831c23801294dac52c0ed43473bc212effe8fb331666a7369d39df06dd01c
伪造成功 : true
伪造消息 : "user=alice&role=guest\x80\x00\x00...\x00\x01\x10&role=admin"

服务端算出来的和伪造的一模一样。

看到输出里那段 \x80\x00...\x00\x01\x10 了吗?那是原始计算时 SHA-256 自己在末尾补的 padding。攻击者把它留在了新消息的中间。这里容易产生一个疑问:中间夹了 padding,这还是合法的消息吗?

答案是:对 SHA-256 来说,中间那段就是普通的字节,不是 padding。SHA-256 只在自己处理的最末尾补 padding,中间的 \x80\x00... 它当普通数据处理。这段”脏数据”之所以要留着,是因为它让新消息的分块边界和原始计算对齐:服务端处理 secret + 原始消息 + 这段旧padding 时,SHA-256 走过的块和产生的中间状态,与原始计算完全相同。接下来处理 &role=admin,就是从同一个状态继续算,结果自然和攻击者从 tag 接着算的一致。

消息中间夹的那堆二进制看着很脏,但很多解析器不在乎。net/url 会把那堆字节当成上一个参数值的一部分,&role=admin 被当成新参数。而 Query().Get("role") 取的是第一个,PHP 的 $_GET 取的是最后一个,两边解析不一致的地方就是提权的口子。

现成工具 hash_extender 一条命令就能生成 payload,连上面这段代码都不用写。

2.4 把密钥放后面呢

hash(message + secret) 确实躲开了长度扩展。但安全性全押在哈希的抗碰撞性上:如果能找到两个不同的消息 m1 ≠ m2 使得 H(m1) = H(m2),那 H(m1||secret) = H(m2||secret) 也成立,签名可以整条搬过去用。

对 SHA-256 来说这暂时不是问题,但对 MD5 是灾难(笔记本上秒级出碰撞),对 SHA-1 也已经失守(2017 年的 SHATTERED,2020 年的选择前缀碰撞把成本压到几万美元)。把安全性建立在”这个哈希以后也不会被找到碰撞”上,是个会过期的假设。

2.5 哪些哈希天生免疫

  • SHA-3 / Keccak:海绵结构,摘要只是内部状态的一部分,天然抗长度扩展,还自带 KMAC。
  • BLAKE2 / BLAKE3:内置 keyed 模式,BLAKE3::keyed_hash(key, msg) 本身就是个 MAC,不需要套 HMAC。
  • SHA-512/256、SHA-224:内部状态比输出长,截断掉的那部分攻击者拿不到,因此也免疫。

即便手上是这些算法,没有明确理由的话还是建议用标准 HMAC。对接方、网关、SDK、审计工具都认 HMAC-SHA256,你自研的 keyed hash 方案需要额外说明,而”需要额外说明的密码学方案”通常就是出事的地方。

3. HMAC 怎么堵住这个洞

HMAC 的思路很直接:在外面再套一层哈希。

上一节说了,攻击者能扩展是因为拿到了内部状态。HMAC 做了两次哈希:

内层:H( K⊕ipad  ‖  message )     →  中间摘要
外层:H( K⊕opad  ‖  中间摘要 )    →  最终 tag

攻击者拿到的最终 tag 是外层的输出。他想接着扩展,扩展出来的是外层的东西;但服务端验签时新消息是喂给内层的。内层和外层用的密钥不同(一个异或了 0x36,一个异或了 0x5c),两边对不上,扩展出来的 tag 通不过验签。

这就是 HMAC 比 hash(secret + msg) 多出来的那一层保护。

RFC 2104 的完整定义:

HMAC(K, m) = H( (K' ⊕ opad) ‖ H( (K' ⊕ ipad) ‖ m ) )

K'    = K 处理成刚好一个分组长(SHA-256 是 64 字节):
        len(K) >  B  →  K' = H(K) 再右补零
        len(K) <= B  →  K' = K 右补零
ipad  = 0x36 重复 B 次
opad  = 0x5c 重复 B 次

手搓一遍验证,和标准库结果完全一致:

const blockSize = 64 // SHA-256 的分组长度

func myHMAC(key, msg []byte) []byte {
	// 1. key 比分组长就先 hash,短就右补零到分组长
	if len(key) > blockSize {
		sum := sha256.Sum256(key)
		key = sum[:]
	}
	k := make([]byte, blockSize)
	copy(k, key)

	ipad := make([]byte, blockSize)
	opad := make([]byte, blockSize)
	for i := range k {
		ipad[i] = k[i] ^ 0x36
		opad[i] = k[i] ^ 0x5c
	}

	// 2. inner = H((K ⊕ ipad) || msg)
	inner := sha256.New()
	inner.Write(ipad)
	inner.Write(msg)

	// 3. outer = H((K ⊕ opad) || inner)
	outer := sha256.New()
	outer.Write(opad)
	outer.Write(inner.Sum(nil))
	return outer.Sum(nil)
}
手搓       : ff53a67998c7d876652e395796a47173ce60af2031c743156da3a68d98ce2412
crypto/hmac: ff53a67998c7d876652e395796a47173ce60af2031c743156da3a68d98ce2412
一致       : true

3.1 安全性不靠抗碰撞

HMAC 有个很实用的性质:安全归约建立在”底层压缩函数是个伪随机函数(PRF)“上,而不是建立在抗碰撞上。意味着什么?MD5 的抗碰撞早就没了,但 HMAC-MD5 至今没有实用的伪造攻击。同理 HMAC-SHA1 在 TLS 里还能继续用一阵。

当然新系统别用它们,没必要去赌。默认 HMAC-SHA256 就行。

3.2 两个容易踩的实现坑

一是超长密钥会被先哈希。len(K) > 64K' = H(K),所以一个 100 字节的密钥,和它的 SHA-256 摘要(当成 32 字节密钥用),产生的 HMAC 完全相同。这不是漏洞,但会让”密钥越长越安全”的直觉失效。超过 64 字节纯属浪费,32 字节随机数就是最优解。

二是 ipad / opad 别自己改。选 0x360x5c 是因为两者异或后汉明距离大,能保证内外两层用的实际密钥差异足够。图省事写成 0x00 / 0xff 之类会削弱安全论证。

4. 性能:HMAC 比裸哈希慢多少

这大概是被问得最多的一个问题。直接上实测(Apple M2,Go 1.26.5,go test -bench):

func BenchmarkHMACReuse(b *testing.B) {
	for _, s := range sizes {
		msg := make([]byte, s.n)
		m := hmac.New(sha256.New, key) // 复用同一个 Hash
		b.Run(s.name, func(b *testing.B) {
			for i := 0; i < b.N; i++ {
				m.Reset()
				m.Write(msg)
				m.Sum(nil)
			}
		})
	}
}
消息长度SHA-256HMAC(每次 NewHMAC(复用 + ResetNew 倍数复用倍数
16 B47.2 ns301.5 ns106.0 ns6.4×2.2×
64 B52.3 ns310.5 ns119.8 ns5.9×2.3×
256 B123.3 ns381.0 ns186.7 ns3.1×1.5×
1 KB414.7 ns697.4 ns480.1 ns1.7×1.16×
64 KB24.95 µs25.55 µs25.03 µs1.02×1.003×
1 MB398.8 µs400.6 µs399.1 µs1.005×1.001×

先看最右边两列。倍数从 6.4× 一路掉到 1.001×,因为 HMAC 的额外开销是个固定值,不随消息长度增长:对比裸哈希,它固定多出三次压缩函数调用,K'⊕ipad 一个分组、K'⊕opad 一个分组、内层摘要一个分组。消息越长这三次占比越小,到 64 KB 已经在噪声里了。

再看 16 字节那一行,New 和复用差了将近三倍。这部分几乎全是密钥预处理和内存分配的钱。Go 的 crypto/hmac 会把 ipad/opad 两个分组压缩后的中间状态 marshal 下来缓存,Reset() 时直接 unmarshal 恢复,省掉重复计算:

// crypto/internal/fips140/hmac/hmac.go
// Marshaling succeeded; save the marshaled state for later
h.ipad = imarshal
h.opad = omarshal

所以高 QPS 的验签路径上,把 hmac.Hash 放进 sync.Pool 或者按密钥缓存起来复用,比换算法有用得多。注意 hmac.Hash 不是并发安全的,池化的时候别共享。

不过说到底,性能不该是”要不要用 HMAC”的理由。就算按最差的 6.4× 算,单次也才 300 ns,一台机器每秒能做 300 万次。真到了这个量级,瓶颈早在 JSON 解析、网络和数据库上了,轮不到 HMAC。

4.1 反过来:有些场景嫌它太快

同一台机器上量一下 PBKDF2-HMAC-SHA256:

迭代轮数耗时单轮
10,0001.26 ms0.126 µs
100,00010.2 ms0.102 µs
310,00032.3 ms0.104 µs
600,00062.9 ms0.105 µs

一次 HMAC-SHA256 约 0.1 µs,OWASP 建议的 600,000 轮 PBKDF2 约 63 ms,差了近六个数量级。这个差距是故意造出来的。

原因很直白。验签的对手是伪造者,他没有密钥,暴力枚举 256 位空间没有意义,所以算法快无所谓。存密码的对手则是拖库之后离线爆破的人,他手里有全部密文和无限时间,你算得越快他也爆得越快。这就引出下一节。

5. 盐、pepper、密钥:三个不同的东西

“加盐”经常和 HMAC 混着讲,但它们解决的完全不是同一个问题。在说区别之前,先把三个概念过一遍。

5.1 盐(salt):让同样的密码产生不同的哈希

假设你的用户表长这样,密码用 SHA-256 存:

user   | password_hash
-------|----------------------------------------------
alice  | 5e884898da28047151d0e56f8dc6292773603d0d...
bob    | 5e884898da28047151d0e56f8dc6292773603d0d...

两个人用了同一个密码(password),哈希完全相同。攻击者拖到库之后,一眼就看出这俩人密码一样。更要命的是,password 这种常见词的 SHA-256 结果早就被算好了,放在彩虹表里直接查就行,连暴力破解都不用。2012 年 LinkedIn 泄露的 650 万条无盐 SHA-1 几天内就被破掉了绝大部分。

盐就是为了解决这个问题。给每个用户生成一个随机字符串(盐),拼在密码前面再 hash:

user   | salt                             | password_hash
-------|----------------------------------|----------------------------------------------
alice  | a3f2b8c1e9d04f6a7b2c8d1e0f3a5b7c | 7b1c4a9e... (和下面完全不同)
bob    | 92e4d1f7a5b8306c4d2e9f1a8b7c0d3e | f3d8e21a... (和上面完全不同)

同样的密码 password,因为盐不同,哈希结果完全不同。攻击者的彩虹表废了(每个盐都要重新算一整张表),也看不出谁和谁密码相同了。

盐不是秘密。 它明文和哈希存在一起,验证密码时要读出来用。它的作用是打散,不是保密。使用要点:

  • 每个用户独立生成,16 字节 CSPRNG 随机数起步
  • 别用用户名、邮箱、自增 ID 这类可预测值当盐
  • 改密码时重新生成

5.2 pepper:藏在数据库之外的秘密

pepper 可以理解成”全局的、保密的盐”。它不存在数据库里,而是放在应用配置、KMS 或 HSM 里。

为什么要多这一层?因为很多安全事故是”只拖了数据库”:SQL 注入、备份文件泄露、从库被人下载。如果密码哈希只靠盐保护,攻击者有了库就能开始爆破。但如果计算哈希时还混入了一个 pepper,攻击者只拿到数据库的话连起步都没法起步,因为他缺一个关键输入。

5.3 三者对照

存哪儿是否保密每用户不同防的是什么
盐 salt和摘要一起进数据库彩虹表、批量爆破、相同口令碰撞
pepper应用配置 / KMS / HSM,不入库只拖了数据库、没拿到应用配置的攻击者
密钥 key同上消息伪造(这才是 HMAC 用的东西)

pepper 的标准做法是在慢哈希外面套一层 HMAC:

// pepper 从 KMS / 环境变量读,绝不入库
pre := hmac.New(sha256.New, pepper)
pre.Write([]byte(password))
hashed := argon2.IDKey(pre.Sum(nil), salt, 3, 64*1024, 4, 32)

顺序别写反:HMAC 在里,慢哈希在外。这样即使 pepper 泄露,Argon2 的成本还在,攻击者不会一夜回到解放前。反过来就没这个性质了。

先过一遍 HMAC 还顺手解决了 bcrypt 的一个老坑。bcrypt 只取密码的前 72 字节,后面的直接丢掉;先 HMAC 成 32 字节再喂给它,长密码就不会被静默截断了。

5.4 慢哈希:存密码为什么不能用 SHA-256 或 HMAC

上面的代码里出现了 Argon2,这就是所谓的”慢哈希”。先说为什么需要它。

SHA-256 算一次只要几十纳秒,一台普通机器每秒能算几千万次。这对验签来说是优点(快),但对存密码来说是灾难。攻击者拖库之后做的是离线爆破:拿着哈希值,把常见密码一个一个试。SHA-256 太快,一张显卡每秒能试几十亿个,六位纯数字密码几毫秒就破完。

慢哈希就是故意把这个过程变慢的算法。Argon2id 算一次要几十毫秒,而且占大块内存(几十 MB),GPU 想并行跑几千路就得有几千份内存,成本直接起飞。同样的时间,用 SHA-256 能试几十亿个密码,用 Argon2id 只能试十几个。

存用户密码请用这些算法(按推荐顺序):

算法推荐参数(OWASP)备注
Argon2idm=19 MiB, t=2, p=1(或 m=47 MiB, t=1, p=1)首选,抗 GPU/ASIC
scryptN=2^17, r=8, p=1次选,同样是内存硬
bcryptcost ≥ 10老系统兼容,注意 72 字节截断
PBKDF2-HMAC-SHA256≥ 600,000 轮FIPS 合规场景的选择;只是计算硬,不是内存硬

Argon2 和 scrypt 是内存硬(memory-hard)的:除了算得慢,还要占大块内存,GPU 和 ASIC 的并行优势因此大打折扣。PBKDF2 只是重复计算,一张显卡能同时跑几万路,所以同样是”63 毫秒”,它给出的实际保护比 Argon2 弱不少。没有 FIPS 包袱就直接上 Argon2id。

5.4 什么时候该用哪个

场景用什么
存用户密码Argon2id / scrypt / bcrypt + 每用户随机盐(+ pepper)
API 请求签名、Webhook 验签HMAC-SHA256
会话 token、CSRF token 完整性HMAC-SHA256(或直接用不透明随机 token + 服务端存储)
文件完整性校验(可信信道下发校验值)裸 SHA-256 / BLAKE3
缓存键、分片路由、去重非加密哈希就够(xxHash、FNV),别浪费 SHA-256
需要对第三方举证数字签名(Ed25519),HMAC 做不到不可否认

最后一行得多说一句。HMAC 是对称的,验证方拿着和签发方一模一样的密钥,所以验证方自己也能伪造出任意签名。这意味着 HMAC 签名在纠纷中当不了证据,需要不可否认性就得上非对称签名。

6. 时序攻击:算对了,栽在比较上

HMAC 用对了、盐加好了,最后一步 if computed == received 还能把你送走。

6.1 问题出在哪

绝大多数语言的默认字符串/字节比较都是短路的,发现第一个不同的字节就立即返回。于是比较耗时和”公共前缀长度”成正比,攻击者反复请求、观察响应时间,就能一个字节一个字节地把正确的 tag 试出来,把 256^32 次的暴力搜索压到 256 × 32 = 8192 次量级。

量一下到底有多明显。测法是拿一个 32 字节 tag,构造前 N 字节正确、其余错误的猜测值,每组跑 20 万次取中位数:

// 一发现不同就返回,耗时和公共前缀长度成正比
func naiveEqual(a, b []byte) bool {
	if len(a) != len(b) {
		return false
	}
	for i := range a {
		if a[i] != b[i] {
			return false
		}
	}
	return true
}
猜对的前缀字节数naiveEqualsubtle.ConstantTimeComparehmac.Equal
01.71 ns11.78 ns11.46 ns
11.76 ns11.88 ns11.75 ns
23.23 ns11.74 ns11.70 ns
43.71 ns11.67 ns12.04 ns
84.73 ns11.92 ns11.75 ns
167.04 ns12.27 ns12.47 ns
249.62 ns11.73 ns11.48 ns
3111.53 ns11.74 ns11.48 ns

信号非常干净。naiveEqual 从 1.71 ns 一路爬到 11.53 ns,猜对得越多就越慢,单调递增。两个常数时间实现则从头到尾稳在 11.5~12.5 ns,跟猜对几个字节完全无关。

有意思的是,常数时间版本在”一个都没猜对”时反而比朴素版慢 6 倍多。这正是它的工作方式:不管对错都把 32 个字节全跑完。安全就是拿这点开销换的,10 ns 而已。

6.2 这个几纳秒的差距,真能被利用吗

差距只有 10 ns 量级,而网络抖动是毫秒量级,看着完全淹没了。但有三点。

噪声可以靠采样次数压下去。时间差是固定偏置,噪声大体上零均值,采样量足够时均值会收敛。Crosby、Wallach 和 Riedi 在 2009 年的 Opportunities and Limits of Remote Timing Attacks 里给过实测:局域网内可以分辨到 100 ns 量级的差异,跨广域网也能做到 10 µs 量级。上表那 10 ns 确实偏小,但换成更长的 token,或者比较恰好发生在循环里被放大,就未必了。

攻击者也常常不在网络另一端。同一台物理机上的另一个容器、同一朵云上的邻居虚机、浏览器里的另一个标签页,拿到的时间分辨率比跨网高好几个数量级。

最要紧的是成本不对称。修好它只要改一行、多花 10 ns;不修则要赌”我的部署环境永远噪声够大”。这个赌注不值得下。Lucky 13(TLS CBC 的 MAC 校验时序)和 BREACH 都说明了同一件事:只要存在可测量的、和秘密相关的时间差,工业界总有人能把它变成实用攻击。

6.3 各语言的正确写法

// Go:首选 hmac.Equal,语义最清楚
if !hmac.Equal(computed, received) {
    return errInvalidSignature
}
// 通用字节比较用 subtle.ConstantTimeCompare(返回 1 表示相等)
if subtle.ConstantTimeCompare(a, b) != 1 { ... }
# Python
import hmac
if not hmac.compare_digest(computed, received):
    raise ValueError("invalid signature")
// Node.js 注意:长度不同会直接 throw,要先自己判长度
const crypto = require("crypto");
if (a.length !== b.length || !crypto.timingSafeEqual(a, b)) {
  throw new Error("invalid signature");
}
// Java: MessageDigest.isEqual 在 JDK 6u17 之前不是常数时间(CVE-2009-3875),
// 现代 JDK 已修复,可以放心用
if (!MessageDigest.isEqual(computed, received)) { ... }
// PHP
if (!hash_equals($computed, $received)) { ... }

有个小细节。几乎所有常数时间比较函数在长度不同时都会提前返回,也就是说长度是泄露的。对固定 32 字节的 HMAC tag 无所谓,但如果比较的是变长的秘密(比如 API key 本身),就要留意了。

6.4 兜底方案:Double HMAC

要是环境实在不好保证常数时间比较(某些 ORM、某些框架的封装、某些不受控的第三方库),有个能套在外面的通用技巧:用一个每次请求随机生成的密钥,把两边都再 HMAC 一次,然后随便怎么比。

func doubleHMACEqual(a, b []byte) bool {
	nonce := make([]byte, 32)
	rand.Read(nonce) // 一次性随机密钥

	ha := hmac.New(sha256.New, nonce)
	ha.Write(a)
	hb := hmac.New(sha256.New, nonce)
	hb.Write(b)

	// 到这里两边都被随机化了,公共前缀长度不再泄露任何信息
	return bytes.Equal(ha.Sum(nil), hb.Sum(nil))
}

原理是攻击者无法预测本次的随机密钥,也就无法通过控制输入去影响两个 HMAC 结果的公共前缀,短路比较泄露的那点信息就没用了。代价是每次多两次 HMAC,按第 4 节的数据,几百纳秒。

6.5 泄露的不止是比较

时序侧信道不限于 MAC 比较,验签路径上常见的还有:

  • 先查 secret 存不存在、不存在就早退。这会泄露 key ID 的有效性。正确做法是查不到也走一遍完整流程,用一个假密钥算完再返回失败。
  • 登录时”用户不存在”比”密码错误”返回快,因为前者根本没走 bcrypt。修法是用户不存在时也对一个固定的假 hash 跑一遍。
  • 按 IP / key 查限流表,命中和未命中耗时差异明显。
  • 先解析 JSON 再验签。畸形 JSON 的解析耗时差异会泄露结构信息,而且这等于在验签之前就把攻击面暴露给了未认证的输入。永远先验签,再解析。

7. 实战:接口签名的正确姿势

7.1 拼接歧义:另一个容易忽略的洞

假设签名是这么拼的:

mac := hmac.New(sha256.New, key)
mac.Write([]byte(userID + action + amount))

那么 ("1", "23", "45")("12", "3", "45") 拼出来是同一个字符串 "12345",签名完全相同。攻击者拿一个合法请求的签名,换成另一组参数照样通过。这类问题叫 canonicalization(规范化)漏洞,毛病出在拼接不可逆。

修法是让拼接可逆。两种常见做法:

// 方案一:长度前缀(最稳妥,任何字节都能装)
func writeField(mac hash.Hash, s string) {
	binary.Write(mac, binary.BigEndian, uint32(len(s)))
	mac.Write([]byte(s))
}

// 方案二:固定顺序 + 字段不可能出现的分隔符(要严格校验字段内容)
canonical := strings.Join([]string{userID, action, amount}, "\n")

方案二在字段可能包含换行时就废了,所以能用方案一就用方案一。

7.2 一个完整的签名 / 验签实现

type Signer struct {
	keyID  string
	secret []byte
}

// canonical 构造带长度前缀的规范化串,杜绝拼接歧义
func canonical(method, path, ts, nonce string, body []byte) []byte {
	var buf bytes.Buffer
	for _, f := range [][]byte{
		[]byte(method), []byte(path), []byte(ts), []byte(nonce), body,
	} {
		binary.Write(&buf, binary.BigEndian, uint32(len(f)))
		buf.Write(f)
	}
	return buf.Bytes()
}

func (s *Signer) Sign(method, path string, body []byte) http.Header {
	ts := strconv.FormatInt(time.Now().Unix(), 10)
	nonce := make([]byte, 16)
	rand.Read(nonce)
	nonceHex := hex.EncodeToString(nonce)

	mac := hmac.New(sha256.New, s.secret)
	mac.Write(canonical(method, path, ts, nonceHex, body))

	h := http.Header{}
	h.Set("X-Key-Id", s.keyID)
	h.Set("X-Timestamp", ts)
	h.Set("X-Nonce", nonceHex)
	h.Set("X-Signature", hex.EncodeToString(mac.Sum(nil)))
	return h
}

func Verify(r *http.Request, body []byte, lookup func(keyID string) []byte) error {
	ts := r.Header.Get("X-Timestamp")
	sec, err := strconv.ParseInt(ts, 10, 64)
	if err != nil {
		return errBadRequest
	}

	// 1. 先卡时间窗,把重放的成本压到 5 分钟内
	if d := time.Since(time.Unix(sec, 0)); d > 5*time.Minute || d < -5*time.Minute {
		return errExpired
	}

	// 2. 查密钥;查不到也用假密钥走完全程,避免泄露 keyID 是否存在
	secret := lookup(r.Header.Get("X-Key-Id"))
	valid := secret != nil
	if !valid {
		secret = dummySecret
	}

	// 3. 算 HMAC
	nonce := r.Header.Get("X-Nonce")
	mac := hmac.New(sha256.New, secret)
	mac.Write(canonical(r.Method, r.URL.Path, ts, nonce, body))

	// 4. 常数时间比较
	got, err := hex.DecodeString(r.Header.Get("X-Signature"))
	if err != nil || !hmac.Equal(mac.Sum(nil), got) || !valid {
		return errInvalidSignature
	}

	// 5. nonce 查重,彻底堵死时间窗内的重放(Redis SETNX,TTL 略大于时间窗)
	if !nonceStore.SetNX(nonce, 6*time.Minute) {
		return errReplay
	}
	return nil
}

7.3 签名 checklist

  • 用 HMAC,不用 hash(secret + msg)
  • 密钥 32 字节 CSPRNG 随机,不是”公司名 + 年份”
  • 规范化串带长度前缀或严格分隔符,杜绝拼接歧义
  • body 参与签名(只签 URL 等于没签 POST)
  • HTTP method 和 path 参与签名(否则 GET 签名可以拿去当 DELETE 用)
  • 带时间戳 + 时间窗校验
  • 带 nonce + 服务端查重(防时间窗内重放)
  • hmac.Equal 之类的常数时间比较
  • 先验签、再解析 body
  • 密钥能轮换:带 key ID,允许新旧密钥并存一段时间
  • 失败时不要在错误信息里回显期望的签名值

8. 常见误区速查

说法实际情况
”HMAC 加密”HMAC 不是加密,不可逆,不提供机密性
”SHA-256 够安全了,不用 HMAC”安全的是算法,不安全的是”没有密钥”这件事
”加了盐就不用 HMAC 了”盐防彩虹表,HMAC 防伪造,两码事
”HMAC 太慢了”短消息 2 倍、长消息几乎无差;固定开销三次压缩函数
”密钥越长越安全”超过 64 字节会被先哈希,32 字节随机数就够
”盐要保密”盐是公开的,和摘要存一起;要保密的是 pepper 和密钥
”用 HMAC 存密码更安全”反了,存密码要的是慢,用 Argon2id
”MD5 破了所以 HMAC-MD5 也不能用”HMAC 安全性不依赖抗碰撞,HMAC-MD5 至今未被实用攻破;但新系统别用
”时序攻击太理论了”上表的信号单调且干净;修它只要改一行
== 比较哈希没问题”短路比较泄露公共前缀长度,用常数时间比较
”签名了就不会被重放”签名保证没被改,不保证没被重发;要靠时间戳 + nonce
”HMAC 能当证据”不能,双方共享密钥,验证方也能伪造;要不可否认性用 Ed25519

小结

回到开头那行代码。sha256.Sum256([]byte(secret + payload)) 的问题不在 SHA-256,在密钥和消息的组合方式:Merkle-Damgård 结构把内部状态原样吐了出来,于是”知道摘要”就等于”能接着往下算”。HMAC 用两层嵌套堵住了这个口子,代价是三次额外的压缩函数调用,短消息慢一倍多,长消息几乎无差。

再往外一圈,密钥、盐、pepper 各管各的事:密钥防伪造,盐防批量爆破,pepper 防单点拖库。存密码时”快”从优点变成缺点,得换成 Argon2id 那种故意慢下来的算法。而就算前面全算对了,最后一步用 == 比较,秘密还是能从时间里漏出去。

真要浓缩成三条能贴在工位上的:有密钥就用 HMAC,别自己拼 hash(key + msg);存密码用 Argon2id 加每用户随机盐,别用任何”快”的哈希;比较 MAC 一律走常数时间函数,hmac.Equal / compare_digest / hash_equals 随便挑一个。

参考

rfc-editor.orgRFC 2104: HMAC: Keyed-Hashing for Message Authentication | RFC EditorThis document describes HMAC, a mechanism for message authentication using cryptographic hash functions. HMAC can be used with any iterative cryptographic hash function, e.g., MD5, SHA-1, in combination with a secret shared key. The cryptographic strength of HMAC depends on the properties of the underlying hash function. This memo provides information for the Internet community. This memo does not specify an Internet standard of any kind cheatsheetseries.owasp.orgPassword Storage - OWASP Cheat Sheet SeriesWebsite with the collection of all the cheat sheets of the project. rfc-editor.orgRFC 9106: Argon2 Memory-Hard Function for Password Hashing and Proof-of-Work ApplicationsThis document describes the Argon2 memory-hard function for password hashing and proof-of-work applications. We provide an implementer-oriented description with test vectors. The purpose is to simplify adoption of Argon2 for Internet protocols. This document is a product of the Crypto Forum Research Group (CFRG) in the IRTF. pkg.go.devhmac package - crypto/hmac - Go PackagesPackage hmac implements the Keyed-Hash Message Authentication Code (HMAC) as defined in U.S. Federal Information Processing Standards Publication 198. pkg.go.devsubtle package - crypto/subtle - Go PackagesPackage subtle implements functions that are often useful in cryptographic code but require careful thought to use correctly. Python documentationhmac — Keyed-Hashing for Message AuthenticationSource code: Lib/hmac.py This module implements the HMAC algorithm as described by RFC 2104. The interface allows to use any hash function with a fixed digest size. In particular, extendable output... en.wikipedia.orgLength extension attack - Wikipediahttps://en.wikipedia.org/wiki/Length_extension_attack GitHubGitHub - iagox86/hash_extenderContribute to iagox86/hash_extender development by creating an account on GitHub. ShatteredShatteredShattered covers AI, cybersecurity, cloud computing, hardware, software, startups, gaming, and mobile technology. In-depth analysis and breaking news on the technologies shaping our future.

时序攻击的可行性数据来自 Crosby、Wallach、Riedi 的 Opportunities and Limits of Remote Timing Attacks(ACM TISSEC, 2009)。文中所有 benchmark 均在 Apple M2 / Go 1.26.5 上实测,数据会随硬件和版本浮动,绝对值仅供参考,重要的是量级关系。

#HMAC#哈希#长度扩展攻击#时序攻击#加盐#密码存储#Go