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

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



