昨天,我們成功展示了如何利用 VulnerableVault 合約中狀態更新順序錯誤(Interactions 在 Effects 之前)導致的重入漏洞,耗盡合約資金。
今天的實作目標是:在不改變合約功能的基礎上,修復這個漏洞,並重複昨天的攻擊流程,證明攻擊已無法成功。
🔹 三句話速記
核心修復原則: 必須遵循 Checks → Effects → Interactions (CEI) 模式。
修復方法: 將 withdraw 函式中餘額扣除的程式碼行(Effect)移到外部轉帳(Interaction)之前。
證明目標: 攻擊交易必須 Success,但 Vault 餘額不能被耗盡。
🔹 修復後的安全合約(選擇 CEI 模式)
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.20;
contract SafeVault { // 更名為 SafeVault 以反映修復
mapping(address => uint256) public balances;
event Deposited(address indexed who, uint256 amount);
event Withdrawn(address indexed who, uint256 amount);
// 接受初始資金 (保持不變)
constructor() payable {}
// 存款功能 (保持不變)
function deposit() external payable {
require(msg.value > 0, "send ETH");
balances[msg.sender] += msg.value;
emit Deposited(msg.sender, msg.value);
}
// 【修復後的提款函式】:Checks -> Effects -> Interactions (CEI)
function withdraw(uint256 amount) external {
// 1. 檢查 (Checks): 驗證餘額是否充足
require(balances[msg.sender] >= amount, "insufficient");
// 【關鍵修正 (Effects)】: 狀態更新必須在外部呼叫之前完成!
balances[msg.sender] -= amount;
// 3. 互動 (Interactions): 執行外部呼叫
(bool ok, ) = msg.sender.call{value: amount}("");
// 檢查呼叫結果 (即使失敗,資金已經扣除,防止重入)
require(ok, "send failed");
emit Withdrawn(msg.sender, amount);
}
// helper to check contract balance
function contractBalance() external view returns (uint256) {
return address(this).balance;
}
}
🔧 Remix(JS VM)逐步操作(證明攻擊失敗)
前提: 假設您已成功部署了原始的 Attacker 合約 (即 Day 28 的 Attacker.sol)。
新增並部署 SafeVault (修復版):
將上方修復後的程式碼貼到一個新的 Solidity 檔案中,並重新編譯。
在 Deploy 面板,選取 SafeVault,並部署 (可發送 10 ETH 作為誘餌)。
複製新的 SafeVault 合約地址。
更新 Attacker 的目標:
在 Remix Deploy 面板,找到已部署的 Attacker 合約。
呼叫 Attacker 的 vault 函式(public 變數),確認目前指向的仍是舊的 Vault 地址。
(註:如果 Attacker 合約沒有提供設定 Vault 地址的函式,您可能需要重新部署 Attacker 合約,並將新的 SafeVault 地址傳入 constructor。)
準備攻擊:
將 SafeVault 地址傳給 Attacker 合約。
切回 Attacker 的部署者帳號(通常為第一帳號)。
執行攻擊 (Attack → withdraw → 靜默停止):
在 Attacker 欄位的 attack 函式,輸入 1 ether 為 value 和 amount 參數。
點擊 attack。
觀察結果(修復成功)
觀察項目 / 攻擊前的 SafeVault 餘額 / 攻擊後的 SafeVault 餘額 / 結論
contractBalance()/ ≈11 ETH (假設初始 10 ETH+1 ETH 存款) /10 ETH /攻擊失敗: Vault 餘額只減少了 1 ETH (第一次提款)。
在 Remix Console 中,你會看到只有一次 Withdrawn 事件 Log。
原因解釋: 由於 balances[msg.sender] -= amount; 已經在外部呼叫之前完成。當惡意的 receive() 函式嘗試重入時,SafeVault.withdraw 函式的第一行檢查 require(balances[msg.sender] >= amount) 會發現餘額已經被扣成 0 ETH,從而阻止了任何進一步的提款。
在 Remix Console 中,你會看到只有一次 Withdrawn 事件 Log。
原因解釋: 由於 balances[msg.sender] -= amount; 已經在外部呼叫之前完成。當惡意的 receive() 函式嘗試重入時,SafeVault.withdraw 函式的第一行檢查 require(balances[msg.sender] >= amount) 會發現餘額已經被扣成 0 ETH,從而阻止了任何進一步的提款。
這個實驗是理解重入攻擊最直觀的方法:你會看到錯誤只是「一行狀態更新順序」就能導致巨額損失。實務上,Checks → Effects → Interactions 與使用成熟庫(OpenZeppelin 的 ReentrancyGuard)是第一道且最有效的防線。下次部署任何處理資金的合約前,務必在本地用 JS VM 做攻防測試。