2、重入攻击原理与NFT场景:重入攻击的Solidity底层机制、NFT转账中的钩子函数
好,咱们今天聊重入攻击。说实话,这个漏洞在DeFi圈子里已经被讲烂了,但在NFT场景里,很多人反而容易忽视。我见过不少项目方,把ERC721的转账逻辑写得跟ERC20一样简单,结果上线就被薅羊毛。嗯,咱们今天就把这事彻底说透。
2.1 重入攻击的Solidity底层机制
先问个问题:为什么Solidity会有重入攻击?
说白了,就是以太坊的智能合约在执行过程中,外部调用会暂停当前合约的执行流。你想想看,合约A调用了合约B的函数,合约B又反过来调用了合约A的函数——这就形成了一个循环。如果合约A在调用合约B之前,没有更新自己的状态变量,那合约B就能利用这个“时间差”反复执行某些操作。
我给大家画个简单的流程:
用户 --> 合约A.withdraw()
--> 检查用户余额(假设100 ETH)
--> 调用 msg.sender.call{value: 100}()
--> 用户fallback函数被触发
--> 用户再次调用 合约A.withdraw()
--> 检查用户余额(还是100 ETH!因为还没扣减)
--> 再转100 ETH...
--> 无限循环直到Gas耗尽或合约归零
关键点在哪?状态更新发生在外部调用之后。这就是重入攻击的根源。
核心原则:先更新状态,再发起外部调用。
这个原则在2016年The DAO事件之后,就成了智能合约开发的铁律。但说实话,直到今天,我审计的合约里仍然能看到违反这个原则的代码。
2.2 NFT转账中的钩子函数
NFT场景比ERC20复杂在哪?因为ERC721标准里多了几个钩子函数。这些钩子函数,说白了就是转账过程中自动触发的回调。
主要有两个:
_beforeTokenTransfer— 转账前触发_afterTokenTransfer— 转账后触发(OpenZeppelin v4.5+引入)
还有一个更关键的:onERC721Received。这个函数是ERC721接收者合约必须实现的。当NFT被转给一个合约地址时,系统会调用这个函数来确认接收方是否支持ERC721协议。
你想想看,这个onERC721Received函数,本质上就是一个外部可控制的回调入口。如果合约开发者没处理好,这里就是重入攻击的温床。
我举个例子:
// 不安全的转账逻辑
function safeTransferFrom(address from, address to, uint256 tokenId) public {
// 1. 检查发送者权限
require(_isApprovedOrOwner(msg.sender, tokenId));
// 2. 执行转账(更新owner)
_transfer(from, to, tokenId);
// 3. 检查接收者(这里会调用onERC721Received!)
_checkOnERC721Received(from, to, tokenId, "");
}
这段代码看起来没问题?嗯,问题大了。如果_checkOnERC721Received里调用了恶意合约的onERC721Received,而这个恶意合约又在回调里再次调用了safeTransferFrom——那就会发生重入。
注意:OpenZeppelin的ERC721实现从v4.3开始,在_checkOnERC721Received中使用了nonReentrant修饰符来防止重入。但如果你是自己手写的ERC721,或者用了旧版本的库,那就得小心了。
2.3 案例分析:2016年The DAO攻击对NFT的启示
说到重入攻击,绕不开The DAO。虽然这事发生在2016年,但它的教训至今仍然适用。我每次给团队做安全培训,都会拿这个案例开刀。
The DAO攻击回顾:
| 时间 | 事件 |
|---|---|
| 2016年6月 | 攻击者利用split函数中的重入漏洞,反复提取ETH |
| 攻击方式 | 调用split() → 创建子DAO → 提取ETH → fallback触发 → 再次调用split() |
| 损失 | 约360万ETH(当时价值约7000万美元) |
| 后果 | 以太坊硬分叉,分裂为ETH和ETC |
这个攻击对NFT有什么启示?我总结了三点:
- 钩子函数就是新的fallback — 在NFT场景里,
onERC721Received、_beforeTokenTransfer这些钩子函数,本质上和The DAO里的fallback函数一样,都是外部可控的入口。攻击者可以在这些函数里植入恶意逻辑。 - 跨合约调用要谨慎 — NFT经常和Marketplace、Staking合约交互。如果这些合约之间存在循环调用,重入的风险就会成倍增加。我在审计一个NFT借贷协议时,就发现用户可以通过重入,在同一个区块内反复借贷同一枚NFT。
- 状态一致性是关键 — The DAO攻击之所以成功,是因为split函数在转账之后才更新用户余额。NFT场景也一样:如果你在转账过程中调用了外部合约,而外部合约又依赖了NFT的当前状态,那就可能出问题。
我的建议:在NFT合约中,尽量使用_beforeTokenTransfer来做状态检查,而不是在_afterTokenTransfer里做。因为_beforeTokenTransfer在状态变更之前执行,不容易被重入利用。当然,最好的做法是加上nonReentrant修饰符。
2.4 实战:一个NFT重入攻击的模拟
光说不练假把式。咱们来看一个具体的攻击场景:
// 攻击者合约
contract NFTReentrancyAttacker {
INFT public nft;
uint256 public targetTokenId;
bool public attackInProgress;
constructor(address _nft) {
nft = INFT(_nft);
}
// 攻击入口
function attack(uint256 tokenId) external {
targetTokenId = tokenId;
attackInProgress = true;
// 触发第一次转账
nft.safeTransferFrom(address(this), victim, tokenId);
}
// 钩子函数:在接收NFT时触发
function onERC721Received(
address, address, uint256, bytes calldata
) external returns (bytes4) {
if (attackInProgress) {
attackInProgress = false;
// 重入!再次调用转账
nft.safeTransferFrom(address(this), victim, targetTokenId);
}
return this.onERC721Received.selector;
}
}
这个攻击能成功的前提是什么?是目标NFT合约在safeTransferFrom中没有使用重入锁。如果用了nonReentrant,第二次调用会被直接revert。
我曾经在一个NFT盲盒项目里发现过类似的问题。项目方为了省Gas,自己手写了ERC721的转账逻辑,结果_checkOnERC721Received被放在了状态更新之后。攻击者只需要部署一个恶意合约,就能在同一个交易里反复领取盲盒奖励。嗯,那次的损失虽然不大,但也够项目方喝一壶的了。
2.5 防御方案总结
说了这么多,到底怎么防?我给大家列个清单:
- 使用OpenZeppelin的ERC721实现 — 别自己造轮子。OpenZeppelin从v4.3开始已经内置了重入保护。
- 添加nonReentrant修饰符 — 对所有涉及外部调用的函数加上这个修饰符。虽然会多花一点Gas,但安全第一。
- 遵循检查-生效-交互模式 — 先检查条件,再更新状态,最后发起外部调用。这个顺序不能乱。
- 限制钩子函数中的操作 — 在
onERC721Received和_beforeTokenTransfer中,尽量不要发起新的NFT转账。如果必须做,一定要加锁。 - 审计第三方合约 — 如果你的NFT要和Marketplace、Staking合约交互,一定要审计这些合约的重入防护。因为攻击者可能通过第三方合约来发起重入。
一句话总结:重入攻击的本质是状态更新和外部调用的顺序问题。在NFT场景里,钩子函数就是新的攻击面。记住:先更新,再调用。别给攻击者留机会。
好,这一章就到这里。下一章咱们聊聊NFT中的闪电贷攻击,那又是另一个有意思的话题了。