Back to Blog

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

Code Auditing
August 2, 2022
8 min read

摘要

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 开发人员在处理映射时应格外小心,以避免出现意外结果。

Best Security Auditor for Web3

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

BlockSec Audit