JOVANA
Explore Library Glossary Getting Started Three Levels Fields How it works Mission
Join the mission
All guides

重入:掏空 The DAO 的那個漏洞

2016 年 6 月,一個順序上的錯誤,讓攻擊者把同一筆 ETH 一提再提,從 The DAO 抽走約 360 萬枚 ETH。本篇逐步解剖重入攻擊的運作原理、依攻擊意圖一步步走過整起事件,並示範每份合約都該採用的兩道修正。

那通價值六千萬美元的回呼

2016 年 4 月 30 日,一個名為 The DAO 的專案啟動了募資,短短幾週內吸進 1,270 萬枚 ETH——以當時價格約合 1.5 億美元——成為史上最大的群眾募資事件。它是一份 智慧合約,讓代幣持有者把資金匯集起來、投票決定要資助哪些專案:握著鑰匙的是程式碼,而不是某家公司。接著在 2016 年 6 月 17 日,餘額開始下滑。有人正一塊一塊地把它抽乾,而沒人能阻止這份合約——因為「沒人能阻止這份合約」本來就是它的全部賣點。

等到攻擊停下,大約 360 萬枚 ETH(當時約 6,000 萬美元) 已被轉進攻擊者控制的「子 DAO」。竊賊根本沒有攻破任何密碼學,他只是找到了放錯位置的一行程式。餘波撕裂了 以太坊 本身:為了把資金奪回,社群在 2016 年 7 月 20 日於區塊 1,920,000 執行了一次 硬分叉,造出我們今天稱為以太坊(ETH)的這條鏈。少數拒絕分叉、堅持「程式跑出來的結果才是唯一真理」的人,則保留了未分叉的原鏈,也就是 以太坊經典(Ethereum Classic, ETC)。一個漏洞改寫了整張加密版圖。那個漏洞,叫做 重入(reentrancy)

致命模式:先付款,後記帳

以下把漏洞濃縮成一個迷你銀行,正是病灶所在。仔細閱讀 `withdraw`,盯緊各步驟的「順序」。

// VULNERABLE — do NOT deploy
pragma solidity ^0.8.20;

contract Bank {
    mapping(address => uint256) public balances;

    function deposit() external payable {
        balances[msg.sender] += msg.value;
    }

    function withdraw() external {
        uint256 amount = balances[msg.sender];
        require(amount > 0, "nothing to withdraw");

        // INTERACTION happens BEFORE the EFFECT:
        (bool ok, ) = msg.sender.call{value: amount}("");  // hands control to msg.sender
        require(ok, "send failed");

        balances[msg.sender] = 0;                          // bookkeeping updated too late
    }
}
經典的脆弱提款函式:先把 ETH 送出去,才把餘額歸零。

陷阱就在 `msg.sender.call{value: amount}("")` 這一行。在 EVM 中,用 `call` 把 ETH 送到某個位址,並不是被動的銀行轉帳——它是一次會「執行程式碼」的 訊息呼叫。如果 `msg.sender` 是一份合約,EVM 就會去呼叫它的 receive/fallback 函式,在我們的 `withdraw` 仍凍結在執行途中、`balances[msg.sender]` 還顯示著完整金額時,把程式計數器交到攻擊者手上。我們付了錢,卻還沒記帳。合約的不變量——「`balances` 的總和等於我持有的 ETH」——此刻暫時為假,而攻擊者正好能在這道縫隙裡動手。

一步步走過攻擊

攻擊者只需要一份 `receive` 函式會再次呼叫 `withdraw` 的合約。假設這家銀行已經持有來自誠實使用者的 10 ETH

