一个广泛使用的 Solidity 开源库中发现了一个严重漏洞,该漏洞允许恶意行为者提交伪造的排除证明。攻击者可以操纵 L2 对 L1 状态的视图,使协议确信 L1 上的特定存储槽未初始化,从而伪造预言机价格和治理投票权等关键数据,造成经济损失。所有受影响的协议都已修补此问题。

在一个用于链上验证 Merkle-Patricia-Trie 证明的库中发现了一个严重的缺陷。使用它将操作桥接到 L2 的协议可能会被误导而接受错误的 state proofs,从而导致直接的资金损失。

在一个广泛使用的开源 Solidity 库中,一个关键的疏忽允许恶意行为者提交伪造的 排除证明。该漏洞影响了将以太坊 Layer 1 (L1) 的 状态根哈希 桥接到 Layer 2 (L2) 的跨链协议,这些协议依赖该库来验证状态证明。
攻击者可以操纵 L2 对 L1 状态的看法,说服协议 L1 上特定的存储槽未初始化(即,在 trie 中不存在),因此值为 0。这为伪造诸如 oracle 价格和治理投票权等关键数据打开了大门,造成了巨大的财务损失风险。
幸运的是,所有已知的受影响协议现在都已经修复了该问题。在本文中,我们将分享我们对该漏洞的技术深入研究。
什么是 Merkle-Patricia Tries?

Merkle-Patricia Trie (MPT) 是以太坊中的一种核心数据结构,用于高效地存储和验证具有密码学保证的键值对。 MPT 支持以太坊的 世界状态,即帐户和智能合约存储的完整映射,并且对于协议如何验证和传播状态至关重要。
本质上,MPT 将 Merkle 树的原理与 Patricia tries 相结合,以实现高效的查找、插入和紧凑的 包含或排除证明。如需全面解释,我们推荐以太坊的官方文档。下面,我们提供一个简明扼要的概述,假设你对 Merkle 树有一定的了解。
在 MPT 中,值 v 存储在与其键 k 对应的节点中。与二叉树不同,以太坊的 MPT 使用 16 的分支因子,这意味着它遵循键的 nibbles (4 位块,即十六进制字符) 来遍历 trie。每个 nibble 指示在给定节点上要采用的 16 个可能路径 (0-f) 中的哪一个。
主要的节点类型有:

状态 Trie 和存储 Trie
在以太坊中,Merkle-Patricia Tries (MPTs) 是一种基础数据结构。为了便于分析,我们重点关注两个关键实例:状态 trie 和 存储 Tries。
状态 Trie 代表以太坊的 世界状态,即所有帐户数据的快照。状态 Trie 中的每个键都是 帐户地址的 Keccak-256 哈希,使所有键的长度正好为 64 nibbles(32 字节)。值仅存储在 叶子节点 中,而不是存储在中间节点中。
每个叶子节点都包含一个帐户记录,该记录由以下内容组成:
存储 Tries 是以太坊智能合约存储其数据的地方。每个存储槽索引 s 都使用 Keccak-256 进行哈希运算,并将生成的 32 字节值用作键。相应的值是存储在该槽中的 32 字节字。
MPT 中的证明验证如何工作?
在了解了状态和存储 Tries 如何组织以太坊的数据之后,我们现在可以研究加密机制,这些机制允许任何人证明特定值存在(或不存在)在这些 tries 中。
验证 Merkle-Patricia Trie (MPT) 证明在概念上类似于验证标准 Merkle 树中的证明:目标是从证明中重建 根哈希,并将其与已知的 "真实" 根哈希(受信任的状态锚点)进行比较。如果它们匹配,则证明有效。
但是,由于它们的分支结构以及对 包含证明 和 排除证明 的支持,MPT 引入了额外的复杂性。
要证明某个节点(即键值对)存在于 trie 中:
这确认指定的键存在于 trie 中,并且相应的值是真实的。
MPT 还允许有效的 排除证明,表明特定的键 不 存在于 trie 中。
在这种情况下:
这令人信服地表明该键不存在于 trie 中,因为它无法在不踏入不存在的分支的情况下访问。
以太坊 Layer 2 (L2) 解决方案旨在 扩展吞吐量并降低 gas 成本,而不会损害 Layer 1 (L1) 的安全保证。为了实现这一点,许多协议将面向用户的交互卸载到 L2,同时将其 核心状态和逻辑锚定在以太坊主网 (L1) 上)。
这种设置带来了一个挑战:L2 合约如何信任和交互存在于 L1 上的数据,而无需在两层之间不断桥接?频繁的桥接 成本高昂且速度慢,这使得它对于实时或高频率用例而言是不切实际的。
解决方案是 桥接单一的真理来源,通常是将最近 L1 区块的区块哈希桥接到 L2。此区块哈希充当 加密信任锚点。它包括 L1 区块头,其中包含:
仅在这一个在 L2 上的信任锚点的情况下,用户可以生成任何智能合约在 L1 上的存储槽值的 可验证证明。这使 L2 智能合约能够 独立验证 来自 L1 的关键链上数据,例如:
与 L2 交互的用户只需在提交交易时 附加状态证明(基于桥接的 L1 区块哈希)。L2 合约使用此证明来验证所声明的 L1 状态,而无需回调到 L1。
该漏洞的核心是一个 Solidity 库,该库负责验证 Merkle-Patricia Trie (MPT) 证明,特别是确定以太坊状态 trie 中是否存在某个键。该库广泛用于在 L2 上验证 L1 状态的跨链消息传递和 oracle 协议中。
该库最初是从一个 维护很少的开源实现 分叉出来的,多年来没有经过严格的审计或更新。它公开了一个函数,该函数处理证明(作为序列化的 MPT 节点的数组)、一个键和一个已知的根哈希,返回匹配的叶子节点值、非包含的空值,或者恢复为无效的证明。
主验证逻辑是一个 for 循环,它逐个节点地遍历证明路径,从顶部开始。在每个步骤中,它执行以下操作:
根据遍历是在叶子、分支还是死胡同结束,该函数得出结论:
.png)
验证逻辑中的一个细微的控制流缺陷允许攻击者提交 伪造的排除证明,这些证明通过所有验证,同时错误地声称某个键在 trie 中不存在。
哪里出错了
该库的 for 循环遍历提供的证明路径的节点,同时前进:
假设是 每个有效证明 都会:
但是,这个假设是不正确的。如果满足以下所有条件,则循环可以 自然退出,而不会遇到显式的 return 或 revert:
在这种情况下,循环只是用完要处理的节点,然后 失败,到达隐式函数返回:
for (uint i = 0; i < stack.length; i++) {
bytes memory rlpNode = stack[i].toRlpBytes();
if (i == 0 && rootHash != keccak256(rlpNode)) revert();
if (i != 0 && nodeHashHash != mptHashHash(rlpNode)) revert();
RLPReader.RLPItem[] memory node = stack[i].toList();
if (node.length == 2) {
...
} else if (node.length == 17) {
// Branch node
if (mptKeyOffset != mptKey.length) {
// we haven't consumed the entire path, so we need to look at a child
uint8 nibble = uint8(mptKey[mptKeyOffset]);
mptKeyOffset += 1;
if (nibble >= 16) {
// each element of the path has to be a nibble
revert();
}
if (isEmptyBytesequence(node[nibble])) {
if (i != stack.length - 1) revert();
return new bytes(0);
} else if (!node[nibble].isList()) {
nodeHashHash = keccak256(node[nibble].toBytes());
} else {
nodeHashHash = keccak256(node[nibble].toRlpBytes());
}
// ###############################################
// /!\ Warning: If i == stack.length - 1 then the
// loop will fall through, and the function will
// return silently.
// ###############################################
} else {
// we have consumed the entire mptKey, so we need to look at what's contained in this node.
if (i != stack.length - 1) revert();
return node[16].toBytes();
}
}
}
// Silent fallthrough: returns an empty byte array
由于该函数具有 bytes 类型的命名返回值,因此它返回一个 未初始化(空)的字节数组,按照惯例,该字节数组被解释为 有效的排除证明。
要触发此漏洞,攻击者可以:
获取现有节点的有效包含证明,并 截断证明路径,同时保持键不变。
因为截断的路径仍然在每个步骤中正确哈希,所以验证者没有理由拒绝它。但是,由于该证明过早结束并跳过任何显式返回条件,因此它会静默退出循环并返回一个空字节数组,错误地证明该节点不存在。
在使用此库验证 L2 上的 L1 存储状态的协议中,攻击者可以错误地说服 L2 验证者 存储槽未设置(即,值为 0),即使它包含非零值。这为诸如以下之类的关键漏洞打开了大门:
这个错误说明了智能合约逻辑中即使是微小的控制流疏忽也可能具有 毁灭性的安全影响。
当对证明节点的循环完成而未遇到任何 return 或 revert 时,就会出现问题,这会导致该函数失败并返回一个未初始化(空)的字节数组,从而错误地发出有效排除证明的信号。
修复方法很简单:在函数末尾添加显式的 revert****。这确保了如果没有满足任何预期条件(例如,有效的包含或排除),则该证明将被视为无效,而不是被静默接受。
这是一个最小的补丁:
for (uint i = 0; i < stack.length; i++) {
// existing verification logic...
}
// If execution reaches here, the proof was inconclusive or malformed
revert("Invalid MPT proof");
这保证了每个代码路径都会导致一个有意的结果:有效的包含、有效的排除或显式拒绝。没有更多静默失败。
此漏洞由 Curve Finance 的 Roman ( @agureevroman) 于 2025 年 3 月 11 日发现。在发现该问题后,Roman 立即联系 ChainSecurity,以进行独立验证并协助协调披露。
披露时间表
我们感谢 Roman 对此关键漏洞的负责任披露,并感谢所有受影响的协议迅速响应并实施必要的修复。
ChainSecurity 的使命是在区块链生态系统中建立信任,以使这项新兴技术能够在已建立的组织、政府和区块链公司中发挥其潜力。
如果你有任何疑问,请随时通过 contact@chainsecurity.com 联系我们,以获取包括审计请求在内的一般请求,以及有关此漏洞或其他漏洞的问题。此外,请访问我们的网站 chainsecurity.com。
- 原文链接: chainsecurity.com/blog/w...
- 登链社区 AI 助手,为大家转译优秀英文文章,如有翻译不通的地方,还请包涵~
如果觉得我的文章对您有用,请随意打赏。你的支持将鼓励我继续创作!