ERC721 tokens have become the backbone of the NFT ecosystem, however their implementation contains subtle security risks that developers often overlook. The ERC721 standard includes a safety mechanism called the ERC721Receiver hook, designed to prevent tokens from being lost when sent to contracts. However, this same mechanism introduces an external call that can be exploited through reentrancy attacks.

In this article, we’ll examine how attackers can manipulate the _safeMint function’s external call to bypass minting limits and drain NFT collections, even when developers believe they’ve followed secure coding practices.

Example Contract: Expected Usage

The Masks contract extends ERC721 and manages NFT minting with the following constraints:

  • Users can call the mintNFT function to mint NFTs
  • Maximum of 20 NFTs per transaction
  • Total supply limited by MAX_NFT_SUPPLY

Vulnerable Contract

The vulnerability arises from the external call IERC721Receiver(to).onERC721Received(_msgSender(), from, tokenId, data) within the _safeMint function.

The call flow follows this pattern:

  • mintNFT calls _safeMint
  • _safeMint calls _safeMint to set arguments
  • _safeMint calls _checkOnERC721Received
  • _checkOnERC721Received calls IERC721Receiver(to).onERC721Received(_msgSender(), from, tokenId, data)

The Masks contract checks the number of NFTs being minted at the function’s beginning. totalSupply() uses _tokenOwners.length().

This value is updated by _tokenOwners.set(tokenId, to); in the _mint function, and after that _checkOnERC721Received is called. So it initially seems that reentrancy does not occur.

However, the condition in mintNFT compares the current totalSupply and numberOfNft with MAX_NFT_SUPPLY.

If an attacker re-enters the for loop, the comparison uses an outdated totalSupply value, allowing excess NFT minting.

Attack Example

The attack proceeds through these steps:

  • Attacker calls mintNFT(20)
  • Let’s say the value of totalSupply() is N
  • The _mint() function updates _tokenOwners, so now totalSupply() is N+1
  • The function _checkOnERC721Received calls onERC721Received() in the attacker contract:
    • Attacker calls mintNFT(20) again via reentrancy
    • At this moment totalSupply() is N+1 not N+20 – this is the main point
    • So we can generate 18 additional NFTs from totalSupply() of N+1
    • It will check whether N+1+18 is less than MAX_NFT_SUPPLY, but it should be checked with N+20+18, which should revert
    • Repeat the process similarly

This is an attacker contract:

And this is the exploit:

The attacker successfully mints 110 NFTs, which exceeds both the 20 NFT limit and the maximum mintable number in one transaction.

Prevention

Implement a reentrancy guard to prevent this vulnerability.

Conclusion

The vulnerability demonstrated here highlights a critical lesson for smart contract developers: even when attempting to follow established security patterns like checks-effects-interactions, the introduction of loops with external calls can create unexpected attack vectors. The ERC721 standard’s safety features, while well-intentioned, can become security liabilities without proper safeguards.

This case underscores why comprehensive security audits and reentrancy guards are essential for any contract handling valuable assets. As the DeFi and NFT ecosystems continue to evolve, developers must remain vigilant about these subtle but devastating vulnerabilities that can bypass seemingly robust validation logic.

We maintain a Reentrancy Examples Github Repository that covers other types of reentrancy attacks and protocol-specific reentrancies.

We have also written about type-specific reentrancy attacks:

Resource

https://samczsun.com/the-dangers-of-surprising-code/