contract Drainer {
    Bank public bank;
    constructor(address bankAddr) { bank = Bank(bankAddr); }

    function pwn() external payable {
        bank.deposit{value: 1 ether}();   // become a depositor of record
        bank.withdraw();                  // first, innocent-looking withdrawal
    }

    // the EVM calls this every time the Bank sends us ETH
    receive() external payable {
        if (address(bank).balance >= 1 ether) {
            bank.withdraw();              // re-enter BEFORE our balance is zeroed
        }
    }
}
攻擊者的合約:它的 receive() 不是收工,而是反過來再次進入 withdraw()。
  1. 存入 1 ETH。 此時 `balances[Drainer] = 1`,銀行持有 11 ETH(誠實的 10 + 攻擊者的 1)。
  2. 呼叫 withdraw。 銀行讀到 `amount = 1`,通過 `require`,接著對 Drainer 執行 `call{value: 1 ether}`——而這發生在餘額被歸零「之前」。
  3. Drainer 的 receive() 觸發。 它看到銀行還有 ≥ 1 ETH,於是「再次」呼叫 `withdraw()`。銀行讀取 `balances[Drainer]`——仍是 `1`、從沒被歸零——通過 `require`,又送出「一筆」1 ETH。
  4. 遞迴。 每一筆新的轉帳都再次觸發 receive(),receive() 又再次進入 withdraw()。一疊凍結中的 `withdraw` 框架不斷堆高,每一層都還等著把餘額設為 0。
  5. 抽乾。 迴圈一直重複,直到銀行餘額跌破 1 ETH(或呼叫堆疊/gas 耗盡)。Drainer 只存了 1 ETH,卻帶走全部 11 ETH——淨偷 10 ETH。直到此刻,凍結的框架才一一回退,終於跑到 `balances[Drainer] = 0`,但為時已晚。

The DAO 真正的漏洞藏在 `splitDAO` 裡:它在把呼叫者的代幣餘額歸零「之前」,就透過一次外部呼叫發放獎勵——結構上與我們的玩具範例一模一樣。攻擊者只是順著那道縫隙不斷遞迴。教訓殘酷無比:一條順序放錯的敘述,就把一份「無法被阻止」的合約,變成一場「無法被阻止」的盜竊。(後來每一份 重入攻擊的事後檢討,都在用不同的受害者重述同一個故事。)

修正一:檢查—生效—互動

最廉價、也最根本的修正,除了三行的順序之外什麼都沒改。把每個會改變狀態的函式都組織成 檢查(Checks)→ 生效(Effects)→ 互動(Interactions):先驗證輸入(檢查),再更新自己的儲存(生效),最後才去碰外部世界(互動)。只要你在外部呼叫「之前」就把餘額歸零,重入對攻擊者就毫無好處——第二次 `withdraw` 讀到的是 `0`,直接 revert。

function withdraw() external {
    uint256 amount = balances[msg.sender];   // CHECK
    require(amount > 0, "nothing to withdraw");

    balances[msg.sender] = 0;                // EFFECT — update state FIRST

    (bool ok, ) = msg.sender.call{value: amount}("");  // INTERACTION — last
    require(ok, "send failed");
}
同一個函式,只是調換順序。如今重入時讀到的是零餘額,直接 revert。

現在重跑一次攻擊:Drainer 的 `receive` 重入,但 `balances[Drainer]` 已經是 `0`,於是 `require(amount > 0)` 觸發一次 EVM revert,內層呼叫失敗。關鍵是,「外層」的提款依然成功——攻擊者只拿回他誠實的那 1 ETH,一分不多。橫跨外部呼叫的整段期間,不變量從未為假,因為帳在開門之前就已經結清。

修正二:重入防護(互斥鎖)

重入防護是一個單位元的鎖:只要某個函式(或一整組函式)已經在呼叫堆疊的某處執行中,它就拒絕再次執行。它是一個 Solidity 修飾器,進入時設旗標、離開時清旗標,期間任何重入呼叫一律 revert。

contract Bank {
    mapping(address => uint256) public balances;

    uint256 private _status = 1;          // 1 = unlocked, 2 = locked

    modifier nonReentrant() {
        require(_status == 1, "reentrant call");
        _status = 2;                      // close the door
        _;                                // run the function body
        _status = 1;                      // open it again on exit
    }

    function withdraw() external nonReentrant {
        uint256 amount = balances[msg.sender];
        require(amount > 0, "nothing to withdraw");
        balances[msg.sender] = 0;                       // still keep CEI!
        (bool ok, ) = msg.sender.call{value: amount}("");
        require(ok, "send failed");
    }
}
一個 nonReentrant 互斥鎖。重入的 withdraw() 如今會卡在 require(_status == 1) 而 revert。

