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

Best Security Auditor for Web3

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

BlockSec Audit