背景信息
20260413,Ethereum 链上的 Hyperbridge 协议遭受了验证绕过攻击,攻击者绕过了 MMR 证明检查,获取到了 Ethereum 链上 DOT 代币的铸币权限。随后通过增发了 1 Billion DOT 代币进行抛售,获取 110.8 ETH (约 22 万美元)。
Trace 分析

- 提交恶意的 MMR proof,绕过检查,获得 DOT 的铸币权限
- 铸造大量的 DOT 代币
- 将铸造的 DOT 代币兑换成 ETH
Merkle Mountain Range (MMR)
在进行代码分析之前,我们需要先了解一下 Merkle Mountain Range,只有了解了这个数据结构是如何运行的,才能理解攻击者是如何构造恶意的证明绕过相关的检查,
Merkle Mountain Range (MMR) 介绍
Merkle Mountain Range(MMR)是 Merkle Tree 的变体,一种**仅追加(append-only)**的数据结构。
经典的 Merkle Tree 是一棵完全二叉树------叶子数量必须是 2 的幂次,而且一旦建好就不方便再往里追加数据。
比如在 tornado 中,采用的方案就是预先构建好高度为 64 的 merkcle tree(叶子节点数量固定),然后未被使用的叶子节点采用 0 数据填充。
MMR 就是为了解决"高效追加 + 高效证明"这个问题而设计的。
数据结构
MMR 的数据结构为一排从高到低排列的"完美二叉树"(因为像山峰从高到低排列,所以叫"Mountain Range")。每棵树的根节点就叫一个 peak(山峰)。

新增过程
每次新增一个新叶子时:
- 把新增叶子作为一棵高度为 0 的山放到最右边。
- 进行合并检查:如果最右边两座山的高度相同,就把它们合并(对两个根做一次哈希,生成一个新的父节点,形成一棵更高的山)。
- 重复步骤 2,直到最右边两座山高度不同为止。
- 此时 MMR 形成了一排从高到低排列的山峰形式。
举例说明
在原有两个叶子的 MMR 添加第 3 个叶子时,旁边多出一座高度 0 的小山。

继续添加第 4 个叶子时,两座高度 0 的又合并成高度 1,然后两座高度 1 的再合并成高度 2,只剩一座大山。

数学规律
如果当前有 n 个叶子,把 n 写成二进制,每个为 1 的位对应一座山峰。例如 n = 11 = 0b1011,表示有三座山峰,高度分别为 3、1、0(对应二进制位 8+2+1)。所以山峰数量等于 n 的二进制中 1 的个数,最多 O(log n) 座。
MMR 根的计算
虽然 MMR 可能有多个 peak,但最终需要一个唯一的根哈希(root)来代表整个 MMR。
Root 的计算流程如下:
- 从右往左选取两个 peak 值进行哈希,获得一个 bag hash
- 继续选取下一个 peak 值,将 bag hash 和它哈希
- 不断重复步骤 2 直到所有 peak 被选取,最后得到的值就是 MMR root
MMR Inclusion Proof
MMR inclusion proof 回答的问题是:"给定一个 MMR root,证明某个叶子确实存在于 MMR 中"。
整个过程分为两个部分:Mountain Proof 和 Peak Bagging。
以图中 L5(节点 8)为例进行 MMR inclusion proof

