什么是长度扩展攻击
长度扩展攻击(Length Extension Attack)是一种针对 Merkle-Damgard 结构哈希函数的经典密码学攻击。它允许攻击者在不知道密钥的情况下,仅凭一个已知的哈希值 H(secret || message),就能构造出 H(secret || message || padding || extension) 的合法哈希值。
受影响的哈希函数包括:MD5、SHA-1、SHA-256、SHA-512 等所有基于 Merkle-Damgard 结构的哈希算法。
攻击场景
假设一个 Web 服务使用如下方式进行请求签名验证:
SECRET = b"my_super_secret_key" # 攻击者不知道
def verify(data, token):
expected = sha256(SECRET + data).hexdigest()
return expected == token
# 客户端请求:
# data = b"user=guest&action=read"
# token = "8e3f...c7a2"
if verify(request.data, request.token):
execute(request.data) # 危险!
攻击者只需要:
- 窃听到一对合法的
(data, token) - 知道或猜测 SECRET 的长度(不需要知道 SECRET 本身)
就能构造出包含恶意内容的新请求 data' 和对应的合法 token',例如追加 &action=DELETE_ALL。
SHA-256 的内部结构
要理解攻击为什么成立,需要先理解 SHA-256 的工作方式。
Merkle-Damgard 结构
SHA-256 把消息切成 512-bit(64 字节)的块,像流水线一样依次”压缩”:
IV → f(IV, M₁) → H₁ → f(H₁, M₂) → H₂ → ... → Hₙ = 最终哈希
每一步压缩函数 f 的输入是上一步的 256-bit 状态加当前 512-bit 消息块。最终输出的哈希值就是最后一次压缩的状态,没有任何额外后处理。
SHA-256 初始向量
SHA-256 的内部状态由 8 个 32-bit 整数组成(合计 256 bit),初始值来自前 8 个素数的平方根小数部分:
H₀ = 6a09e667 H₁ = bb67ae85 H₂ = 3c6ef372 H₃ = a54ff53a
H₄ = 510e527f H₅ = 9b05688c H₆ = 1f83d9ab H₇ = 5be0cd19
填充规则
SHA-256 在处理消息前需要做填充,使消息长度成为 512 bit 的整数倍:
- 追加一个
0x80字节(即一个 1 比特后面跟 7 个 0 比特) - 追加若干
0x00,使总长度模 512 等于 448 - 追加 64 位大端序的原始消息比特长度
这个规则是完全公开的,任何人都可以根据消息长度推算出填充内容。
漏洞的本质
三个关键事实:
- 哈希 = 内部状态:SHA-256 输出的 256 bit 就是最后一次压缩后的 8 个寄存器值,直接拼接,没有任何混淆
- 填充规则公开:给定消息长度,任何人都能算出填充内容
- 压缩可以”接力”:只要有中间状态 + 后续消息块,就能从任意位置继续计算
因此,拿到 H = SHA256(SECRET || data) 就等于拿到了处理完这段消息后的内部状态。攻击者可以把这个哈希值拆成 8 个 32-bit 整数,当作”伪 IV”继续计算任意追加内容。
攻击步骤
Step 1:获取合法 token
攻击者窃听到:
data = "user=alice&action=read"(22 字节)token = SHA256(SECRET || data)的值
Step 2:还原内部状态
把 64 个 hex 字符的 token 切成 8 段,每段 8 个 hex 字符 = 一个 32-bit 整数:
function parseHashToState(hexHash) {
const H = new Uint32Array(8);
for (let i = 0; i < 8; i++) {
H[i] = parseInt(hexHash.substr(i * 8, 8), 16) >>> 0;
}
return H;
}
Step 3:构造 glue padding
假设攻击者猜测 SECRET 长度为 12 字节,则 SECRET || data 共 34 字节(272 bit)。模拟 SHA-256 的填充逻辑:
function computePadding(messageLenBytes) {
const zeros = (56 - (messageLenBytes + 1) % 64 + 64) % 64;
const out = new Uint8Array(1 + zeros + 8);
out[0] = 0x80;
let bl = BigInt(messageLenBytes) * 8n;
for (let i = 7; i >= 0; i--) {
out[1 + zeros + i] = Number(bl & 0xffn);
bl >>= 8n;
}
return out;
}
34 字节的消息需要 30 字节填充(1 + 21 + 8),正好凑满 64 字节。
Step 4:拼装伪造请求
data' = data || glue_padding || extension
当服务端计算 SHA256(SECRET || data') 时,前 64 字节(SECRET + data + glue padding)恰好填满一个完整的 SHA-256 块,压缩后的状态正是攻击者已知的那个 token!
Step 5:从中间状态继续计算
function sha256FromState(fakeIV, prevTotalBytes, extension) {
const totalLen = prevTotalBytes + extension.length;
const finalPadding = computePadding(totalLen);
const tail = concatBytes(extension, finalPadding);
const H = new Uint32Array(fakeIV);
for (let i = 0; i < tail.length; i += 64) {
processBlock(H, tail.subarray(i, i + 64));
}
const out = new Uint8Array(32);
for (let i = 0; i < 8; i++) {
out[i * 4] = (H[i] >>> 24) & 0xff;
out[i * 4 + 1] = (H[i] >>> 16) & 0xff;
out[i * 4 + 2] = (H[i] >>> 8) & 0xff;
out[i * 4 + 3] = H[i] & 0xff;
}
return bytesToHex(out);
}
Step 6:攻击成功
服务端收到 (data', H') 后计算 SHA256(SECRET || data'),结果与攻击者伪造的 H' 完全一致,验证通过。
密钥长度爆破
如果攻击者不知道密钥长度,只需从 1 到 N 逐个尝试。每个候选长度生成一个 token 发给服务端验证,能通过的就对应真实长度。整个过程仅需 N 次哈希计算,代价极低。
实现:纯 JavaScript 的 SHA-256 + 长度扩展攻击
我用纯 JavaScript 实现了一个完整的交互式演示,包含:
- 从零实现的 SHA-256:包含消息扩展、64 轮压缩的完整实现
- 攻击全流程动画:逐步展示每一步的字节变化
- 交互实验室:可自由修改参数、亲手验证攻击
- 密钥长度爆破:一键遍历所有可能的密钥长度
核心压缩函数实现:
function processBlock(H, block) {
const W = new Uint32Array(64);
// 前 16 个字:从 block 读取(大端)
for (let i = 0; i < 16; i++) {
W[i] = ((block[i*4] << 24) | (block[i*4+1] << 16) |
(block[i*4+2] << 8) | block[i*4+3]) >>> 0;
}
// 消息扩展:W₁₆..W₆₃
for (let i = 16; i < 64; i++) {
const s0 = rotr(W[i-15], 7) ^ rotr(W[i-15], 18) ^ (W[i-15] >>> 3);
const s1 = rotr(W[i-2], 17) ^ rotr(W[i-2], 19) ^ (W[i-2] >>> 10);
W[i] = (W[i-16] + s0 + W[i-7] + s1) >>> 0;
}
let a = H[0], b = H[1], c = H[2], d = H[3];
let e = H[4], f = H[5], g = H[6], h = H[7];
for (let i = 0; i < 64; i++) {
const S1 = rotr(e, 6) ^ rotr(e, 11) ^ rotr(e, 25);
const ch = (e & f) ^ ((~e) & g);
const temp1 = (h + S1 + ch + K[i] + W[i]) >>> 0;
const S0 = rotr(a, 2) ^ rotr(a, 13) ^ rotr(a, 22);
const maj = (a & b) ^ (a & c) ^ (b & c);
const temp2 = (S0 + maj) >>> 0;
h = g; g = f; f = e;
e = (d + temp1) >>> 0;
d = c; c = b; b = a;
a = (temp1 + temp2) >>> 0;
}
H[0] = (H[0] + a) >>> 0; H[1] = (H[1] + b) >>> 0;
H[2] = (H[2] + c) >>> 0; H[3] = (H[3] + d) >>> 0;
H[4] = (H[4] + e) >>> 0; H[5] = (H[5] + f) >>> 0;
H[6] = (H[6] + g) >>> 0; H[7] = (H[7] + h) >>> 0;
}
防御方案
不要这样写
H(SECRET || data) ← 受长度扩展攻击影响
也不够安全
H(data || SECRET) ← 能挡住长度扩展,但存在碰撞攻击风险
推荐方案:HMAC
HMAC-SHA256(K, data) = H((K ⊕ opad) || H((K ⊕ ipad) || data))
HMAC 用双层哈希 + 内外密钥派生,外层哈希切断了”中间状态 = 输出”的链路,长度扩展攻击立刻失效。
现代方案
- SHA-3 (Keccak):基于 Sponge 结构,天然免疫长度扩展攻击
- BLAKE2 / BLAKE3:设计了内置 MAC 模式
- 双重哈希:
SHA256(SHA256(K || M))也能防御(比特币 PoW 就用了双 SHA-256)
实战建议
- 消息认证永远用
HMAC,不要自己拼H(K||M) - 新项目偏好 SHA-3、BLAKE2/3 等无长度扩展漏洞的哈希
- 原则:消息认证用专门的 MAC 算法,绝不要把哈希函数当 MAC 直接用
在线演示
我把这个项目做成了一个完整的交互式教学页面,所有计算在浏览器本地完成,包含:
- Merkle-Damgard 流水线动画
- SHA-256 填充规则的实时可视化
- 6 步攻击全流程动画演示
- 自由实验室:修改任意参数验证攻击
- 密钥长度暴力枚举演示
参考资料
- RFC 2104 - HMAC: Keyed-Hashing for Message Authentication
- FIPS 180-4 - Secure Hash Standard (SHS)
- Merkle, R.C. (1979) - Secrecy, Authentication, and Public Key Systems