惯性聚合 高效追踪和阅读你感兴趣的博客、新闻、科技资讯
阅读原文 在惯性聚合中打开

推荐订阅源

G
Google Developers Blog
Jina AI
Jina AI
大猫的无限游戏
大猫的无限游戏
Martin Fowler
Martin Fowler
博客园 - 司徒正美
云风的 BLOG
云风的 BLOG
C
Cybersecurity and Infrastructure Security Agency CISA
CTFtime.org: upcoming CTF events
CTFtime.org: upcoming CTF events
S
Securelist
S
Security Affairs
freeCodeCamp Programming Tutorials: Python, JavaScript, Git & More
L
LINUX DO - 热门话题
博客园 - 三生石上(FineUI控件)
T
Threatpost
T
The Blog of Author Tim Ferriss
C
CERT Recently Published Vulnerability Notes
IT之家
IT之家
P
Palo Alto Networks Blog
Microsoft Azure Blog
Microsoft Azure Blog
Spread Privacy
Spread Privacy
Cyberwarzone
Cyberwarzone
腾讯CDC
L
LangChain Blog
Know Your Adversary
Know Your Adversary
C
CXSECURITY Database RSS Feed - CXSecurity.com
GbyAI
GbyAI
Threat Intelligence Blog | Flashpoint
Threat Intelligence Blog | Flashpoint
I
Intezer
T
Tor Project blog
AWS News Blog
AWS News Blog
T
Tenable Blog
NISL@THU
NISL@THU
Security Latest
Security Latest
cs.CL updates on arXiv.org
cs.CL updates on arXiv.org
H
Hackread – Cybersecurity News, Data Breaches, AI and More
人人都是产品经理
人人都是产品经理
MongoDB | Blog
MongoDB | Blog
MyScale Blog
MyScale Blog
D
DataBreaches.Net
Microsoft Security Blog
Microsoft Security Blog
钛媒体:引领未来商业与生活新知
钛媒体:引领未来商业与生活新知
量子位
美团技术团队
The Cloudflare Blog
酷 壳 – CoolShell
酷 壳 – CoolShell
罗磊的独立博客
The GitHub Blog
The GitHub Blog
阮一峰的网络日志
阮一峰的网络日志
OSCHINA 社区最新新闻
OSCHINA 社区最新新闻
Stack Overflow Blog
Stack Overflow Blog

博客园_首页

