20260413-Hyperbridge 攻击事件:发生在默克尔山上的验证绕过

背景信息

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

Trace 分析

  1. 提交恶意的 MMR proof,绕过检查,获得 DOT 的铸币权限
  2. 铸造大量的 DOT 代币
  3. 将铸造的 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(山峰)。

新增过程

每次新增一个新叶子时:

  1. 把新增叶子作为一棵高度为 0 的山放到最右边。
  2. 进行合并检查:如果最右边两座山的高度相同,就把它们合并(对两个根做一次哈希,生成一个新的父节点,形成一棵更高的山)。
  3. 重复步骤 2,直到最右边两座山高度不同为止。
  4. 此时 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 的计算流程如下:

  1. 从右往左选取两个 peak 值进行哈希,获得一个 bag hash
  2. 继续选取下一个 peak 值,将 bag hash 和它哈希
  3. 不断重复步骤 2 直到所有 peak 被选取,最后得到的值就是 MMR root

MMR Inclusion Proof

MMR inclusion proof 回答的问题是:"给定一个 MMR root,证明某个叶子确实存在于 MMR 中"。

整个过程分为两个部分:Mountain ProofPeak Bagging


以图中 L5(节点 8)为例进行 MMR inclusion proof

阶段一:Mountain Proof

这一阶段和普通 Merkle Tree 的 proof 完全一样。

  1. 验证者手里有 L5 的原始数据,首先算出 H(8) = Hash(L5_data)。然后 prover 提供了兄弟节点 9 的哈希值,验证者计算 H(10) = Hash(H(8) || H(9))

    这里有个关键细节------左右顺序不能搞反。MMR 的节点编号天然编码了位置信息,验证者可以通过位置推算出谁在左、谁在右。

  2. 接着上一层:prover 提供 H(13),验证者算 H(14) = Hash(H(10) || H(13))

  3. 再上一层: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 开始,两两向左折叠哈希。

  1. bag = Hash(H(18) || H(19))
  2. 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);
}

leavesForSubtreecurrent_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 这些部分的内容未能提及,读者如果感兴趣的话可以自行了解。

唉,写攻击事件分析真难啊。倒不是难在分析,是难在写,是需要有时间有精力有想法去把它写下来。

感谢你的阅读,如果你觉得写得还行的话欢迎点赞推荐转发,这是对我最大的支持和鼓励(抱拳)。

相关推荐
Gobysec3 个月前
Goby 漏洞安全通告|GNU InetUtils Telnetd USER环境变量注入 权限绕过漏洞(CVE-2026-24061)
数据库·安全·gnu·漏洞分析·漏洞预警
QuantumRedGuestk3 个月前
DEDECMS靶场CSRF漏洞分析与安全防护
网络安全·漏洞分析·csrf·dedecms
阿菜ACai3 个月前
20260109 - TRU 协议攻击事件分析:买得够多免费送了喂!
漏洞分析
阿菜ACai3 个月前
20250702 - FPC Token 攻击事件:严格的限制,灵活的黑客
漏洞分析
阿菜ACai4 个月前
20251217 - Yearn 攻击事件2:协议授人以柄错设地址,黑客自断一臂巧控价格
漏洞分析
阿菜ACai4 个月前
20251205 - USPD 攻击事件:初始化缺失露破绽,黑客潜伏多日终得手
漏洞分析
阿菜ACai5 个月前
20251124-DRLVaultV3安全事件:链上实时计算的滑点就等于没有滑点
漏洞分析
阿菜ACai5 个月前
20251103 - Balancer 攻击事件:还是 batchSwap,还是价格操纵+精度丢失
漏洞分析
阿菜ACai5 个月前
20230827 - Balancer 攻击事件:价格操纵 + 精度丢失的经典组合拳
漏洞分析