Skip to content
On this page

L1-11: Reentrancy(重入攻击)

1. 问题

创建一个存在重入漏洞的合约,然后构建攻击者合约演示如何利用该漏洞,最后使用 CEI(Checks-Effects-Interactions)模式和 OpenZeppelin ReentrancyGuard 修复漏洞。

核心问题是:当一个合约在更新自身状态之前向外部地址发送 ETH 时,接收方的 receive()fallback() 函数可以重新调用原合约的提款函数,由于状态尚未更新(余额未扣除),攻击者可以反复提取资金直到合约余额耗尽。

2. 原因

重入攻击是智能合约历史上最具破坏性的漏洞类别。2016 年 The DAO 黑客事件导致 360 万 ETH(当时价值约 6000 万美元)被盗,直接引发了以太坊的硬分叉。至今(2026 年),重入漏洞仍在 DeFi 协议中频繁出现——2023 年 Curve/ Vyper 漏洞、2024 年多个借贷协议的重入事件都证明了这一点。

理解重入攻击的机制是每个 Solidity 开发者的必修课。三个层面:第一,为什么状态更新必须在外部调用之前(CEI 原则);第二,识别所有可能触发外部调用的操作(ETH 转账、ERC20 transfersafeTransfer、跨合约调用);第三,理解重入未必来自恶意合约——任何接收 ETH 的合约都可能有意或无意地触发回调。

3. 方案

架构设计

项目包含三个核心合约:

  1. VulnerableVault(漏洞合约):先转账后更新状态——典型的反 CEI 模式
  2. ReentrancyAttacker(攻击者合约):通过 receive() 回调反复调用 withdraw()
  3. SecureVault(安全合约):使用 CEI 模式 + nonReentrant modifier

漏洞合约(演示攻击面)

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

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

    // 漏洞模式:Interactions 在 Effects 之前
    function withdraw() external {
        uint256 amount = balances[msg.sender];
        require(amount > 0, "Nothing to withdraw");
        require(address(this).balance >= amount, "Insufficient contract balance");

        (bool sent, ) = msg.sender.call{value: amount}("");
        require(sent, "Transfer failed");

        balances[msg.sender] = 0; // 状态更新在外部调用之后!
    }
}

攻击者合约

solidity
contract ReentrancyAttacker {
    VulnerableVault public vault;

    constructor(address _vault) {
        vault = VulnerableVault(_vault);
    }

    function attack() external payable {
        vault.deposit{value: msg.value}();
        vault.withdraw();
    }

    receive() external payable {
        if (address(vault).balance >= 1 ether) {
            vault.withdraw(); // 重入!此时 balances[attacker] 尚未被设为 0
        }
    }
}

安全合约(两种防护方案)

方案 A:CEI 模式

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

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

    function withdraw() external {
        uint256 amount = balances[msg.sender];     // Checks
        require(amount > 0, "Nothing to withdraw");

        balances[msg.sender] = 0;                   // Effects(先更新!)

        (bool sent, ) = msg.sender.call{value: amount}(""); // Interactions
        require(sent, "Transfer failed");
    }
}

方案 B:ReentrancyGuard

solidity
import {ReentrancyGuard} from "@openzeppelin/contracts/utils/ReentrancyGuard.sol";

contract SecureVaultWithGuard is ReentrancyGuard {
    mapping(address => uint256) public balances;

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

    function withdraw() external nonReentrant {
        uint256 amount = balances[msg.sender];
        require(amount > 0, "Nothing to withdraw");

        balances[msg.sender] = 0;
        (bool sent, ) = msg.sender.call{value: amount}("");
        require(sent, "Transfer failed");
    }
}

4. 遭遇的陷阱

4.1 对 transfer() 的盲目信任

许多开发者认为 address.transfer() 是安全的,因为它只转发 2300 gas——不足以执行重入。但实际上,EIP-1884 后某些操作码 gas 成本增加,2300 gas 的假设不再可靠。此外,如果合约使用 call{value}("")(推荐方式),就必须自己处理重入防护。

4.2 跨函数重入

即使 withdraw() 本身有 CEI 保护,如果 withdraw() 调用了另一个未受保护的函数,那个函数又可能被重入。重入不一定是递归调用同一个函数——它可以跨任意公开函数。

4.3 ERC20 代币的重入

不仅 ETH 转账可被重入。任何 ERC20 transfersafeTransfer 都可能触发接收方的回调(特别是 ERC777 和某些 ERC20 变体)。当合约向未知代币发送资金时,同样需要 CEI 保护。

4.4 nonReentrant 只能防同一函数的重入

OpenZeppelin 的 nonReentrant modifier 只在同一 modifier 范围内生效。如果有两个函数各用各的 nonReentrant,它们之间可以互相重入。真实攻击中常利用此点进行跨函数重入。

5. 陷阱的原因

5.1

以太坊虚拟机在执行 CALL 操作码时会转移控制权给目标合约。如果目标合约的代码触发了一个回到原合约的调用,这个新调用会在原调用的剩余上下文(包括未更新的存储)中执行。Solidity 编译器不会自动检测这种模式。

5.2

跨函数重入之所以危险,是因为开发者通常只关注单个函数的正确性,而非全局状态一致性。当 Contract A 调用 Contract B 时,Contract B 可能回调 Contract A 的任何公开函数——而且此时 Contract A 的状态可能处于中间态。

5.3

ERC777 代币标准引入了 tokensReceived 回调钩子,使得任何代币转账都可以触发接收方代码执行。许多对 ERC20 安全的假设在 ERC777 面前失效。这就是为什么 Uniswap V2 在添加新代币对时明确排除了 ERC777 代币。

5.4

nonReentrant 使用一个 _status 状态变量(值为 1 表示未锁定,2 表示已锁定)。两个独立的函数可以各自获取和释放锁,但它们之间的调用链不被阻止。

6. 如何解决陷阱

6.1

始终遵循 CEI 模式:先做所有检查(Checks),再更新所有状态变量(Effects),最后才进行外部调用(Interactions)。在 withdraw 中,将 balances[msg.sender] = 0 放在 call{value}("") 之前。

6.2

对所有公开函数使用 nonReentrant modifier。注意:如果函数 A 和函数 B 都应该被保护,确保它们在同一个重入锁下(使用同一个 modifier)。

6.3

处理 ERC20 转账时使用 safeTransfersafeTransferFrom(来自 OpenZeppelin SafeERC20)。但在调用它们之前仍然先更新自己的状态——不要因为 safeTransfer 看起来安全就放松警惕。

6.4

在审计时,找出所有外部调用点(.call{}(), .transfer(), .send(), safeTransfer(), IERC20 的 transfertransferFrom),对每个点验证:这次调用之后是否还有状态写入?如果有,是否可能被重入利用?

7. 技术要点

要点说明
CEI 模式Checks-Effects-Interactions:状态更新先于外部调用
ReentrancyGuardOpenZeppelin 的 nonReentrant modifier,使用 mutex 锁
重入检测查找所有 .call{}() / transfer / send 之后的状态写入
跨函数重入非递归重入,通过回调链跨越不同函数
ERC777 风险tokensReceived 回调使代币转账也能触发重入
审计策略画出控制流图,标记所有外部调用及其后状态写入

Built with AiAda