摘要
2022 年 8 月 2 日,名為 Nomad Bridge 的跨鏈橋遭到攻擊,導致近 2 億美元的損失。根本原因是鏈上智能合約升級版本中的檢查機制錯誤。
背景
Nomad Bridge 是一個新興的跨鏈資產橋,採用了基於詐欺證明(fraud-proof)的設計。其運作方式如下:
- Nomad 在每個支援的區塊鏈上部署一個名為 Replica 的核心合約,作為所有跨鏈訊息的信箱。
- 鏈下代理(Off-chain agents)將跨鏈訊息中繼並排列在 Merkle 樹中,並透過向該合約發布已簽署的新樹根雜湊值來更新樹根。
- 需要在鏈上確認的新訊息必須同時通過
prove()和process()程序。prove()程序驗證 Merkle 樹中的訊息與證明,然後將訊息標記為已驗證(proven)。process()程序在訊息先前已驗證並且關聯的樹根已確認的情況下,檢查並執行該訊息。
代碼
在以太坊上,Replica 是一個部署於 0x5d94309e5a0090b165fa4181519701637b6daeba 的 Beacon 代理合約。該邏輯合約有兩個版本,第一個版本部署於 0x7f58bb8311db968ab110889f2dfa04ab7e8e831b,第二個版本部署於 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;
// 將訊息狀態更新為已處理
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)。
接下來檢查第二個版本的邏輯合約。對於新版本,我們首先檢查 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;
}
我們立刻注意到這裡有一個重大改變:出於某些原因,開發者決定將計算出的樹根記錄為驗證狀態,而不是使用特殊標記。對於此函數而言這沒問題,因為 Merkle 樹根雜湊值被確保為非零。這也是合理的,因為一旦樹根確認,任何使用該樹根驗證的新訊息都可以準備執行。
接著我們檢查新版本中的 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] 這行。一個常見的陷阱是:從映射中檢索不存在的條目會返回零。 在此情境下,這意味著與該訊息雜湊相關的 Merkle 樹根為零。我們需要進一步檢查該零值的結果,因此必須仔細檢查新的 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 映射,以確認 Merkle 樹根是否已確認。
不幸的是,在兩個版本的 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() 函數是在兩天前(區塊 15249565),詳見 0xa654fd4152f4734fcd774dd64b618b22a1561e2528b7b8e4500d20edb05b3ba0。
在下圖中,我們可以看到該訊息的 messages 狀態變數儲存槽最初為零,意味著 Replica 合約先前並不知道此訊息。

隨後該槽位被設為 2(即 LEGACY_STATUS_PROCESSED 狀態,意指該訊息已處理)。這表明一個無效的訊息繞過了 prove() 邏輯並直接被處理了。
結論
這是另一個利用映射返回未檢查值所導致的經典攻擊案例。Solidity 開發者在處理映射時應特別留意,以避免意外結果。



