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

Best Security Auditor for Web3

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

BlockSec Audit