重入攻击:原理剖析、经典DAO攻击案例与防御策略
大家好,我是你们的老朋友。今天咱们来聊聊智能合约安全里最经典、也最致命的一个漏洞——重入攻击。
说实话,我入行智能合约安全的第一课,就是研究DAO攻击。那会儿我还在想,一个简单的转账函数,怎么就能把几千万美元给转走了?后来自己动手复现了一遍,才真正明白——合约的执行顺序,有时候比逻辑本身更重要。
一、重入攻击的原理剖析
重入攻击,说白了就是:合约A调用了合约B,合约B在回调合约A的时候,合约A的状态还没更新完。
你想想看,如果合约A先转账给合约B,再更新自己的余额。那合约B在收到转账时,就可以再次调用合约A的转账函数。这时候合约A的余额还没扣呢,它以为你还有钱,于是又转了一次。如此循环,直到合约A的余额被掏空。
为什么会这样?因为以太坊的调用是同步的。合约A调用合约B时,合约B的代码会立即执行。如果合约B的fallback函数里又调用了合约A,那就形成了递归调用。
我举个例子,大家感受一下:
// 有漏洞的合约
contract VulnerableBank {
mapping(address => uint) public balances;
function withdraw(uint _amount) public {
require(balances[msg.sender] >= _amount);
// 先转账
(bool success, ) = msg.sender.call{value: _amount}("");
require(success, "转账失败");
// 后更新状态
balances[msg.sender] -= _amount;
}
}
这段代码的问题在哪?先转账,后更新余额。攻击者可以在自己的合约里写一个fallback函数,每次收到以太坊就再次调用withdraw。因为余额还没扣,所以每次都能通过require检查。
核心要点:重入攻击的本质是状态更新滞后。合约在外部调用完成之后才更新内部状态,给了攻击者可乘之机。
二、经典DAO攻击案例
说到重入攻击,就不得不提2016年的DAO事件。那是我入行前两年发生的事,但至今仍是每个安全工程师的必修课。
DAO合约实现了一个拆分功能(splitDAO),允许用户提取自己的以太坊。它的逻辑大致是这样的:
- 检查用户是否有足够的余额
- 向用户发送以太坊
- 更新用户的余额
嗯,你猜到了——又是先转账,后更新。
攻击者写了一个攻击合约,在fallback函数里递归调用splitDAO。每次调用都触发一次转账,而余额始终没变。就这样,攻击者从DAO合约里转走了大约360万个以太坊。按当时的币价算,价值超过6000万美元。
我记得第一次看到攻击交易记录时,整个人都愣住了。一个简单的递归调用,居然能造成这么大的损失。从那以后,我写合约时都会反复检查:状态更新是不是在外部调用之前完成的?
警告:DAO攻击的教训告诉我们,不要相信任何外部合约。你的合约被调用时,调用者可能是恶意的。它可以在你的执行过程中再次调用你。
三、防御策略
好了,原理和案例都讲完了。接下来咱们聊聊怎么防。我个人习惯用两种方法:检查-效应-交互模式和重入锁。
3.1 检查-效应-交互模式
这个模式的名字有点长,但核心思想很简单:先检查条件,再更新状态,最后才做外部调用。
说白了就是:把状态更新挪到转账之前。
// 安全的合约
contract SafeBank {
mapping(address => uint) public balances;
function withdraw(uint _amount) public {
// 第一步:检查
require(balances[msg.sender] >= _amount, "余额不足");
// 第二步:效应(更新状态)
balances[msg.sender] -= _amount;
// 第三步:交互(外部调用)
(bool success, ) = msg.sender.call{value: _amount}("");
require(success, "转账失败");
}
}
你看,现在即使攻击者在fallback里再次调用withdraw,require检查也会失败——因为余额已经被扣掉了。
我在项目中遇到过好几次这样的情况:开发同学觉得「先转账再更新」和「先更新再转账」没什么区别。其实区别大了去了。你想想看,如果转账失败了呢?先更新再转账,转账失败时余额已经扣了,用户就亏了。所以正确的做法是:先更新状态,再转账。如果转账失败,用revert回滚状态。
小技巧:如果你用的是Solidity 0.8.0以上版本,可以用address.transfer()或address.send()。它们只转发2300 gas,不够攻击者做复杂的递归调用。但要注意,这只能防住gas消耗大的攻击,不是万能的。
3.2 重入锁
重入锁是另一种常见的防御手段。它的原理很简单:用一个布尔变量标记合约是否正在执行中。如果正在执行,就拒绝新的调用。
OpenZeppelin提供了一个现成的ReentrancyGuard合约,咱们可以直接用:
import "@openzeppelin/contracts/security/ReentrancyGuard.sol";
contract LockedBank is ReentrancyGuard {
mapping(address => uint) public balances;
function withdraw(uint _amount) public nonReentrant {
require(balances[msg.sender] >= _amount, "余额不足");
(bool success, ) = msg.sender.call{value: _amount}("");
require(success, "转账失败");
balances[msg.sender] -= _amount;
}
}
注意看,这里用了nonReentrant修饰符。它的作用就是:在函数执行期间锁定合约,防止递归调用。
我曾经在一个审计项目里看到,开发同学自己写了一个重入锁,但锁的变量是public的。攻击者可以直接读取锁的状态,然后绕过检查。嗯,这里要注意:重入锁的变量一定要是private的,而且不能有任何外部函数可以修改它。
四、两种防御策略的对比
| 防御策略 | 优点 | 缺点 | 适用场景 |
|---|---|---|---|
| 检查-效应-交互模式 | 简单、gas消耗低、不需要额外状态 | 需要开发者严格遵守顺序,容易遗漏 | 所有涉及外部调用的函数 |
| 重入锁 | 防御彻底、不依赖代码顺序 | 增加gas消耗、可能与其他合约交互冲突 | 复杂合约、跨合约调用场景 |
我个人建议:两种方法一起用。检查-效应-交互模式作为第一道防线,重入锁作为第二道防线。这样即使你忘了更新状态顺序,重入锁也能兜底。
最佳实践:
- 所有外部调用(call、delegatecall、transfer、send)都放在函数最后
- 使用OpenZeppelin的ReentrancyGuard,不要自己造轮子
- 在测试中模拟递归调用,确保防御生效
五、避坑指南
最后,分享几个我踩过的坑:
- 我曾经以为只有转账函数才有重入风险。其实任何外部调用都可能被重入,包括调用其他合约的函数。所以只要你的合约调用了外部合约,就要考虑重入问题。
- 我曾经在构造函数里用了重入锁。构造函数里不能用nonReentrant,因为合约还没部署完。这个坑让我debug了一下午。
- 我曾经见过有人用modifier来更新状态。比如在modifier里扣余额,然后在函数体里转账。这样虽然顺序对了,但可读性很差,容易出错。我建议把状态更新写在函数体里,保持逻辑清晰。
好了,关于重入攻击的内容就讲到这里。下一章咱们聊聊访问控制漏洞——为什么你的管理员权限可能被任何人调用。到时候见!