Back to Blog

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

Code Auditing
August 2, 2022
8 min read

摘要

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 開發者在處理映射時應特別留意,以避免意外結果。

Sign up for the latest updates
電子報 - 2026年6月
Security Insights

電子報 - 2026年6月

本月報告涵蓋2026年6月三起最大安全事件,確認損失合計約2200萬美元。一次精密蜜罐攻擊利用未受檢查的代幣授權,從JaredFromSubway的MEV機器人抽走約1500萬美元。兩個舊版Aztec rollup部署因證明結算邊界漏洞損失約435萬美元。SecondFi的Ed25519實現缺陷暴露錢包私鑰,導致374個錢包共損失約240萬美元。三起事件共同模式:表面完整但從未真正執行的安全保證。

~$400萬損失:Taiko、SecondFi遭攻擊 | BlockSec週報
Security Insights

~$400萬損失:Taiko、SecondFi遭攻擊 | BlockSec週報

本週區塊鏈安全報告涵蓋2026年6月22-28日兩起重大事件,以太坊和Cardano確認損失約410萬美元。Taiko橋接漏洞結合了外洩的SGX飛地簽名密鑰與不完整的認證策略,未能拒絕調試飛地,使攻擊者註冊惡意證明者並偽造L2狀態證明。SecondFi錢包漏洞源於Ed25519隨機數派生中的密碼學實現缺陷,移除了秘密輸入,可從Cardano公開交易數據離線恢復私鑰。

~$18M損失:jaredFromSubway、Aztec及更多 | BlockSec每週快報
Security Insights

~$18M損失:jaredFromSubway、Aztec及更多 | BlockSec每週快報

本週區塊鏈安全報告涵蓋2026年6月15日至21日,以太坊與BNB Chain共發生3起重大事件,總損失約1830萬美元。重點分析兩起事件:jaredFromSubway事件揭示反向授權攻擊模式——MEV機器人主動將資產授權給不可信第三方合約套利,攻擊者構造假包裝代幣與交易池,發出真實事件但從未消耗授權額度,損失約1500萬美元。Aztec三日內第二次漏洞:逃生艙ZK電路中`old_data_root`兩個見證值缺少相等約束,致使攻擊者可用偽造Merkle樹證明虛假票據所有權並通過鏈上驗證。

Best Security Auditor for Web3

Validate design, code, and business logic before launch. Aligned with the highest industry security standards.

BlockSec Audit