重入攻击:智能合约的“幽灵漏洞”
重入攻击,说白了就是合约在调用外部合约时,被对方“反咬一口”。
我最早接触这个漏洞,是在2016年。那时候DAO事件刚发生,整个以太坊社区都炸了锅。360万以太币被盗,价值数亿美元。嗯,这个漏洞的破坏力,你想想看,直接导致了以太坊硬分叉。
为什么会这样?因为Solidity的调用机制有个特点:外部调用会把控制权交给对方。如果对方是个恶意合约,它可以在你的函数还没执行完时,再次调用你的函数。
攻击原理:递归调用如何掏空合约
重入攻击的核心逻辑其实很简单。我画个图你就明白了:
你看这个流程:攻击者先调用 withdraw(),合约检查余额通过后,先转账。转账时触发攻击者的 fallback() 函数,这个函数里又调用了 withdraw()。此时余额还没更新,所以又能通过检查。如此循环,直到合约被掏空。
关键点:重入攻击能成功,是因为状态更新发生在外部调用之后。只要把顺序反过来,就能堵住这个漏洞。
经典DAO攻击案例:血的教训
2016年的DAO攻击,是区块链史上最著名的事件之一。我复盘一下当时的代码逻辑:
// 简化版DAO合约(有漏洞)
contract DAO {
mapping(address => uint) public balances;
function withdraw(uint _amount) public {
// 检查余额
require(balances[msg.sender] >= _amount);
// 转账(外部调用)
msg.sender.call{value: _amount}("");
// 更新余额(在转账之后!)
balances[msg.sender] -= _amount;
}
}
攻击者部署了一个恶意合约:
contract AttackDAO {
DAO public dao;
constructor(address _dao) {
dao = DAO(_dao);
}
function attack() public payable {
// 先存入一些ETH
dao.deposit{value: msg.value}();
// 开始提取
dao.withdraw(msg.value);
}
// fallback函数:收到ETH时再次调用withdraw
fallback() external payable {
if (address(dao).balance >= msg.value) {
dao.withdraw(msg.value);
}
}
}
这个攻击有多狠?攻击者只存了一次钱,却可以反复提取。我记得当时有个团队,他们的合约里存了上千万美元,几分钟内就被掏空了。
⚠️ 避坑指南:我曾经审计过一个DeFi项目,他们的提现函数也是先转账后更新。我跟他们说了DAO攻击的故事,CTO还不信,说“我们用的是ERC20,不是ETH”。结果我写了个测试用例,用ERC20的transfer回调函数,一样能重入。记住:任何外部调用都可能被重入,不管你是转ETH还是调ERC20。
重入锁防护:最简单的防御方案
重入锁,说白了就是一个“正在执行”的标志位。函数执行时锁上,执行完再解锁。如果重入进来,发现锁着,直接revert。
// OpenZeppelin的ReentrancyGuard
contract ReentrancyGuard {
uint256 private _status;
// 0 = 未锁定, 1 = 已锁定
constructor() {
_status = 0;
}
modifier nonReentrant() {
require(_status == 0, "ReentrancyGuard: reentrant call");
_status = 1;
_;
_status = 0;
}
}
// 使用重入锁
contract SafeDAO is ReentrancyGuard {
mapping(address => uint) public balances;
function withdraw(uint _amount) public nonReentrant {
require(balances[msg.sender] >= _amount);
// 先更新余额
balances[msg.sender] -= _amount;
// 再转账
msg.sender.call{value: _amount}("");
}
}
这个方案的好处是简单粗暴。但要注意:重入锁会消耗更多gas,而且不能嵌套使用。我见过有人在一个函数里调另一个带锁的函数,结果把自己锁死了。
💡 个人经验:我习惯在涉及外部调用的函数上都加nonReentrant。虽然gas成本高了点,但安全第一。你想想看,省那点gas,万一被盗了,得不偿失。
检查-生效-交互模式:更优雅的解决方案
其实重入锁是“治标”,检查-生效-交互模式才是“治本”。这个模式的核心思想就一句话:先检查条件,再更新状态,最后做外部调用。
// 检查-生效-交互模式
contract CheckEffectInteract {
mapping(address => uint) public balances;
function withdraw(uint _amount) public {
// 第一步:检查(Check)
require(balances[msg.sender] >= _amount, "余额不足");
require(_amount > 0, "金额必须大于0");
// 第二步:生效(Effect)
balances[msg.sender] -= _amount;
// 第三步:交互(Interaction)
(bool success, ) = msg.sender.call{value: _amount}("");
require(success, "转账失败");
}
}
你看这个顺序:先检查余额够不够,然后直接扣减余额,最后才转账。就算转账时触发重入,余额已经扣了,重入进来检查余额时就会失败。
| 方案 | 优点 | 缺点 | 适用场景 |
|---|---|---|---|
| 重入锁 | 简单、通用、不易出错 | gas成本高、不能嵌套 | 复杂合约、多函数需要保护 |
| 检查-生效-交互 | gas成本低、无嵌套问题 | 需要仔细设计状态更新顺序 | 简单提现、转账场景 |
| 两者结合 | 双重保险、最安全 | gas成本最高 | 高价值合约、资金池 |
我的建议:对于新写的合约,优先用检查-生效-交互模式。如果合约逻辑复杂,或者有多个函数互相调用,再加个重入锁做双重保险。我自己的项目都是这么干的,目前还没出过问题。
嗯,重入攻击就讲到这里。记住一句话:外部调用永远不可信。你永远不知道对方会在回调函数里做什么。把状态更新放在外部调用之前,这个习惯养成了,能帮你避开90%的重入漏洞。
公众号:蓝海资料掘金营,微信deep3321