Back to Blog

攻撃分析 | チェックされないマッピングがノマドブリッジで2億ドルの損失を生む仕組み

Code Auditing
August 2, 2022

要約

2022年8月2日、Nomad Bridgeというクロスチェーンブリッジが攻撃を受け、約2億ドルの損失が発生しました。根本原因は、オンチェーンスマートコントラクトのアップグレード版におけるチェックの誤りです。

背景

Nomad Bridgeは、不正証明(fraud-proof)ベースの設計を採用した、新興のクロスチェーン資産ブリッジです。その仕組みは以下の通りです。

  1. Nomadは、サポートされている各ブロックチェーン上にReplicaというコアコントラクトをデプロイし、クロスチェーンメッセージのメールボックスとして機能させます。
  2. オフチェーンエージェントが、クロスチェーンメッセージを中継・整理してMerkleツリーを構築し、署名された新しいツリーのルートハッシュをこのコントラクトに投稿してツリーのルートを更新します。
  3. オンチェーンで確認する必要がある新しいメッセージは、prove()process()の両方の手順を経る必要があります。
    1. prove()手順は、Merkleツリー内のメッセージと証明を検証し、メッセージを「証明済み」としてマークします。
    2. process()手順は、メッセージが以前に証明されており、関連するツリーのルートが確認されている場合に、メッセージをチェックして実行します。

コード

Ethereumでは、Replicaは0x5d94309e5a0090b165fa4181519701637b6daebaでデプロイされたBeaconプロキシです。論理コントラクトには2つのバージョンがあり、最初のバージョンは0x7f58bb8311db968ab110889f2dfa04ab7e8e831bでデプロイされ、2番目のバージョンは0xb92336759618f55bd0f8313bd843604592e27bd8でデプロイされています。

まず、Replicaコントラクトの以前のバージョンのprocess()関数を確認します。

function process(bytes memory _message) public returns (bool _success) {
    bytes29 _m = _message.ref(0);
    // ensure message was meant for this domain
    require(_m.destination() == localDomain, "!destination");
    // ensure message has been proven
    bytes32 _messageHash = _m.keccak();
    require(messages[_messageHash] == MessageStatus.Proven, "!proven");
    // check re-entrancy guard
    require(entered == 1, "!reentrant");
    entered = 0;
    // update message status as processed
    messages[_messageHash] = MessageStatus.Processed;

この関数のほんの一部を示しています。このコードセグメントでは、メッセージハッシュが計算され、そのハッシュがmessagesマッピングに対してチェックされ、このメッセージが以前に証明されていることを確認します。その後、再入可能性チェックが行われ、メッセージステータスが更新されます。

古いprove()関数も簡単にレビューします。

function prove(
    bytes32 _leaf,
    bytes32[32] calldata _proof,
    uint256 _index
) public returns (bool) {
    // ensure that message has not been proven or processed
    require(messages[_leaf] == MessageStatus.None, "!MessageStatus.None");
    // calculate the expected root based on the proof
    bytes32 _calculatedRoot = MerkleLib.branchRoot(_leaf, _proof, _index);
    // if the root is valid, change status to Proven
    if (acceptableRoot(_calculatedRoot)) {
        messages[_leaf] = MessageStatus.Proven;
        return true;
    }
    return false;
}

ここでは特別なことはありません。重複チェック、ツリーのルートの計算、そしてそれが有効であれば「証明済み」としてマークします。したがって、Replicaコントラクトの古いバージョンでは、「証明済み」のすべてのメッセージに特別なマーク(MessageStatus.Proven = 1)がありました。

次に、論理コントラクトの2番目のバージョンを確認します。新しいバージョンでは、まずprove()関数を確認します。

function prove(
    bytes32 _leaf,
    bytes32[32] calldata _proof,
    uint256 _index
) public returns (bool) {
    // ensure that message has not been processed
    // Note that this allows re-proving under a new root.
    require(
        messages[_leaf] != LEGACY_STATUS_PROCESSED,
        "already processed"
    );
    // calculate the expected root based on the proof
    bytes32 _calculatedRoot = MerkleLib.branchRoot(_leaf, _proof, _index);
    // if the root is valid, change status to Proven
    if (acceptableRoot(_calculatedRoot)) {
        messages[_leaf] = _calculatedRoot;
        return true;
    }
    return false;
}

ここで、開発者が特別なマークではなく、計算されたルートを証明済みステータスとして記録することにした理由がすぐにわかりました。この関数にとっては問題ありません。Merkleツリーのルートハッシュはゼロでないことが保証されているからです。ツリーのルートが確認されるとすぐに、このツリーのルートで証明された新しいメッセージは実行可能になるため、これも合理的です。

次に、新しいバージョンでのprocess()関数を確認します。

function process(bytes memory _message) public returns (bool _success) {
    // ensure message was meant for this domain
    bytes29 _m = _message.ref(0);
    require(_m.destination() == localDomain, "!destination");
    // ensure message has been proven
    bytes32 _messageHash = _m.keccak();
    require(acceptableRoot(messages[_messageHash]), "!proven");
    // check re-entrancy guard
    require(entered == 1, "!reentrant");
    entered = 0;
    // update message status as processed
    messages[_messageHash] = LEGACY_STATUS_PROCESSED;

messages[_messageHash]の行に注目します。マッピングエントリが存在しない場合にゼロが返されることは、よくある落とし穴です。 この文脈では、このメッセージハッシュに関連付けられたMerkleツリーのルートがゼロであることを意味します。このゼロの結果をさらに確認する必要があります。そのため、新しいacceptableRoot()関数を注意深く確認する必要があります。

function acceptableRoot(bytes32 _root) public view returns (bool) {
    // this is backwards-compatibility for messages proven/processed
    // under previous versions
    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, // this is zero at initialization
    uint256 _optimisticSeconds
) public initializer {
    __NomadBase_initialize(_updater);
    // set storage variables
    entered = 1;
    remoteDomain = _remoteDomain;
    committedRoot = _committedRoot;
    // pre-approve the committed root.
    confirmAt[_committedRoot] = 1;
    _setOptimisticTimeout(_optimisticSeconds);
}

Replicaコントラクトの古いバージョンでは、これは全く問題ありません。prove()ではツリーのルートハッシュがゼロになることはないため、confirmAtマッピングのゼロハッシュエントリを1に設定しても安全です。

しかし、新しいバージョンでは、新しいメッセージに対してmessages[_messageHash]はゼロを返します。すると、acceptableRootconfirmAtマッピングのゼロハッシュエントリにアクセスし、trueを返します。

攻撃

上記のコード分析から、以前は見られなかったメッセージであれば、検証ロジックを通過して実行されることがわかります。したがって、メッセージを偽造してprocess()を呼び出すだけです。

興味深いことに、このコントラクトでのprocess()関数の最初の呼び出しは、わずか2日前のブロック15249565(0xa654fd4152f4734fcd774dd64b618b22a1561e2528b7b8e4500d20edb05b3ba0)で発生しました。

次の図では、このメッセージのmessages状態変数のストレージスロットが、当初はゼロだったことがわかります。これは、Replicaコントラクトがこのメッセージを以前は認識していなかったことを意味します。

その後、このスロットは2(つまり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