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质押合约里,用户提取奖励时如果先转账后更新状态,同样存在重入风险。
⚠️ 特别注意: 即使你的合约没有直接转账ETH,只要调用了外部合约(包括ERC721的回调),就存在重入风险。不要以为“我没用call”就安全了。

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合约中的关键点串起来了。你一看就明白:

NFT合约重入攻击知识体系 攻击原理 先调用 → 后更新状态 NFT合约风险点 mint / transfer / 批量操作 防护方案 ReentrancyGuard 实战案例:CryptoPunks Lite 仿盘 — 循环中 _safeMint 导致无限铸造 ✅ 最佳实践总结 1. 所有涉及外部调用的函数,加上 nonReentrant 修饰符 2. 先更新内部状态,再调用外部合约(检查-生效-交互模式) 3. 避免在循环中调用 _safeMint / safeTransferFrom

2.6 避坑指南

我曾经犯过一个低级错误:在withdraw()函数里用了nonReentrant,但忘了在mint()函数上加。结果攻击者通过onERC721Received回调,绕过了提现的限制,直接从mint函数里搞事情。所以,要么所有外部调用的函数都加锁,要么一个都不加。半吊子的防护等于没防护。

另外,ReentrancyGuard也不是万能的。如果你的合约逻辑里存在跨合约调用链,比如A调B、B调C、C又调A,这种“跨合约重入”也是有可能的。我建议在架构设计阶段就画好调用图,提前识别循环依赖。

最后说一句: 重入攻击是Solidity里最经典的漏洞之一,但也是最容易防范的。只要记住“先改状态,再调外部”这个原则,加上ReentrancyGuard这把锁,基本就能挡住99%的攻击。剩下的1%,靠代码审计和测试来补。

专注资料整理