要約
2022年8月2日、Nomad Bridgeというクロスチェーンブリッジが攻撃を受け、約2億ドルの損失が発生しました。根本原因は、オンチェーンスマートコントラクトのアップグレード版におけるチェックの誤りです。
背景
Nomad Bridgeは、不正証明(fraud-proof)ベースの設計を採用した、新興のクロスチェーン資産ブリッジです。その仕組みは以下の通りです。
- Nomadは、サポートされている各ブロックチェーン上にReplicaというコアコントラクトをデプロイし、クロスチェーンメッセージのメールボックスとして機能させます。
- オフチェーンエージェントが、クロスチェーンメッセージを中継・整理してMerkleツリーを構築し、署名された新しいツリーのルートハッシュをこのコントラクトに投稿してツリーのルートを更新します。
- オンチェーンで確認する必要がある新しいメッセージは、
prove()とprocess()の両方の手順を経る必要があります。prove()手順は、Merkleツリー内のメッセージと証明を検証し、メッセージを「証明済み」としてマークします。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]はゼロを返します。すると、acceptableRootはconfirmAtマッピングのゼロハッシュエントリにアクセスし、trueを返します。
攻撃
上記のコード分析から、以前は見られなかったメッセージであれば、検証ロジックを通過して実行されることがわかります。したがって、メッセージを偽造してprocess()を呼び出すだけです。
興味深いことに、このコントラクトでのprocess()関数の最初の呼び出しは、わずか2日前のブロック15249565(0xa654fd4152f4734fcd774dd64b618b22a1561e2528b7b8e4500d20edb05b3ba0)で発生しました。
次の図では、このメッセージのmessages状態変数のストレージスロットが、当初はゼロだったことがわかります。これは、Replicaコントラクトがこのメッセージを以前は認識していなかったことを意味します。

その後、このスロットは2(つまりLEGACY_STATUS_PROCESSEDステータス、このメッセージが処理されたことを意味します)に設定されました。これは、無効なメッセージがprove()ロジックをバイパスして直接処理されたことを示しています。
結論
これは、マッピングから取得した未チェックの戻り値を悪用した、もう一つの古典的な攻撃です。Solidity開発者は、予期しない結果を避けるために、マッピングを扱う際には特に注意を払う必要があります。



