3、重入攻击原理与实战:单函数重入、跨函数重入、重入锁与检查-效果模式
重入攻击,说白了就是合约在调用外部合约时,被对方反过来又调用了自己。这就像你打电话给朋友,结果朋友在电话里又拨通了你的号码——你俩就陷入了死循环。在智能合约的世界里,这种循环往往意味着资金被盗。
我最早接触这个漏洞是在一个DeFi项目的审计中。当时看到一个提现函数,逻辑很简单:先转账,后更新余额。我一看就警觉了——这不就是教科书式的重入漏洞吗?结果一查,项目方还真没做防护。嗯,今天我们就来彻底搞懂它。
3.1 单函数重入:最经典的攻击方式
单函数重入是最基础的形式。攻击者利用同一个函数内的外部调用,在状态更新前反复进入。
看个例子:
// 漏洞合约
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;
}
}
攻击者合约长这样:
contract Attacker {
VulnerableBank public bank;
constructor(address _bank) {
bank = VulnerableBank(_bank);
}
// fallback 函数,收到ETH时自动触发
receive() external payable {
if (address(bank).balance >= 1 ether) {
bank.withdraw(1 ether);
}
}
function attack() external payable {
require(msg.value >= 1 ether, "至少存1个ETH");
bank.deposit{value: 1 ether}();
bank.withdraw(1 ether);
}
}
为什么会这样?因为call会把控制权交给接收方。攻击者的receive()函数在收到ETH后,又调用了withdraw()。而此时balances[msg.sender]还没扣减,所以又能通过检查。如此循环,直到合约余额被掏空。
核心问题:状态更新在外部调用之后。这是所有重入攻击的根源。
3.2 跨函数重入:更隐蔽的攻击手法
跨函数重入比单函数重入更狡猾。攻击者不是重复调用同一个函数,而是利用外部调用跳转到合约的另一个函数。
我记得审计过一个NFT市场合约,就踩过这个坑。合约里有buy()和refund()两个函数,共享同一个状态变量。攻击者可以在buy()的外部调用中,触发refund()来修改共享状态。
contract CrossFunctionVulnerable {
mapping(address => uint) public credits;
bool public isRefunding;
function buy(address _nft, uint _price) external {
require(credits[msg.sender] >= _price, "积分不足");
// 外部调用:转账给NFT卖家
(bool success, ) = msg.sender.call{value: _price}("");
require(success, "转账失败");
// 扣减积分
credits[msg.sender] -= _price;
}
function refund() external {
require(!isRefunding, "正在退款中");
isRefunding = true;
uint amount = credits[msg.sender];
// 外部调用
(bool success, ) = msg.sender.call{value: amount}("");
require(success, "退款失败");
// 清零积分
credits[msg.sender] = 0;
isRefunding = false;
}
}
攻击者可以在buy()的call中,通过fallback函数调用refund()。由于buy()还没扣减积分,refund()会拿到完整的积分进行退款。结果就是:攻击者既买了NFT,又拿到了全额退款。
注意:跨函数重入更难发现,因为攻击路径不是同一个函数。审计时一定要关注所有外部调用点,检查它们可能触发的其他函数。
3.3 重入锁:最直接的防御手段
重入锁的原理很简单:用一个状态变量标记合约是否正在执行。如果已经进入,就拒绝再次调用。
OpenZeppelin提供了现成的ReentrancyGuard:
import "@openzeppelin/contracts/security/ReentrancyGuard.sol";
contract SafeBank is ReentrancyGuard {
mapping(address => uint) public balances;
function withdraw(uint _amount) external nonReentrant {
require(balances[msg.sender] >= _amount, "余额不足");
(bool success, ) = msg.sender.call{value: _amount}("");
require(success, "转账失败");
balances[msg.sender] -= _amount;
}
}
nonReentrant修饰符的工作原理:
modifier nonReentrant() {
require(_status != _ENTERED, "禁止重入");
_status = _ENTERED;
_;
_status = _NOT_ENTERED;
}
我个人习惯在涉及外部调用的函数上都加上nonReentrant。哪怕你觉得这个函数不会被重入,也加上。为什么?因为合约升级后,函数逻辑可能变化,外部调用可能新增。提前加锁,省得以后踩坑。
小技巧:重入锁的变量要放在存储槽的最前面,避免被其他操作意外覆盖。OpenZeppelin的实现已经考虑到了这一点。
3.4 检查-效果模式:更优雅的解决方案
检查-效果模式(Checks-Effects-Interactions Pattern)是Solidity官方推荐的做法。核心思想是:先检查条件,再更新状态,最后进行外部交互。
说白了就是:把状态更新挪到外部调用之前。
contract SafeWithdraw {
mapping(address => uint) public balances;
function withdraw(uint _amount) external {
// 第一步:检查
require(balances[msg.sender] >= _amount, "余额不足");
// 第二步:效果(更新状态)
balances[msg.sender] -= _amount;
// 第三步:交互(外部调用)
(bool success, ) = msg.sender.call{value: _amount}("");
require(success, "转账失败");
}
}
这样改完之后,即使攻击者在call中再次调用withdraw(),余额已经扣减了,require检查会直接失败。
我曾经在一个跨链桥项目中看到过这种模式的应用。项目方把检查-效果模式用在了跨链消息处理上:先验证消息签名,再更新本地状态,最后才调用目标链。这种设计让整个系统安全了很多。
最佳实践:检查-效果模式 + 重入锁,双重保险。检查-效果模式解决逻辑问题,重入锁应对极端情况。两者配合使用,基本能堵住所有重入漏洞。
3.5 实战中的避坑指南
讲几个我实际踩过的坑:
- 不要相信gas限制:有人觉得外部调用会消耗大量gas,攻击者无法循环太多次。但以太坊的gas上限足够攻击者循环几十次,每次提取少量ETH,积少成多。
- 小心ERC777的钩子函数:ERC777代币在转账时会调用接收方的
tokensReceived()钩子。这本质上也是一个外部调用,可能被利用进行重入攻击。我建议优先使用ERC20,或者对ERC777的钩子做重入保护。 - 注意跨合约调用链:有时候重入攻击不是直接调用你的合约,而是通过中间合约绕一圈。审计时要画出完整的调用链,找出所有可能的回环路径。
我曾经... 审计过一个借贷协议,发现攻击者可以通过闪电贷+重入攻击的组合,在同一个交易中反复借贷、还款、提取抵押品。最终导致协议损失了约50万美元。教训就是:不要假设外部调用是安全的,永远假设对方会尝试重入。
3.6 总结
重入攻击的本质是状态更新滞后于外部调用。防御手段无非两种:
| 防御方式 | 原理 | 适用场景 |
|---|---|---|
| 检查-效果模式 | 先更新状态,后外部调用 | 所有涉及外部调用的函数 |
| 重入锁 | 用状态变量标记执行状态 | 复杂逻辑、跨函数调用场景 |
我个人建议:默认使用检查-效果模式,在关键函数上叠加重入锁。这不是过度设计,而是血的教训换来的经验。你想想看,一个重入漏洞可能让整个合约的资金归零,多花几分钟加个锁,值不值得?
下一章我们会讲整数溢出漏洞。这个漏洞在Solidity 0.8版本之后已经被内置检查解决了,但很多旧项目还在用0.7甚至更早的版本。到时候我会分享一个我在审计中发现的、因为整数溢出导致代币增发的案例。