默克尔树第二原像攻击详解

理解中间节点冒充叶子伪造证明的攻击原理,以及域分离与双重哈希的防御

5 分钟阅读
默克尔树第二原像攻击详解

默克尔树第二原像攻击详解

目录


一、攻击概览

第二原像攻击(更准确地叫”节点冒充叶子攻击”)利用默克尔树实现的一个缺陷:攻击者可以把一个中间树节点当作原始叶子提交,配上一个缩短的证明,依然能对根验证通过。

后果:在用默克尔树做白名单/空投的合约里,攻击者可能伪造一个”从未被加入白名单的叶子”却通过验证——非法领取空投或获得权限。

二、核心漏洞

攻击之所以成立,是因为中间节点和叶子节点在建树时用了完全相同的哈希方式。攻击者可以:

  1. 拿一个真实叶子的有效默克尔证明;
  2. 把一个中间节点(它本身就是个哈希值)当作”叶子”提交;
  3. 把证明缩短一层
  4. 让伪造的证明对根验证成功。

如果一个默克尔证明有效,那么把原证明的第一个值当作叶子传入、并缩短证明,这个缩短版本也有效

三、具体攻击例子

设要证明叶子 ℓ₂,需要证明 [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)

  1. test_LegitClaim:合法叶子正常领取;
  2. test_SecondPreimageAttack(核心):对 VulnerableDrop,拿一个真实叶子的证明,把证明第一个值当”叶子”、缩短证明一层,断言攻击成功通过验证(伪造领取);
  3. test_SafeDropResistsAttack:对 SafeDrop 实施同样攻击,断言失败(双重哈希挡住);
  4. test_LeafSize:验证 SafeDrop 的叶子结构无法被构造成 64 字节中间节点原像;
  5. test_NoDoubleClaim:重复领取 revert;
  6. test_WrongAccountReverts:替别人领取(msg.sender != account)revert。

Sepolia 部署与验证步骤

  1. 链下构造两棵树,部署 VulnerableDrop 和 SafeDrop(各传对应 root);
  2. 对 VulnerableDrop 正常领一次,再用中间节点 + 缩短证明伪造领一次,Etherscan 上观察两次都成功(漏洞);
  3. 对 SafeDrop 正常领取成功,伪造尝试 revert;
  4. 对比两者验证逻辑,确认双重哈希是关键。

进阶挑战(可选)

  • 实现”前缀域分离”版本(叶子 keccak256(0x00, data)、节点 keccak256(0x01, l, r))作为第三种防御,对比双重哈希;
  • 写注释回答:为什么 64 字节的叶子是危险信号?OZ 的 StandardMerkleTree 默认怎么防这个攻击?

💬 评论