返回部落格

攻擊分析 | 未經檢查的映射如何導致 Nomad 跨鏈橋損失 2 億美元

Code Auditing
2022年8月2日
閱讀約 8 分鐘

摘要

2022 年 8 月 2 日,名為 Nomad Bridge 的跨鏈橋遭到攻擊,導致近 2 億美元的損失。根本原因是鏈上智能合約升級版本中的檢查機制錯誤。

背景

Nomad Bridge 是一個新興的跨鏈資產橋,採用了基於詐欺證明(fraud-proof)的設計。其運作方式如下:

  1. Nomad 在每個支援的區塊鏈上部署一個名為 Replica 的核心合約,作為所有跨鏈訊息的信箱。
  2. 鏈下代理(Off-chain agents)將跨鏈訊息中繼並排列在 Merkle 樹中,並透過向該合約發布已簽署的新樹根雜湊值來更新樹根。
  3. 需要在鏈上確認的新訊息必須同時通過 prove() 和 process() 程序。
    1. prove() 程序驗證 Merkle 樹中的訊息與證明,然後將訊息標記為已驗證(proven)。
    2. 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 開發者在處理映射時應特別留意,以避免意外結果。

訂閱最新動態
Bitget 3.875億美元鏈下違規事件:超越金鑰與合約的範疇
Security Insights

Bitget 3.875億美元鏈下違規事件:超越金鑰與合約的範疇

2026年9月24日,攻擊者利用第三方安全產品漏洞,獲取內部憑證並偽造提款指令,導致約3.875億美元從Bitget部分營運錢包(涵蓋Ethereum、其他EVM網絡、XRP Ledger、Zcash及TRON)轉出;私鑰及冷錢包未受影響。本文深入梳理事件路徑與資金流向,分析快速轉換為原生資產及生態恢復應對,提出機構縱深防禦框架,並說明授權區塊鏈滲透測試如何驗證跨層假設。

約1130萬美元損失:Multicall Router、Nostra|BlockSec Weekly
Security Insights

約1130萬美元損失:Multicall Router、Nostra|BlockSec Weekly

本報告涵蓋2026/09/14至2026/09/20期間,探討以太坊與Starknet上兩起合計損失約1130萬美元的安全事件。較大一起中,多重呼叫路由器將自身位址接受為分派目標,導致巢狀呼叫以路由器自身已授權身分而非外部呼叫者身分進入Safe錢包的Gateway模組,約2900枚`aEthrsETH`被轉出至攻擊者建立的Uniswap v4池。在Starknet上,Nostra預言機整合僅要求最少一個聚合來源,當三個設定價格來源中僅兩個達成聚合時,操縱的薄池報價與正常報價平均後將`NSTR`估值約49.52美元,支撐約350萬美元以高估抵押品借貸。

损失約3.2億美元:Liquid Network與Symbiosis遭攻擊事件 | BlockSec
Security Insights

损失約3.2億美元:Liquid Network與Symbiosis遭攻擊事件 | BlockSec

本報告涵蓋2026/09/07至2026/09/13,探討兩起造成約3.2億美元損失的安全事件,包括前份報告未提及、發生於2026/09/06的Liquid Network漏洞事件。較大者為該Liquid Network漏洞:Elements中的rangeproof驗證快取藉由雜湊四個欄位(其中兩個長度可變)串接而成金鑰,且未標示欄位邊界,導致某輸出的驗證結果被錯誤套用至另一個未經檢驗證明的輸出,使攻擊者得以憑空創造4,000枚無擔保L-BTC,並將幾乎全數兌回比特幣。在橫跨BNB Smart Chain、Ethereum與Rootstock的Symbiosis跨鏈橋比特幣路徑中,讀取比特幣存款的鏈下程式碼從存款人可自行控制的欄位取得其身份,並在未檢查手續費是否為負值的情況下自存款中扣除,使330聰的存款鑄造出`46,116,860,184.27388234 syBTC`;其須賣出所經之資金池僅有11.26 syBTC,致使流動性提供者與用戶損失約9.97 BTC(約77萬美元)。

Web3 最佳安全審計方

在上線之前驗證設計、程式碼與業務邏輯,對標業內最高安全標準。

BlockSec 審計