Back to Blog

攻撃分析 | 未チェックのマッピングがNomad Bridgeで2億ドルの損失を招く理由

Code Auditing
August 2, 2022
7 min read

要約

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

背景

Nomad Bridgeは、フラウドプルーフベースの設計を使用した、新興のクロスチェーン資産ブリッジです。その仕組みは以下の通りです。

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

コード

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マッピングに対してチェックされ、このメッセージが以前にprovenであったことが確認されます。次に、再入可能性チェックが行われ、メッセージステータスが更新されます。

古い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;
}

ここでは特別なことはありません。重複チェック、ツリールートの計算、そしてそれが受け入れ可能であればprovenとしてマークします。したがって、Replicaコントラクトの古いバージョンでは、provenされたすべてのメッセージに特別なマーク(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;
}

ここで大きな変更にすぐに気づきました。開発者は、特別なマークの代わりに、計算されたルートをprovenステータスとして記録することを決定したようです。この関数については、マークルツリーのルートハッシュはゼロでないことが保証されているため問題ありません。ツリールートが確認され次第、このツリールートでprovenされた新しいメッセージは実行可能になるため、これも合理的です。

次に、新しいバージョンの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]の行に注目します。存在しないマッピングエントリを取得するとゼロが返されるのは一般的な落とし穴です。 この文脈では、このメッセージハッシュに関連するマークルツリーのルートがゼロであることを意味します。このゼロの結果をさらにチェックする必要があります。したがって、新しい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マッピングをチェックして、マークルツリーのルートが確認されているかどうかを確認します。

残念ながら、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
~$4.72M Lost: TAC, Transit Finance & More | BlockSec Weekly
Security Insights

~$4.72M Lost: TAC, Transit Finance & More | BlockSec Weekly

This BlockSec weekly security report covers 3 notable attack incidents identified between May 11 and May 17, 2026, across TRON, TON, and Ethereum, with total estimated losses of approximately $4.72M. Three incidents are analyzed in detail: the highlighted $1.88M Transit Finance exploit on TRON, where a deprecated swap bridge contract with lingering token approvals was exploited through arbitrary calldata forwarding; the $2.8M TAC TON-to-EVM bridge exploit caused by missing canonical wallet verification in the jetton deposit flow; and the $46.75K Boost Hook exploit on Ethereum, where spot price manipulation on a Uniswap V4 hook-based perpetual protocol forced the protocol to buy tokens at inflated prices using its own reserves.

~$15.9M Lost: Trusted Volumes, Wasabi & More | BlockSec Weekly
Security Insights

~$15.9M Lost: Trusted Volumes, Wasabi & More | BlockSec Weekly

This BlockSec bi-weekly security report covers 11 notable attack incidents identified between April 27 and May 10, 2026, across Sui, Ethereum, BNB Chain, Base, Blast, and Berachain, with total estimated losses of approximately $15.9M. Three incidents are analyzed in detail: the highlighted $1.14M Aftermath Finance exploit on Sui, where a signed/unsigned semantic mismatch in the builder-fee validation allowed an attacker to inject a negative fee that was converted into positive collateral during settlement; the $5.87M Trusted Volumes RFQ authorization mismatch on Ethereum; and the $5.7M Wasabi Protocol infrastructure-to-contract-control compromise across multiple EVM chains.

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

Best Security Auditor for Web3

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

BlockSec Audit