3. 重入攻击实战与修复
重入攻击,说白了就是合约在转账或调用外部合约时,被对方反过来又调用了自己。我最早在2016年的The DAO事件里见识到它的威力——那次直接导致了以太坊分叉。嗯,咱们今天就来彻底搞懂它。
3.1 重入攻击的原理
先看一个典型的漏洞合约。我见过不少新手写的NFT合约,都有类似的问题:
// 有漏洞的NFT合约
contract VulnerableNFT is ERC721 {
mapping(uint256 => address) private _owners;
function safeMint(address to, uint256 tokenId) public payable {
require(msg.value >= 0.1 ether, "不够钱");
// 先转账,再铸造
(bool sent, ) = to.call{value: msg.value}("");
require(sent, "转账失败");
_safeMint(to, tokenId); // 这里会被重入
}
function withdraw() public {
uint256 balance = address(this).balance;
(bool sent, ) = msg.sender.call{value: balance}("");
require(sent, "提款失败");
}
}
为什么会这样?因为to.call会触发接收合约的fallback函数。如果接收合约是个恶意合约,它可以在fallback里再次调用safeMint。这时候合约的状态还没更新,_safeMint会重复铸造NFT。
我经历过一个真实案例:有个项目方在mint函数里先退多余的ETH,再更新状态。结果被机器人抢跑,同一个地址mint了上百个NFT,项目直接崩了。
3.2 三种修复方案
方案一:检查-效果-交互模式
这是最基础的防御思路。核心原则就一句话:先检查条件,再更新状态,最后做外部调用。
// 修复后的合约
contract SafeNFT is ERC721 {
mapping(uint256 => address) private _owners;
function safeMint(address to, uint256 tokenId) public payable {
// 第一步:检查
require(msg.value >= 0.1 ether, "不够钱");
require(_owners[tokenId] == address(0), "已存在");
// 第二步:效果(更新状态)
_owners[tokenId] = to;
// 第三步:交互
(bool sent, ) = to.call{value: msg.value}("");
require(sent, "转账失败");
_safeMint(to, tokenId);
}
}
你看,我把_owners[tokenId] = to移到了转账之前。这样即使被重入,第二次调用时require就会失败。
方案二:使用OpenZeppelin ReentrancyGuard
OpenZeppelin提供了现成的重入锁。我个人非常推荐这个方案,因为它简单、可靠,而且经过了大量项目的验证。
import "@openzeppelin/contracts/security/ReentrancyGuard.sol";
contract GuardedNFT is ERC721, ReentrancyGuard {
using ReentrancyGuard for uint256;
function safeMint(address to, uint256 tokenId)
public
payable
nonReentrant // 加上这个修饰符
{
require(msg.value >= 0.1 ether, "不够钱");
(bool sent, ) = to.call{value: msg.value}("");
require(sent, "转账失败");
_safeMint(to, tokenId);
}
function withdraw() public nonReentrant {
uint256 balance = address(this).balance;
(bool sent, ) = msg.sender.call{value: balance}("");
require(sent, "提款失败");
}
}
nonReentrant修饰符会在函数入口设置一个锁,函数执行完再释放。如果重入发生,锁还在,第二次调用就会直接revert。
delegatecall绕过去了。
方案三:在NFT合约中正确放置锁
对于NFT合约,有几个关键位置必须加锁:
- mint函数:尤其是涉及ETH退款的mint
- batchMint函数:批量铸造时更容易被重入
- withdraw函数:提款函数是重入攻击的重灾区
- setApprovalForAll:虽然不直接涉及转账,但状态变更也可能被利用
我见过一个项目,他们在mint函数加了锁,但batchMint忘了加。攻击者通过批量铸造,每次只付一个NFT的钱,却拿到了多个NFT。嗯,这种低级错误其实挺常见的。
3.3 我经历过的重入漏洞审计案例
分享一个我印象深刻的案例。去年审计一个NFT市场合约,他们的挂单取消功能是这样的:
function cancelOrder(uint256 orderId) public {
Order storage order = orders[orderId];
require(order.seller == msg.sender, "不是你的单");
// 先退NFT
IERC721(order.nft).safeTransferFrom(
address(this),
msg.sender,
order.tokenId
);
// 再删除订单
delete orders[orderId];
}
问题出在哪?safeTransferFrom会触发接收合约的onERC721Received回调。攻击者可以在回调里再次调用cancelOrder,因为订单还没删除,第二次调用会再次转出同一个NFT。
修复方案很简单:先删除订单,再转NFT。
function cancelOrder(uint256 orderId) public nonReentrant {
Order storage order = orders[orderId];
require(order.seller == msg.sender, "不是你的单");
// 先删除
address nft = order.nft;
uint256 tokenId = order.tokenId;
delete orders[orderId];
// 再转账
IERC721(nft).safeTransferFrom(
address(this),
msg.sender,
tokenId
);
}
- 重入攻击的本质是状态更新和外部调用的顺序问题
- 检查-效果-交互模式是最基础的防御
- ReentrancyGuard是可靠的现成方案
- NFT合约中,mint、batchMint、withdraw、cancelOrder都是高危函数
- 跨合约调用时要注意锁的覆盖范围
最后说一句:重入攻击的变种很多,但防御思路其实很统一。你只要记住「先更新状态,再做外部调用」这个原则,就能防住90%的攻击。剩下的10%,靠ReentrancyGuard和代码审计来补。