Plist 二进制格式 Milvus 和 PGVector,哪个更好? OpenClaw 已过时?在 VS Code 中运行 Hermes Agent! 分享一下笔者的 Mac 装机必备软件 第30篇文章:一个大三计科生的自白 Manim如何在数学公式中完美显示中文? Docker 部署 RocketMQ 5 并发编程核心概念辨析 C#事务处理最佳实践:别再让“主表存了、明细丢了”的破事发生 CLI 是什么?为什么大厂突然集体卷命令行? 【从0到1构建一个ClaudeAgent】协作-自主Agent UIImageView 设置图片不生效的原因排查 最小二乘问题详解20:无先验约束下的增量式SFM自由网平差 痞子衡嵌入式:大话双核i.MXRT1180之XIP应用里借助MU实现可靠Flash IAP的方法 AI Chat 封装, SemanticKerne.AiProvider.Unified 已发布 Windows下右键编辑js文件无法打开记事本——在注册表中使用环境变量 在后台服务中使用 Scoped 服务,为什么总是报错? H200 安装驱动并使用sglang启动模型 wireshark 抓包Trap上报告警内容 我用 AI 辅助开发了一系列小工具(2):图片压缩工具 [A Primer On MC and CC] 2.1 Memory Consistency 1 - 指令重排序和 SC 模型 Oracle数据库SCN推进技术详解与实践指南 玩转控件:封装个带图片的Label控件 Claude Code 4.7 真正该升级的不是模型,而是你的工作流 前端小白一句话,AI 帮我做了个颜值拉满的桌面媒体播放器。当代码不再是门槛,一句话编程就是现实。 5. WorkBuddy: 小龙虾的灵魂三件套,让你的小龙虾不只是工具 SQLite 分片方案实战:三种分片策略的深度对比 告别简陋 UI!一款基于 Fluent Design 和基于 WinUI 的开源免费、现代化的 Avalonia UI 控件库 关于二进制排列组合枚举的总结 AI开发-python-LangGraph框架(3-27-LangGraph从零实现大模型智能决策工作流) ElasticSearch主分片和副本分片概念详解 【002】HTTPS 粗解:证书、TLS 握手与对后端配置的影响 Hermes Agent 一周暴涨五万 Star,但我劝你别急着追 明明连接的是Redis的DB0,为什么能查到DB3的数据? 【从0到1构建一个ClaudeAgent】协作-Agent团队 熟悉电子元器件之后,电子小白下一步该怎么走? MAF快速入门(23)通过C#类定义Skills .NET 高级开发 | 手写一个对象映射框架 FastAPI数据库ORM怎么选?我肝了三个Demo后,终于不再纠结了 mysqldump 参数拾遗:在遗忘与铭记之间 C# .NET 周刊|2026年3月5期 Claude code入门 - 陈彦斌 一文学习入门 ThingsBoard 开源物联网平台 GitHub 热门项目 | 2026年04月16日 如何为GIT设置全局勾子,为每次提交追加信息 Number.isFinite和isFinite与isNaN()和Number.isNaN的区别 PortSwigger SQL注入LAB2 推荐一个测试人必备的Skills,从功能到性能全搞定(附详细实操和安装下载方式) 筑基期:掌握Odoo基础核心知识点02(Odoo XML 开发方式详解) GLM模型这么火,咱们用vllm也咧一个呗! 深入理解 AbortController:从底层原理到跨语言设计哲学 字符串学习笔记 多租户系统框架的基础模块设计和分析设计 Apache SeaTunnel Zeta 为什么能做到“又快又稳”? AI开发-python-LangGraph框架(3-26-LangGraph基本概念及第一个简单样例) Vue 3 组件通信,别只会用 Props 和 Emits 了,这几个狠活儿你得看看 ElasticSearch7.X版本配置密码 用Manim实现动态交点计算--从一个动点问题说起 团结引擎+Addressable+Instant Game打包抖音小游戏 function call 实战:让 LLM 自动判断 pod 异常、调用日志工具并完成故障分析 bubseek —— 让 Agent 的足迹,变成团队的洞察 通过 C# 读取并导出 PDF 书签 如何用 GitHub Actions 实现 Steam 自动化发布 【从0到1构建一个ClaudeAgent】并发-后台任务 .NET 高级开发 | 定制 ASP.NET Core 框架 电子小白:什么是运算放大器(运放) zero2Agent:面向大厂面试的 Agent 工程教程,从概念到生产的完整学习路线 堆上的ORW HC32F460 USB CDC通信异常:非对齐访问异常排查 那些喊着AI 要淘汰你的人,正在靠你的焦虑赚大钱! 深度学习进阶(八)Swin Transformer 最小二乘问题详解19:带先验约束的增量式SFM优化与实现 SnapTranslate 3.0 正式发布:全局划词翻译 + 完整英语学习闭环,一站式搞定查词、记词、复习 工作的意义、工作的困难认知再思考 .NET + AI 进阶实战:基于类的技能开发 - 打造可治理的 Agent 能力模块 【从0到1构建一个ClaudeAgent】规划与协调-技能 上周热点回顾(4.6-4.12) 电子小白的工具三件套:面包板、杜邦线、万能板 单表五亿数据的查询优化 | Mysql、StarRocks 2. WorkBuddy:从“我是谁”到“帮我干活” C# 如何减少代码运行时间:7 个实战技巧 基于HelixToolkit.SharpDX 渲染3D模型 - 笺上知微 从零开始的双臂具身VLA起源及现阶段发展综述 - SkyXZ 记对 xonsh shell 的使用, 脚本编写, 迁移及调优 - pluvium27 受够了Vibe Coding的失控?换个起点,让AI事半功倍 从开始配置漏洞环境到漏洞复现流程 - 難しい 关于10年工作经验的程序员对OpenClaw的实战经验分享以及看法 - 虚无境 Any metadata 的内存布局 C# .NET 周刊|2026年3月2期 - InCerry 我帮你测过了,测试圈排名第二的 Skill 依然很牛逼 Skill Discovery | 无监督技能发现的经典工作总结 - MoonOut 上下文工程是什么?过时了么?一文讲明白! - 一枫说码 开了 TUN 模式还是直连?90% 的人都踩过这个坑 AScript扩展多种脚本语言 - rockey627 AI 学习笔记:Agent 的记忆机制 你能被装进一个文件里吗?——7 万人把同事"蒸馏"成了 AI - 我没有三颗心脏 Claude Code 通关手册(七):给 AI 装上技能包——Skills 完全指南 - 暮色之狐 在浏览器中快速编辑代码:VSCode Web 集成实践 - Newbe36524 蒸馏自己 skill?基于 Deepseek 的蒸馏器,丐版蒸馏方式,简单便捷 - To_Carpe_Diem Spring AI Aliababa和AgentScope,哪个更好? - 苏三说技术
20260413-Hyperbridge 攻击事件:发生在默克尔山上的验证绕过
ACai_sec · 2026-04-15 · via 博客园_首页

背景信息

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

Trace 分析

image

  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(山峰)。

image

新增过程

每次新增一个新叶子时:

  1. 把新增叶子作为一棵高度为 0 的山放到最右边。
  2. 进行合并检查:如果最右边两座山的高度相同,就把它们合并(对两个根做一次哈希,生成一个新的父节点,形成一棵更高的山)。
  3. 重复步骤 2,直到最右边两座山高度不同为止。
  4. 此时 MMR 形成了一排从高到低排列的山峰形式。

举例说明

在原有两个叶子的 MMR 添加第 3 个叶子时,旁边多出一座高度 0 的小山。

image

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

image

数学规律

如果当前有 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

image

阶段一: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 压入栈。

image


第五部分: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 铸币权限的调用。

image

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

后记

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