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

erkle 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(阶段二),第六部分是最终输出。

接下来我们结合攻击者的输入进行分析,排查出漏洞出现的位置,以及攻击者是如何利用这个漏洞构造一次成功的攻击的。

重点关注以下部分的参数(完整版本的参数请自行查看)

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() 函数分析

第一部分:单叶特判

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 分解 + 迭代器初始化

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 值。


第三部分:主循环——逐座山处理

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


第四部分:叶子分割 + 三路分支

MmrLeaf[] memory subtreeLeaves = new MmrLeaf; if (leaves.length > 0) { (subtreeLeaves, leaves) = leavesForSubtree(leaves, current_subtree); }

leavesForSubtreecurrent_subtree为分界点,把leaves切成两半:leaf_index < current_subtree的归入当前这座山(subtreeLeaves),剩下的留给后面的山(重新赋值给leaves)。

然后进入三路分支:

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

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

return peakRoots.data[0];

经过 bagging,最终结果就存在peakRoots.data[0],这就是整个 MMR 的 root hash。

此时也就是返回了攻击者最开始传入的 root 值,也就顺利地通过了验证了。


在通过了 MMR 的校验以后,HandlerV1合约会通过调用host.dispatchIncoming()函数执行request 中的内容,也就是攻击者构建的获取 DOT 铸币权限的调用。

接下来的操作就是铸造大量的 DOT,并进行抛售获利。

后记

链上攻击事件不会等你有空的时候再发生,发生了就只能抽空出来做攻击分析了。所以本篇文章重点在 rootcause 部分的内容,对于跨链 ISMP,MMR root,Governance 这些部分的内容未能提及,读者如果感兴趣的话可