当空值意味着有效:利用 MPT 证明验证来获取另一种真实

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

当空表示有效:利用 MPT 证明验证来获得另一种真相

在一个用于链上验证 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) 中的哪一个。

主要的节点类型有:

  • 分支节点:包含 17 个字段,前 16 个用于子指针(或它们的哈希),第 17 个可选地在该 trie 中的该点存储一个值。
  • 叶子节点:存储剩余的键片段和相应的值,表示 trie 中的终端路径。
  • 扩展节点:通过将单子路径的序列折叠成包含共享键片段的单个节点来优化 trie(为简洁起见,此处省略)。

状态 Trie 和存储 Trie

在以太坊中,Merkle-Patricia Tries (MPTs) 是一种基础数据结构。为了便于分析,我们重点关注两个关键实例:状态 trie存储 Tries

状态 Trie:以太坊的全局状态

状态 Trie 代表以太坊的 世界状态,即所有帐户数据的快照。状态 Trie 中的每个键都是 帐户地址的 Keccak-256 哈希,使所有键的长度正好为 64 nibbles(32 字节)。值仅存储在 叶子节点 中,而不是存储在中间节点中。

每个叶子节点都包含一个帐户记录,该记录由以下内容组成:

  1. Nonce:从帐户发送的交易数量。
  2. 余额:帐户的 ETH 余额。
  3. 存储根:帐户的存储 Trie 的根哈希。
  4. 代码哈希:合约字节码的哈希值。
存储 Tries:智能合约存储

存储 Tries 是以太坊智能合约存储其数据的地方。每个存储槽索引 s 都使用 Keccak-256 进行哈希运算,并将生成的 32 字节值用作键。相应的值是存储在该槽中的 32 字节字。

MPT 中的证明验证如何工作?

在了解了状态和存储 Tries 如何组织以太坊的数据之后,我们现在可以研究加密机制,这些机制允许任何人证明特定值存在(或不存在)在这些 tries 中。

验证 Merkle-Patricia Trie (MPT) 证明在概念上类似于验证标准 Merkle 树中的证明:目标是从证明中重建 根哈希,并将其与已知的 "真实" 根哈希(受信任的状态锚点)进行比较。如果它们匹配,则证明有效。

但是,由于它们的分支结构以及对 包含证明排除证明 的支持,MPT 引入了额外的复杂性。

包含证明

要证明某个节点(即键值对)存在于 trie 中:

  • 该证明必须包含从根到目标节点的完整节点路径。
  • 每个节点都包含所有可能的子节点的哈希(分支节点为 16 个条目),使得验证者可以重建 trie 的每个步骤。
  • 要证明的键至关重要,因为它的 nibbles 定义了每个级别的路径方向。
  • 如果路径上的所有节点都存在并且被正确哈希,则可以将最终重建的根与受信任的根哈希进行比较。

这确认指定的键存在于 trie 中,并且相应的值是真实的。

排除证明

MPT 还允许有效的 排除证明,表明特定的键 存在于 trie 中。

在这种情况下:

  • 证明者提供通过 trie 的部分路径,该路径仅延伸到到达 死胡同 之前的最深现有节点。
  • 当给定 nibble 位置预期的子节点不存在时(通常在节点的子数组中表示为 0 或空值),就会发生此死胡同。
  • 验证者检查路径是否完全在声称的键 继续的位置终止,并且该位置不存在其他子节点。

这令人信服地表明该键不存在于 trie 中,因为它无法在不踏入不存在的分支的情况下访问。

为什么要在链上验证状态证明?

以太坊 Layer 2 (L2) 解决方案旨在 扩展吞吐量并降低 gas 成本,而不会损害 Layer 1 (L1) 的安全保证。为了实现这一点,许多协议将面向用户的交互卸载到 L2,同时将其 核心状态和逻辑锚定在以太坊主网 (L1) 上)

这种设置带来了一个挑战:L2 合约如何信任和交互存在于 L1 上的数据,而无需在两层之间不断桥接?频繁的桥接 成本高昂且速度慢,这使得它对于实时或高频率用例而言是不切实际的。

解决方案是 桥接单一的真理来源,通常是将最近 L1 区块的区块哈希桥接到 L2。此区块哈希充当 加密信任锚点。它包括 L1 区块头,其中包含:

  • 状态 Trie 根哈希(代表整个以太坊世界状态)。
  • 以及传递地,每个智能合约的存储 Trie 根哈希。

仅在这一个在 L2 上的信任锚点的情况下,用户可以生成任何智能合约在 L1 上的存储槽值的 可验证证明。这使 L2 智能合约能够 独立验证 来自 L1 的关键链上数据,例如:

  • Oracle 价格
  • 投票权余额
  • 白名单或允许列表
  • 存储在 L1 上的任何其他有状态变量

