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

Nomad Bridgeのセキュリティが侵害された経緯:Nomad Bridgeの脆弱性を引き起こしたコードの分析と、約2億ドルの不正流出について

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

要約

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