Back to Blog

攻击分析 | 如何因未受控的映射导致 Nomad Bridge 损失 2 亿美元

Code Auditing
August 2, 2022

摘要

2022年8月2日,名为Nomad Bridge的跨链桥遭到攻击,损失近2亿美元。根本原因是链上智能合约升级版本中的检查不正确。

背景

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

  1. Nomad在每个支持的区块链上部署一个名为Replica的核心合约,作为任何跨链消息的邮箱。
  2. 链下代理以Merkle树的形式中继和安排跨链消息,并通过向此合约发布签名的新的树根哈希来更新树根。
  3. 需要在链上确认的新消息必须经过prove()process()这两个过程。
    1. prove()过程验证Merkle树中的消息和证明,然后将消息标记为已证明。
    2. process()过程在消息先前已证明且关联的树根已确认的情况下,检查并执行消息。

代码

在以太坊上,Replica是一个Beacon代理,部署在0x5d94309e5a0090b165fa4181519701637b6daeba。该逻辑合约有两个版本,第一个版本部署在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合约之前并不知道此消息。

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

结论

这是另一个利用了从映射中检索的未检查返回值来执行的经典攻击。Solidity开发者在处理映射时应特别注意,以避免意外结果。

Sign up for the latest updates
Weekly Web3 Security Incident Roundup | Mar 16 – Mar 22, 2026
Security Insights

Weekly Web3 Security Incident Roundup | Mar 16 – Mar 22, 2026

This BlockSec weekly security report covers seven DeFi attack incidents detected between March 16 and March 22, 2026, across Ethereum, BNB Chain, Polygon, and Polygon zkEVM, with total estimated losses of approximately $82.7M. The most significant event was the Resolv stablecoin protocol's infrastructure-key compromise, which led to over $80M in unauthorized USR minting and cross-protocol contagion across lending markets. Other incidents include a $2.15M donation attack combined with market manipulation on Venus Protocol, a $257K empty-market exploit on dTRINITY (Aave V3 fork), access control vulnerabilities in Fun.xyz and ShiMama, a weak-randomness exploit in BlindBox, and a redemption accounting flaw in Keom.

Building a Secure Stablecoin Payment Network: BlockSec Partners with Morph
Partnership

Building a Secure Stablecoin Payment Network: BlockSec Partners with Morph

BlockSec has partnered with Morph as an official audit partner for the $150M Morph Payment Accelerator. By offering exclusive discounts on smart contract audits and penetration testing, BlockSec provides institutional-grade security to payment builders, ensuring a safe and resilient foundation for the future of global stablecoin payments.

Weekly Web3 Security Incident Roundup | Mar 9 – Mar 15, 2026
Security Insights

Weekly Web3 Security Incident Roundup | Mar 9 – Mar 15, 2026

This BlockSec weekly security report covers eight DeFi attack incidents detected between March 9 and March 15, 2026, across Ethereum and BNB Chain, with total estimated losses of approximately $1.66M. Incidents include a $1.01M AAVE incorrect liquidation caused by oracle misconfiguration, a $242K exploit on the deflationary token MT due to flawed trading restrictions, a $149K exploit on the burn-to-earn protocol DBXen from `_msgSender()` and `msg.sender` inconsistency, and a $131K attack on AM Token exploiting a flawed delayed-burn mechanism. The report provides detailed vulnerability analysis and attack transaction breakdowns for each incident.

Best Security Auditor for Web3

Validate design, code, and business logic before launch. Aligned with the highest industry security standards.

BlockSec Audit