Back to Blog

攻击分析 | 未经检查的映射导致Nomad桥损失2亿美元

Code Auditing
August 2, 2022
8 min read

摘要

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

背景

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

  1. Nomad在每个支持的区块链上部署一个名为Replica的核心合约,作为任何跨链消息的邮箱。
  2. 链下代理在默克尔树中传递和安排跨链消息,并通过向该合约发布签名的新的树根哈希来更新树根。
  3. 需要在链上确认的新消息必须经过prove()process()两个步骤。
    1. prove()步骤验证默克尔树中的消息和证明,然后将消息标记为已证明。
    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;
}

我们立即注意到这里有一个重大变化:不知何故,开发人员决定将计算出的根记录为已证明状态,而不是特殊标记。对于这个函数来说,这没关系,因为默克尔树根哈希保证不为零。这也是合理的,因为一旦树根得到确认,任何使用此树根证明的新消息就可以执行。

然后我们检查新版本中的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]这一行。检索不存在的映射条目会返回零,这是一个常见的陷阱。 在这个上下文中,这意味着与此消息哈希关联的默克尔树根为零。我们需要进一步检查这个零的结果。所以我们应该仔细检查新的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映射,以查看默克尔树根是否已确认。

不幸的是,在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开发人员在处理映射时应特别注意,以避免意外结果。

Sign up for the latest updates
Newsletter - April 2026
Security Insights

Newsletter - April 2026

In April 2026, the DeFi ecosystem experienced three major security incidents. KelpDAO lost ~$290M due to an insecure 1-of-1 DVN bridge configuration exploited via RPC infrastructure compromise, Drift Protocol suffered ~$285M from a multisig governance takeover leveraging Solana's durable nonce mechanism, and Rhea Finance incurred ~$18.4M following a business logic flaw in its margin-trading module that allowed circular swap path manipulatio

~$7.04M Lost: GiddyDefi, Volo Vault & More | BlockSec Weekly
Security Insights

~$7.04M Lost: GiddyDefi, Volo Vault & More | BlockSec Weekly

This BlockSec weekly security report covers eight attack incidents detected between April 20 and April 26, 2026, across Ethereum, Avalanche, Sui, Base, HyperLiquid, and MegaETH, with total estimated losses of approximately $7.04M. The highlighted incident is the $1.3M GiddyDefi exploit, where the attacker did not break any cryptography or use a flash loan but simply replayed an existing on-chain EIP-712 signature with the unsigned `aggregator` and `fromToken` fields swapped out for a malicious contract, demonstrating how partial signature coverage turns any historical signature into a generic permit. Other incidents include a $3.5M Volo Vault operator key compromise on Sui, a $1.5M Purrlend privileged-role takeover, a $413K SingularityFinance oracle misconfiguration, a $142.7K Scallop cross-pool index injection, a $72.35K Kipseli Router decimal mismatch, a $50.7K REVLoans (Juicebox) accounting pollution, and a $64K Custom Rebalancer arbitrary-call exploit.

Weekly Web3 Security Incident Roundup | Apr 13 – Apr 19, 2026
Security Insights

Weekly Web3 Security Incident Roundup | Apr 13 – Apr 19, 2026

This BlockSec weekly security report covers four attack incidents detected between April 13 and April 19, 2026, across multiple chains such as Ethereum, Unichain, Arbitrum, and NEAR, with total estimated losses of approximately $310M. The highlighted incident is the $290M KelpDAO rsETH bridge exploit, where an attacker poisoned the RPC infrastructure of the sole LayerZero DVN to fabricate a cross-chain message, triggering a cascading WETH freeze across five chains and an Arbitrum Security Council forced state transition that raises questions about the actual trust boundaries of decentralized systems. Other incidents include a $242K MMR proof forgery on Hyperbridge, a $1.5M signed integer abuse on Dango, and an $18.4M circular swap path exploit on Rhea Finance's Burrowland protocol.

Best Security Auditor for Web3

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

BlockSec Audit