与 L2 交互的用户只需在提交交易时 附加状态证明(基于桥接的 L1 区块哈希)。L2 合约使用此证明来验证所声明的 L1 状态,而无需回调到 L1

存在漏洞的 Solidity 库

该漏洞的核心是一个 Solidity 库,该库负责验证 Merkle-Patricia Trie (MPT) 证明,特别是确定以太坊状态 trie 中是否存在某个键。该库广泛用于在 L2 上验证 L1 状态的跨链消息传递和 oracle 协议中。

该库最初是从一个 维护很少的开源实现 分叉出来的,多年来没有经过严格的审计或更新。它公开了一个函数,该函数处理证明(作为序列化的 MPT 节点的数组)、一个键和一个已知的根哈希,返回匹配的叶子节点值、非包含的空值,或者恢复为无效的证明。

主验证逻辑是一个 for 循环,它逐个节点地遍历证明路径,从顶部开始。在每个步骤中,它执行以下操作:

  • 验证当前节点的哈希是否与预期哈希匹配(从受信任的根开始)。
  • 将当前键 nibble 与父节点中指示的子节点匹配。
  • 同步前进证明路径和键 nibble 序列。

根据遍历是在叶子、分支还是死胡同结束,该函数得出结论:

  • 有效包含:到达叶子或分支节点,并且完全使用了键和证明。
  • 有效排除:路径提前结束,或者在分支中间遇到 0 子指针。
  • 无效证明:哈希中的任何不匹配或意外结构都会导致 revert

该错误:静默退出验证循环

验证逻辑中的一个细微的控制流缺陷允许攻击者提交 伪造的排除证明,这些证明通过所有验证,同时错误地声称某个键在 trie 中不存在。

哪里出错了

该库的 for 循环遍历提供的证明路径的节点,同时前进:

  • 节点路径,以及
  • 键(逐个 nibble)

假设是 每个有效证明 都会:

  • 到达具有完全匹配的键的叶子或分支节点(包含证明),
  • 到达死胡同或不匹配的节点(排除证明),
  • 或者触发无效或格式错误的输入上的 revert

但是,这个假设是不正确的。如果满足以下所有条件,则循环可以 自然退出,而不会遇到显式的 returnrevert

  • 键未完全使用,并且
  • 没有遇到死胡同,并且
  • 没有到达叶子节点,并且
  • 没有检测到哈希不匹配或其他无效状态。

在这种情况下,循环只是用完要处理的节点,然后 失败,到达隐式函数返回:

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),即使它包含非零值。这为诸如以下之类的关键漏洞打开了大门:

  • 伪造 oracle 价格
  • 伪造投票权
  • 绕过访问控制或白名单

这个错误说明了智能合约逻辑中即使是微小的控制流疏忽也可能具有 毁灭性的安全影响

如何修复

当对证明节点的循环完成而未遇到任何 returnrevert 时,就会出现问题,这会导致该函数失败并返回一个未初始化(空)的字节数组,从而错误地发出有效排除证明的信号。

修复方法很简单:在函数末尾添加显式的 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,以进行独立验证并协助协调披露。

披露时间表

  • 2025 年 3 月 11 日:Curve Finance 的 Roman 发现该漏洞并联系 ChainSecurity
  • 2025 年 3 月 11 日:ChainSecurity 在当晚确认了该漏洞
  • 2025 年 3 月 12-14 日:在已部署的合约中进行了全面搜索,以识别所有受影响的协议
  • 2025 年 3 月 14 日:首次披露给 Frax Finance
  • 2025 年 3 月 15 日:披露范围扩大到包括 Aave 和 StakeDAO
  • 2025 年 3 月 15-31 日:协议有时间按照自己的节奏实施修复和升级
  • 2025 年 3 月 31 日:向易受攻击库和类似实现的用户的发送更广泛的通知,这些用户未发现受到直接影响,但可能存在风险

我们感谢 Roman 对此关键漏洞的负责任披露,并感谢所有受影响的协议迅速响应并实施必要的修复。

关于我们

ChainSecurity 的使命是在区块链生态系统中建立信任,以使这项新兴技术能够在已建立的组织、政府和区块链公司中发挥其潜力。

如果你有任何疑问,请随时通过 contact@chainsecurity.com 联系我们,以获取包括审计请求在内的一般请求,以及有关此漏洞或其他漏洞的问题。此外,请访问我们的网站 chainsecurity.com

external-link

  • 原文链接: chainsecurity.com/blog/w...
  • 登链社区 AI 助手,为大家转译优秀英文文章,如有翻译不通的地方,还请包涵~
点赞 0
收藏 0
分享
本文参与登链社区写作激励计划 ,好文好收益,欢迎正在阅读的你也加入。

0 条评论

请先 登录 后评论
chainsecurity
chainsecurity
江湖只有他的大名,没有他的介绍。