2. 重入攻击与NFT合约:重入攻击原理、NFT合约中的重入风险点、使用ReentrancyGuard防护、实战案例分析
2.1 重入攻击到底是个啥?
说实话,我第一次听说“重入攻击”这个词,是在一个深夜的漏洞复盘会上。当时一个哥们儿脸色铁青,说项目被黑了,损失了300多个ETH。原因?就是重入。
重入攻击,说白了就是:合约A调用了合约B,合约B在回调合约A的时候,合约A的状态还没更新完。你想想看,这就像你借钱给别人,别人还没打借条,他又来找你借第二次。你借不借?
在Solidity里,最经典的场景就是withdraw()函数。合约先给用户转账ETH,再更新用户的余额。如果用户是个恶意合约,它会在收到ETH的fallback()函数里,再次调用withdraw()。这时候用户的余额还没扣减,所以第二次提现又能成功。如此循环,直到合约的ETH被掏空。
2.2 NFT合约里的重入风险点
很多人觉得重入攻击只跟ETH转账有关,跟NFT没啥关系。嗯,我以前也这么想,直到我审计了一个NFT市场合约,差点翻车。
NFT合约里,重入攻击的风险点其实不少。我列几个常见的:
- mint() 函数中的回调:有些NFT合约在铸造时会调用
_safeMint(),这个函数会触发接收者的onERC721Received()回调。如果接收者是个恶意合约,它可以在回调里再次调用mint(),导致铸造数量超出预期。 - transferFrom() 中的钩子:ERC721的
safeTransferFrom()同样会触发回调。如果目标地址是合约,它可以在回调里执行任意逻辑,比如再次转移同一NFT。 - 批量操作中的状态不一致:比如批量销毁、批量转移,如果循环里调用了外部合约,且状态更新在循环之后,就可能被重入。
- 质押/借贷合约中的提现:NFT质押合约里,用户提取奖励时如果先转账后更新状态,同样存在重入风险。
2.3 用ReentrancyGuard防护
OpenZeppelin提供了一个现成的解决方案——ReentrancyGuard。它的原理其实很简单:用一个状态变量作为“锁”,进入函数时上锁,退出时解锁。如果函数还没执行完,再次进入就会被阻止。
我个人习惯在涉及外部调用的函数上,都加上nonReentrant修饰符。虽然有时候会多花一点gas,但安全第一嘛。
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.0;
import "@openzeppelin/contracts/security/ReentrancyGuard.sol";
contract NFTSafeMint is ReentrancyGuard {
mapping(uint256 => address) private _owners;
uint256 private _nextTokenId = 1;
// 使用 nonReentrant 防止重入
function safeMint(address to) external nonReentrant returns (uint256) {
uint256 tokenId = _nextTokenId;
_nextTokenId++;
// 先更新状态
_owners[tokenId] = to;
// 再调用外部回调
_safeMint(to, tokenId);
return tokenId;
}
function _safeMint(address to, uint256 tokenId) internal {
// 省略具体实现...
// 这里会触发 onERC721Received 回调
}
}
_safeMint(),建议在调用之前先更新内部状态(比如_owners映射)。这样即使回调里发生重入,状态已经更新,不会造成重复铸造。
2.4 实战案例分析
我记得有一个真实案例,是一个叫“CryptoPunks Lite”的仿盘项目。它的mint函数是这样的:
function mint(uint256 amount) external payable {
require(msg.value == amount * mintPrice);
for (uint256 i = 0; i < amount; i++) {
uint256 tokenId = totalSupply++;
_safeMint(msg.sender, tokenId);
}
}
问题出在哪?_safeMint()在循环里被调用,而且totalSupply是在循环里递增的。如果msg.sender是个恶意合约,它在onERC721Received()回调里再次调用mint(),会发生什么?
嗯,totalSupply已经增加了,但msg.value的校验已经通过了。攻击者可以只付一次钱,就铸造出无数个NFT。这个项目上线不到24小时就被薅秃了。
我曾经在审计一个NFT市场时,也遇到过类似的问题。合约的batchTransfer()函数在循环里调用了safeTransferFrom(),而状态更新在循环之后。我建议他们把状态更新移到循环之前,或者加上nonReentrant修饰符。还好客户听劝,改得及时。
2.5 知识体系总览
下面这张图是我自己画的,把重入攻击在NFT合约中的关键点串起来了。你一看就明白:
2.6 避坑指南
我曾经犯过一个低级错误:在withdraw()函数里用了nonReentrant,但忘了在mint()函数上加。结果攻击者通过onERC721Received回调,绕过了提现的限制,直接从mint函数里搞事情。所以,要么所有外部调用的函数都加锁,要么一个都不加。半吊子的防护等于没防护。
另外,ReentrancyGuard也不是万能的。如果你的合约逻辑里存在跨合约调用链,比如A调B、B调C、C又调A,这种“跨合约重入”也是有可能的。我建议在架构设计阶段就画好调用图,提前识别循环依赖。
ReentrancyGuard这把锁,基本就能挡住99%的攻击。剩下的1%,靠代码审计和测试来补。