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
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