現在 Drainer 的重入會在 `_status` 仍為 `2` 時撞上 `require(_status == 1)` 而 revert。把「同一個」`nonReentrant` 旗標套到每一個會碰到共用餘額、且可被外部呼叫的函式上,跨函式重入也跟著陣亡:這把鎖對整份合約是全域的,而非各函式各自為政。

第三道結構性防線是 提領式付款(pull-payment) 模式:與其在你的邏輯裡把 ETH「推」給使用者,不如記下每個人應得多少,讓他們在另一筆獨立、隔離的交易中自行 `claim()`。如此一來,提款函式把唯一的外部呼叫放在最後一步,後面已無任何可被破壞之物——而一個會 revert 的收款人,頂多只能 卡死自己的提款,無法癱瘓整份合約。

重入在當代的真實樣貌

The DAO 是 2016 年的事,但重入從未消失——它只是變種了。三種當代形式,連經驗老到的團隊都會中招:

  1. 代幣回呼重入。 ERC-777 與 ERC-721 會在轉帳過程中對「收款方」呼叫回呼(`tokensReceived`、`onERC721Received`)——這是一個你可能料想不到的內建外部呼叫。Lendf.me/imBTC 事件(2020 年 4 月,約 2,500 萬美元)Cream Finance(2021 年 8 月,約 1,880 萬美元) 都濫用 ERC-777 回呼來重入借貸邏輯。
  2. 跨合約重入。 重入是透過「另一份」與你共用狀態的合約繞回來(例如一個借貸市場與它的抵押模組)。Fei/Rari Capital 的 Fuse 資金池(2022 年 4 月,約 8,000 萬美元) 就栽在借款流程的跨合約重入上——單一合約內部做到 CEI 並不足夠。
  3. 唯讀重入。 在回呼期間,受害者的狀態暫時不一致;攻擊者去呼叫一個「`view`」函式(例如某資金池的 `get_virtual_price`),讀到那段壞掉的狀態,再把錯誤的數字餵給「第三個」把它當預言機使用的協定。view 並沒有寫入任何狀態——錢卻在別處被搬動了。

如何揪出並封住它

一旦你認得它的形狀,重入其實是最「找得到」的漏洞之一。稽核員的反射動作,就是把每一個外部呼叫都搜出來,並在每一處問同一個問題:在這一行,哪些狀態是過期的?惡意的被呼叫方能拿它做什麼? 把這當成你的檢查清單:

  1. 把每個函式都排成「檢查 → 生效 → 互動」,外部呼叫之後不再有任何狀態寫入。把這視為不可妥協的鐵律。
  2. 為每一個會搬動價值、或碰到共用狀態、且可被外部呼叫的函式加上 `nonReentrant` 防護——並把「同一把」鎖套用到所有共用該狀態的函式上。
  3. 盯緊那些隱藏的外部呼叫:ERC-777/ERC-721 的收款回呼、任意代幣轉帳,以及任何使用者提供的回呼或位址。假設任何代幣都可能回呼。
  4. 跑靜態分析(Slither 的 `reentrancy-eth`/`reentrancy-no-eth` 偵測器),並用 Foundry 的攻擊者合約撰寫性質測試;以 不變量測試模糊測試 斷言「餘額總和 == 持有的 ETH」永遠不會被破壞。

重入正是那個典範案例,說明了為什麼「程式完全照著寫的跑」一點也不能讓人安心——因為那段程式「本身」就是漏洞。本階接下來的篇章會處理其他經典類型(算術、存取控制、預言機/閃電貸),並在最後示範一場專業的 稽核如何把這一切織成縱深防禦。先把重入練到精:只要你能看見藏在一個無辜 `call` 裡的脈絡切換,你就已經開始像攻擊者一樣思考了。