返回博客

攻击分析 | 映射检查缺失如何导致 Nomad 跨链桥损失 2 亿美元

Code Auditing
2022年8月2日
阅读约 8 分钟

摘要

2022 年 8 月 2 日,一个名为 Nomad Bridge 的跨链桥遭到攻击,导致近 2 亿美元的损失。根本原因是链上智能合约升级版本中的检查逻辑存在错误。

背景

Nomad Bridge 是一个新兴的跨链资产桥,采用基于欺诈证明(fraud-proof)的设计。其工作原理如下:

  1. Nomad 在每个支持的区块链上部署名为 Replica 的核心合约,作为所有跨链消息的邮箱。
  2. 链下代理负责中继消息并将其组织在 Merkle 树中,通过向该合约提交已签名的最新树根哈希来更新树根。
  3. 需要在链上确认的新消息必须同时经过 prove() 和 process() 流程。
    1. prove() 流程验证 Merkle 树中的消息和证明,然后将该消息标记为已验证(proven)。
    2. process() 流程检查并执行已验证的消息,前提是该消息此前已被验证且关联的树根已获确认。

代码分析

在以太坊上,Replica 是部署在 0x5d94309e5a0090b165fa4181519701637b6daeba 的信标代理(Beacon proxy)。逻辑合约有两个版本,第一个版本部署在 0x7f58bb8311db968ab110889f2dfa04ab7e8e831b,第二个版本部署在 0xb92336759618f55bd0f8313bd843604592e27bd8。

我们首先查看前一个版本的逻辑合约,特别是 process() 函数:

