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

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