阶段一:Mountain Proof
这一阶段和普通 Merkle Tree 的 proof 完全一样。
-
验证者手里有
L5的原始数据,首先算出H(8) = Hash(L5_data)。然后 prover 提供了兄弟节点 9 的哈希值,验证者计算H(10) = Hash(H(8) || H(9))。这里有个关键细节------左右顺序不能搞反。MMR 的节点编号天然编码了位置信息,验证者可以通过位置推算出谁在左、谁在右。
-
接着上一层:prover 提供
H(13),验证者算H(14) = Hash(H(10) || H(13))。 -
再上一层:prover 提供
H(7),验证者算H(15) = Hash(H(7) || H(14))。
节点 15 就是这座山的 peak。到此为止,山内阶段结束。验证者现在手里有了 H(15) 的值------这个值是自己一步步算出来的,不是 prover 给的,所以如果中间任何一步被篡改,最终的 H(15) 都会不匹配。
阶段二:Peak Bagging
一个 MMR 通常有多座山,图中 11 个叶子产生了 3 座山:peak 15(h=3)、peak 18(h=1)、peak 19(h=0)。
验证者已经通过阶段一已经算出了 H(15),然后在 prover 提供的 proof 中获取 H(18) 和 H(19)的值。
bagging 阶段:从最右边的 peak 开始,两两向左折叠哈希。
bag = Hash(H(18) || H(19))root = Hash(H(15) || bag)
这个 root 就是整个 MMR 的根哈希,验证者把算出的 root 和链上存储的可信 MMR root 做比较,如果相同则证明 proof 有效,L5 确实在这个 MMR 中。
代码分析
整个 MMR 验证的核心对应的是 MerkleMountainRange.CalculateRoot() 函数,第一到第四部分对应 mountain proof (阶段一),第五部分对应 peak bagging(阶段二),第六部分是最终输出。
接下来我们结合攻击者的输入进行分析,排查出漏洞出现的位置,以及攻击者是如何利用这个漏洞构造一次成功的攻击的。
重点关注以下部分的参数(完整版本的参数请自行查看)
jsx
host: 0x792a6236af69787c40cf76b69b4c8c7b28c4ca20
proof[1]:
height:[stateMachineId: 3367, height: 9775932]
multiproof[1]:[0x466dddba7e9a84a0f2632b59be71b8bd489e3334a1314a61253f8b827c9d3a36]
**leafCount: 1**
requests[1]:
request[1]:[call DOT.changeAdmin()]
**index: 1**
kIndex: 0
| 其中 multiproof 的值是和 height (3367, 9775932) 对应的 MMR 的 root
MerkleMountainRange.CalculateRoot() 函数分析
第一部分:单叶特判
solidity
if (leafCount == 1 && leaves.length == 1 && leaves[0].leaf_index == 0) {
return leaves[0].hash;
}
如果整个 MMR 只有一个叶子(同时满足以下三个条件),那 root 就是这个叶子本身的 hash,不需要任何 proof 节点,直接返回。
- leafCount == 1:MMR 只有 1 个叶子节点
- leaves.length == 1:需要验证的叶子节点数组长度为 1
- leaves[0].leaf_index == 0:所验证的第一个叶子节点的索引是 0
从这个检查可以看出,如果 MMR 的叶子数量只有 1 个的时候,所传入的叶子索引值 leaves[0].leaf_index 应该为 0
从攻击者的输入来看,他所需要验证的 MMR 叶子节点数量为 1 (leafCount: 1),理应是符合这个分支的判定,直接返回的。但是攻击者通过构造了一个恶意参数 requests[1].index: 1,使得 leaves[0].leaf_index == 0 的判断为 false,从而绕过了这个判断。
也正是这个关键步骤的绕过,使得在后续的证明构建与验证过程中,requests[1] 所对应的叶子节点始终没有参与到整个证明过程中。
第二部分:Peak 分解 + 迭代器初始化
solidity
uint256[] memory subtrees = subtreeHeights(leafCount);
uint256 length = subtrees.length;
Iterator memory peakRoots = Iterator(0, new bytes32);
Iterator memory proofIter = Iterator(0, proof);
subtreeHeights(leafCount) 把叶子数做二进制分解,算出每座山的高度。比如 leafCount = 11 = 8+2+1,返回 [3, 1, 0],表示三座山,高度分别是 3、1、0。
然后初始化两个迭代器:peakRoots 是一个空数组,用来收集每座山最终算出的 peak hash;proofIter 是对传入的 proof 数组的游标,后面每次需要 proof 节点时就从里面顺序取。
由于传入的
leafCount = 1,所以subtrees = [0],proofIter指向的是传入的 root 值。
第三部分:主循环------逐座山处理
solidity
uint256 current_subtree;
for (uint256 p; p < length; ) {
uint256 height = subtrees[p];
current_subtree += 2 ** height;
从左到右遍历每座山。current_subtree 是一个累加器,记录"前 p+1 座山总共覆盖了多少个叶子"。比如第一座山高度 3,覆盖 2 ** 3 = 8 个叶子,第二座高度 1,再加 2 ** 1 = 2,累计 10 个。这个值用来判断哪些待证明的叶子属于当前这座山。
由于单个节点的 hight 为 0,所以 current_subtree = 1
第四部分:叶子分割 + 三路分支
solidity
MmrLeaf[] memory subtreeLeaves = new MmrLeaf;
if (leaves.length > 0) {
(subtreeLeaves, leaves) = leavesForSubtree(leaves, current_subtree);
}
leavesForSubtree 以 current_subtree 为分界点,把 leaves 切成两半:leaf_index < current_subtree 的归入当前这座山(subtreeLeaves),剩下的留给后面的山(重新赋值给 leaves)。
然后进入三路分支:
solidity
if (subtreeLeaves.length == 0) {
if (proofIter.data.length == proofIter.offset) {
break;
} else {
push(peakRoots, next(proofIter));
}
} else if (subtreeLeaves.length == 1 && height == 0) {
push(peakRoots, subtreeLeaves[0].hash);
} else {
push(peakRoots, CalculateSubtreeRoot(subtreeLeaves, proofIter, height));
}
- 分支 A:这座山里没有需要证明的叶子。那这座山的 peak hash 应该由 prover 直接提供------从 proof 里取一个。如果 proof 已经耗尽就 break 退出。
- 分支 B:这座山高度为 0(只有一个叶子),且恰好就是要证明的那个叶子。那 peak hash 就是叶子自身的 hash,不需要任何 proof 节点。
- 分支 C:这座山里有需要证明的叶子,且山的高度大于 0。调用
CalculateSubtreeRoot做标准的 Merkle 多重证明------用subtreeLeaves和 proof 中的兄弟节点,一层层向上合并,算出这座山的 peak hash。
三个分支都把结果 push 到 peakRoots 里。
由于传入的
leafCount: 1代表 MMR 只有 1 个叶子节点,所以leavesForSubtree函数以current_subtree为分界点切成两半的值都是空的,进入到subtreeLeaves.length == 0分支,把攻击者传入的 root 压入栈。

第五部分:Peak bagging
solidity
unchecked {
peakRoots.offset--;
}
while (peakRoots.offset != 0) {
bytes32 right = previous(peakRoots);
bytes32 left = previous(peakRoots);
unchecked {
++peakRoots.offset;
}
peakRoots.data[peakRoots.offset] = keccak256(
abi.encodePacked(right, left)
);
}
进入这段时,peakRoots 里已经收集了所有山的 peak hash,offset 指向最后一个元素的下一个位置。先 offset-- 把游标退到最后一个有效元素上。
然后 while 循环从右向左折叠:每次取出当前位置的值(right)和它左边的值(left),哈希后写回 left 的位置,同时 offset 也回到了这个位置。下一轮再取这个新值和更左边的值继续折叠,直到 offset == 0,只剩一个值。
用具体例子说明------假设 peakRoots = [P0, P1, P2],offset 从 2 开始:第一轮取 right=P2, left=P1,算出 bag1 = H(P2, P1),写到位置 1;第二轮取 right=bag1, left=P0,算出 root = H(bag1, P0),写到位置 0。循环结束。
按照只有一个叶子节点处理,只有一个 peak,所以这部分会被跳过。
第六部分:返回 root
solidity
return peakRoots.data[0];
经过 bagging,最终结果就存在 peakRoots.data[0],这就是整个 MMR 的 root hash。
此时也就是返回了攻击者最开始传入的 root 值,也就顺利地通过了验证了。
在通过了 MMR 的校验以后,HandlerV1 合约会通过调用 host.dispatchIncoming() 函数执行request 中的内容,也就是攻击者构建的获取 DOT 铸币权限的调用。

接下来的操作就是铸造大量的 DOT,并进行抛售获利。
后记
链上攻击事件不会等你有空的时候再发生,发生了就只能抽空出来做攻击分析了。所以本篇文章重点在 rootcause 部分的内容,对于跨链 ISMP,MMR root,Governance 这些部分的内容未能提及,读者如果感兴趣的话可以自行了解。
唉,写攻击事件分析真难啊。倒不是难在分析,是难在写,是需要有时间有精力有想法去把它写下来。
感谢你的阅读,如果你觉得写得还行的话欢迎点赞推荐转发,这是对我最大的支持和鼓励(抱拳)。