function process(bytes memory _message) public returns (bool _success) {
    bytes29 _m = _message.ref(0);
    // 确保消息是为了本域而设计
    require(_m.destination() == localDomain, "!destination");
    // 确保消息已通过验证
    bytes32 _messageHash = _m.keccak();
    require(messages[_messageHash] == MessageStatus.Proven, "!proven");
    // 检查重入保护
    require(entered == 1, "!reentrant");
    entered = 0;
    // 将消息状态更新为已处理
    messages[_messageHash] = MessageStatus.Processed;

我们仅展示了该函数的部分代码。在此代码段中,计算消息哈希值,将其与 messages 映射进行比对以确保消息此前已通过验证,随后进行重入检查,最后更新消息状态。

我们再简要回顾旧版的 prove() 函数:

function prove(
    bytes32 _leaf,
    bytes32[32] calldata _proof,
    uint256 _index
) public returns (bool) {
    // 确保消息尚未被验证或处理
    require(messages[_leaf] == MessageStatus.None, "!MessageStatus.None");
    // 根据证明计算期望的根
    bytes32 _calculatedRoot = MerkleLib.branchRoot(_leaf, _proof, _index);
    // 如果根有效,则将状态更改为 Proven
    if (acceptableRoot(_calculatedRoot)) {
        messages[_leaf] = MessageStatus.Proven;
        return true;
    }
    return false;
}

这里没有特殊之处:重复检查、计算树根,如果可接受,则标记为已验证。因此在旧版本的 Replica 合约中,所有已通过验证的消息都有一个特殊标记(MessageStatus.Proven = 1)。

接下来让我们检查第二个版本的逻辑合约。对于新版本,我们首先检查 prove() 函数:

function prove(
    bytes32 _leaf,
    bytes32[32] calldata _proof,
    uint256 _index
) public returns (bool) {
    // 确保消息尚未被处理
    // 注意:这允许在新根下重新验证
    require(
        messages[_leaf] != LEGACY_STATUS_PROCESSED,
        "already processed"
    );
    // 根据证明计算期望的根
    bytes32 _calculatedRoot = MerkleLib.branchRoot(_leaf, _proof, _index);
    // 如果根有效,则将状态更改为 Proven
    if (acceptableRoot(_calculatedRoot)) {
        messages[_leaf] = _calculatedRoot;
        return true;
    }
    return false;
}

我们立即注意到这里有一个重大变化:开发人员出于某种原因决定将计算出的根作为已验证状态记录,而不是使用特殊标记。对于此函数来说这没问题,因为 Merkle 树根哈希确保不为零。这也合乎逻辑,因为一旦树根获确认,任何使用此树根验证的新消息都可以执行。

然后我们检查新版本的 process() 函数:

function process(bytes memory _message) public returns (bool _success) {
    // 确保消息是为了本域而设计
    bytes29 _m = _message.ref(0);
    require(_m.destination() == localDomain, "!destination");
    // 确保消息已通过验证
    bytes32 _messageHash = _m.keccak();
    require(acceptableRoot(messages[_messageHash]), "!proven");
    // 检查重入保护
    require(entered == 1, "!reentrant");
    entered = 0;
    // 将消息状态更新为已处理
    messages[_messageHash] = LEGACY_STATUS_PROCESSED;

我们注意到了 messages[_messageHash] 这一行。一个常见的陷阱是,检索不存在的映射条目会返回零。 在此情境下,这意味着与此消息哈希关联的 Merkle 树根为零。我们需要进一步检查该零值的结果。因此,我们应该仔细检查新的 acceptableRoot() 函数。

function acceptableRoot(bytes32 _root) public view returns (bool) {
    // 这是为了与旧版本处理的消息保持向后兼容
    if (_root == LEGACY_STATUS_PROVEN) return true;
    if (_root == LEGACY_STATUS_PROCESSED) return false;

    uint256 _time = confirmAt[_root];
    if (_time == 0) {
        return false;
    }
    return block.timestamp >= _time;
}

基本上,此函数检查 confirmAt 映射以确认 Merkle 树根是否已获确认。

遗憾的是,在 Replica 合约的两个版本中,初始化函数都将零哈希设置为 1:

function initialize(
    uint32 _remoteDomain,
    address _updater,
    bytes32 _committedRoot, // 初始化时此值为零
    uint256 _optimisticSeconds
) public initializer {
    __NomadBase_initialize(_updater);
    // 设置存储变量
    entered = 1;
    remoteDomain = _remoteDomain;
    committedRoot = _committedRoot;
    // 预先批准提交的根
    confirmAt[_committedRoot] = 1;
    _setOptimisticTimeout(_optimisticSeconds);
}

在旧版本的 Replica 合约中,这完全没问题:在 prove() 中没有任何树根哈希可以为零,因此在 confirmAt 映射中将零哈希条目设置为 1 是安全的。

然而在新版本中,对于一条新消息,messages[_messageHash] 会返回零。随后 acceptableRoot 将访问 confirmAt 映射中的零哈希条目,并返回 true。

攻击方式

从上述代码分析可知,任何此前未见过的消息都可以直接绕过验证逻辑并被执行。因此,只需伪造一条消息并调用 process() 即可。

有趣的是,该合约中对 process() 函数的首次调用发生在两天前(区块 15249565),交易地址为:0xa654fd4152f4734fcd774dd64b618b22a1561e2528b7b8e4500d20edb05b3ba0。

如下图所示,该消息的 messages 状态变量存储槽最初为零,这意味着 Replica 合约此前并不知道此消息。

随后该槽位被设置为 2(即 LEGACY_STATUS_PROCESSED 状态,表示此消息已处理)。这表明存在一条无效消息绕过了 prove() 逻辑,被直接处理了。

结论

这是又一起利用映射返回值未进行正确校验的典型攻击。Solidity 开发人员在处理映射时应格外小心,以避免出现意外结果。

订阅最新动态
NEAR Intents 漏洞利用事件:退款缺陷与安全启示
Security Insights

NEAR Intents 漏洞利用事件:退款缺陷与安全启示

NEAR Intents 漏洞利用事件中,攻击者利用超额退款事件,使存入的资产退回后内部余额仍得以保留。本文分析重建了交易回执中的执行流程,以及在 BNB Chain 上进行的七笔提款;这些提款合计 3,865,021 USDT。本文还区分了这些资金流出与项目方报告的全部资金追回,并概述了开发和审计 NEAR 智能合约时需要考虑的安全问题。

损失约557万 USD:X100 Freezer、NEM 遭攻击|BlockSec周报
Security Insights

损失约557万 USD:X100 Freezer、NEM 遭攻击|BlockSec周报

本周报告涵盖 2026年10月19日–2026年10月25日 期间以太坊和 BNB Chain 上发生的两起安全事件。报告分析了利用闪电贷资金接管 X100 Freezer 治理权的事件,以及影响 BNB Chain 上一个 NEM 代币流动性池的税费结算缺陷。报告统计范围内的估计损失合计约 557万 USD:其中,具有代表性的 X100 交易造成约 ~475万 USD 的损失,NEM 流动性池事件造成约 ~82.0万 USD 的损失。该合计金额不包括另行报告的后续 X100 资金提取。

Autheo Network、UNCX 遭黑客攻击,损失约387万 USD | BlockSec 周报
Security Insights

Autheo Network、UNCX 遭黑客攻击,损失约387万 USD | BlockSec 周报

本周报告涵盖 2026年10月12日–2026年10月18日 期间的 3 起安全事件,估计总损失约为 387万 USD。Autheo Network 因私钥泄露损失约 176万 USD,Qubic Wallet 因种子生成机制薄弱损失约 130万 USD;UNCX Network 在 Ethereum、BNB Chain、Base 和 Robinhood Chain 上的 Smart Token Vesting V2 合约因会计核算错误损失约 80.7万 USD。重点分析的 UNCX 事件说明,有缺陷的总份额计算将按不同代币与份额兑换比率创建的归属批次混合计算,从而使人能够从共享资金池超额提取。

Web3 最佳安全审计方

在上线之前验证设计、代码与业务逻辑,对标业内最高安全标准。

BlockSec 审计