Back to Blog

攻撃分析|Nomadブリッジで2億ドルの損失を招いたマッピングの不備とは

Code Auditing
August 2, 2022
7 min read

概要

2022年8月2日、Nomad Bridgeと呼ばれるクロスチェーンブリッジが攻撃を受け、2億ドル近くが失われました。根本的な原因は、オンチェーンスマートコントラクトのアップグレード版における不適切なチェック処理にあります。

背景

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

  1. Nomadは、サポートされている各ブロックチェーン上に「Replica」と呼ばれるコアコントラクトをデプロイし、クロスチェーンメッセージのメールボックスとして機能させます。
  2. オフチェーンのエージェントがクロスチェーンメッセージを中継し、マークルツリーに配置します。その後、署名付きの新しいツリールートハッシュをコントラクトに送信することで、ツリールートを更新します。
  3. オンチェーンで確認が必要な新しいメッセージは、prove()process()の両方の手順を経る必要があります。
    1. prove()手順は、マークルツリー内のメッセージとその証明を検証し、そのメッセージを「検証済み(proven)」としてマークします。
    2. process()手順は、メッセージが事前に検証されており、かつ関連するツリールートが承認されている場合に、そのチェックを実行します。

コード

イーサリアムにおいて、Replicaは0x5d94309e5a0090b165fa4181519701637b6daebaにデプロイされたBeaconプロキシです。ロジックコントラクトには2つのバージョンがあり、第1版は0x7f58bb8311db968ab110889f2dfa04ab7e8e831bに、第2版は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;
    // メッセージステータスを処理済み(Processed)に更新
    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)が存在します。

次に、ロジックコントラクトの第2版を確認します。新バージョンについては、まず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]がゼロを返します。その後、acceptableRootconfirmAtマッピング内のゼロハッシュのエントリにアクセスし、trueを返してしまいます。

攻撃

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

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

以下の図から、このメッセージに対するmessagesステート変数のストレージスロットがもともとゼロであったことがわかります。これは、Replicaコントラクトがこのメッセージを以前に認識していなかったことを意味します。

その後、このスロットは2(つまり、LEGACY_STATUS_PROCESSEDステータス、このメッセージが処理済みであることを意味する)に設定されました。これは、無効なメッセージがprove()ロジックをバイパスし、直接処理されたことを示しています。

結論

これは、マッピングから取得した戻り値がチェックされていないことを悪用した、また一つ古典的な攻撃手法です。Solidityの開発者は、マッピングを扱う際には予期せぬ結果を避けるために細心の注意を払う必要があります。

Sign up for the latest updates
約160万ドルの損失:MokeトークンとLpdFiへの攻撃 | BlockSec週次レポート
Security Insights

約160万ドルの損失:MokeトークンとLpdFiへの攻撃 | BlockSec週次レポート

2026年8月3〜9日の週、BNBチェーンで2件の重大なセキュリティインシデントが発生し、いずれも価格操作により総損失は約160万ドルに上った。LpdFi事件(約69.7万ドル)では、注文評価と利息償還の両方に同じ操作可能なPancakeSwapペアのリザーブが使用され、攻撃者がポジションの元本を水増しし過大な利息を請求した。Mokeトークン(約90.6万ドル)では、操作可能なスポット価格とLP配当の重複計上を組み合わせ、MOKEを水増し請求してBNB配当を複数回取得した。

COLDCARDの事件:ウォレットの「ランダム」シードがランダムではなかった時
Security Insights

COLDCARDの事件:ウォレットの「ランダム」シードがランダムではなかった時

COLDCARDファームウェアのビルド・統合バグにより、Bitcoin種生成がソフトウェアRNGフォールバックに誘導され、脆弱な乱数によりウォレットの秘密鍵がオフラインで復元可能となった。欠陥は種自体にあるためファームウェア更新では修正不可。2026年8月7日までに確認された被害は1,405 BTC(約91億円)、非公式推定では最大2,055 BTCに上る。

〜8800万ドルの損失:COLDCARDとLULAのエクスプロイト|BlockSec週次レポート
Security Insights

〜8800万ドルの損失:COLDCARDとLULAのエクスプロイト|BlockSec週次レポート

2026年7月27日〜8月2日、BitcoinとBNBチェーンで計約8800万ドルの損失をもたらす2件のインシデントが発生。COLDCARDではファームウェアのエントロピー障害により、RNG設定マクロの有効性ではなく存在のみを確認するビルドガードが決定論的なソフトウェアフォールバックへシード生成を誘導。攻撃者は少なくとも1,370 BTC(約8800万ドル)を窃取。BNBチェーンのLULAトークンでは、攻撃者が特権的な`recycle()`関数を呼び出し、PancakeSwap V2のペアからLULAを引き出して流動性を枯渇させ、約57.8万ドルの損失が発生。

Best Security Auditor for Web3

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

BlockSec Audit