默克尔树第二原像攻击详解
目录
一、攻击概览
第二原像攻击(更准确地叫”节点冒充叶子攻击”)利用默克尔树实现的一个缺陷:攻击者可以把一个中间树节点当作原始叶子提交,配上一个缩短的证明,依然能对根验证通过。
后果:在用默克尔树做白名单/空投的合约里,攻击者可能伪造一个”从未被加入白名单的叶子”却通过验证——非法领取空投或获得权限。
二、核心漏洞
攻击之所以成立,是因为中间节点和叶子节点在建树时用了完全相同的哈希方式。攻击者可以:
- 拿一个真实叶子的有效默克尔证明;
- 把一个中间节点(它本身就是个哈希值)当作”叶子”提交;
- 把证明缩短一层;
- 让伪造的证明对根验证成功。
如果一个默克尔证明有效,那么把原证明的第一个值当作叶子传入、并缩短证明,这个缩短版本也有效。
三、具体攻击例子
设要证明叶子 ℓ₂,需要证明 [h(ℓ₁), h(b), h(f)](沿途的兄弟节点)。
验证过程:h(h(ℓ₂) + h(ℓ₁)) = 中间节点 a,再 h(a + h(b))…一路算到根。
攻击者改为:直接提交中间节点 a(其中 a = h(ℓ₁) + h(ℓ₂) 的哈希)作为”叶子”,配证明 [h(b), h(f)](少了一层)。合约验证:h(a + h(b))…照样算到同一个根,验证通过!
于是攻击者”证明”了一个根本不是原始叶子的值在树里。
四、为什么会发生
漏洞源于逐步哈希而非一次性哈希整个证明。验证函数无法区分”我现在哈希的这个 32 字节是原始叶子,还是某个内部节点”——因为它们形式上一模一样。攻击者通过在证明序列的靠后位置切入,构造出产生相同根的另一组输入。
五、防御机制
5.1 限制叶子大小
阻止 64 字节的输入。因为 keccak256 产出 32 字节哈希,中间节点 a = h(ℓ₁) + h(ℓ₂) 的原像是 64 字节(两个 32 字节哈希拼接)。如果合约规定叶子必须是某个特定结构(比如 abi.encode(address, uint256) 而非裸 64 字节),就堵住了攻击。
5.2 域分离:不同哈希方式
对叶子和内部节点使用不同的哈希函数(或哈希变体)。这样攻击者无法把中间节点重构成合法叶子。常见做法:
- 叶子:
keccak256(bytes.concat(keccak256(abi.encode(data)))); - 内部节点:
keccak256(left + right);
或者给叶子加前缀字节 0x00、内部节点加 0x01,从”域”上彻底区分两类哈希的输入空间。
5.3 OpenZeppelin 的双重哈希方案
OpenZeppelin 不用单独的哈希函数,而是对叶子做双重哈希:
h'(x) = keccak256(keccak256(x))
具体:leaf = keccak256(bytes.concat(keccak256(abi.encode(addr, amount))))。
这种双重哈希用很低的成本实现了域分离——攻击者无法把一个 32 字节的中间节点”反推”成需要双重哈希才能匹配的叶子形式。
⚠️ 注意:OpenZeppelin 不强制这样做,开发者必须显式对叶子双重哈希,否则仍然脆弱。这是用 OZ MerkleProof 时最容易踩的坑。
六、关键限制
文章指出一个约束:
攻击者必须传入中间节点的原像,而不是它的哈希值。
也就是说攻击者需要知道 h(ℓ₁) + h(ℓ₂) 这个 64 字节拼接(中间节点的原像),而不仅仅是节点的哈希。这让攻击只在特定条件下可行——但只要叶子是裸 64 字节数据、且攻击者能凑出这个原像,攻击就成立。所以”叶子不要是 64 字节、并做域分离”是必须的防御。
七、总结
- 第二原像/节点冒充叶子攻击:把中间节点当叶子 + 缩短证明,对根验证通过;
- 根因:叶子和内部节点用相同哈希方式,无法区分;
- 后果:伪造白名单/空投资格;
- 防御:① 叶子不用裸 64 字节(用结构化编码)② 域分离(叶子/节点不同哈希或加前缀)③ OZ 的叶子双重哈希;
- 用 OZ MerkleProof 时务必显式双重哈希叶子,否则脆弱。
八、动手练习项目:可防御的默克尔空投 SafeMerkleDrop
项目目标
先实现一个脆弱的默克尔空投并亲手用第二原像攻击伪造领取,再用双重哈希修复它。部署到 Sepolia。
合约要求
1. VulnerableDrop.sol(脆弱版)
bytes32 public root;claim(bytes32 leaf, bytes32[] calldata proof):用MerkleProof.verify(proof, root, leaf),叶子是裸 32 字节或允许 64 字节输入;- 验证通过就发空投,
mapping claimed防重复。
2. SafeDrop.sol(修复版)
claim(address account, uint256 amount, bytes32[] calldata proof):- 叶子 =
keccak256(bytes.concat(keccak256(abi.encode(account, amount))))(双重哈希 + 结构化); MerkleProof.verify(proof, root, leaf);- 只给
account自己(require(msg.sender == account)或直接打给 account)。
- 叶子 =
3. 链下脚本:用 merkletreejs 或 OZ 的 StandardMerkleTree 构造两棵树(脆弱版用单哈希裸叶子,安全版用双哈希结构化叶子),生成 root 和证明。
测试要求(Foundry)
test_LegitClaim:合法叶子正常领取;test_SecondPreimageAttack(核心):对 VulnerableDrop,拿一个真实叶子的证明,把证明第一个值当”叶子”、缩短证明一层,断言攻击成功通过验证(伪造领取);test_SafeDropResistsAttack:对 SafeDrop 实施同样攻击,断言失败(双重哈希挡住);test_LeafSize:验证 SafeDrop 的叶子结构无法被构造成 64 字节中间节点原像;test_NoDoubleClaim:重复领取 revert;test_WrongAccountReverts:替别人领取(msg.sender != account)revert。
Sepolia 部署与验证步骤
- 链下构造两棵树,部署 VulnerableDrop 和 SafeDrop(各传对应 root);
- 对 VulnerableDrop 正常领一次,再用中间节点 + 缩短证明伪造领一次,Etherscan 上观察两次都成功(漏洞);
- 对 SafeDrop 正常领取成功,伪造尝试 revert;
- 对比两者验证逻辑,确认双重哈希是关键。
进阶挑战(可选)
- 实现”前缀域分离”版本(叶子
keccak256(0x00, data)、节点keccak256(0x01, l, r))作为第三种防御,对比双重哈希; - 写注释回答:为什么 64 字节的叶子是危险信号?OZ 的 StandardMerkleTree 默认怎么防这个攻击?