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
mintNFTfunction 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:
mintNFTcalls_safeMint_safeMintcalls_safeMintto set arguments_safeMintcalls_checkOnERC721Received_checkOnERC721ReceivedcallsIERC721Receiver(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()isN - The
_mint()function updates_tokenOwners, so nowtotalSupply()isN+1 - The function
_checkOnERC721ReceivedcallsonERC721Received()in the attacker contract:- Attacker calls
mintNFT(20)again via reentrancy - At this moment
totalSupply()isN+1notN+20– this is the main point - So we can generate 18 additional NFTs from
totalSupply()ofN+1 - It will check whether
N+1+18is less thanMAX_NFT_SUPPLY, but it should be checked withN+20+18, which should revert - Repeat the process similarly
- Attacker calls
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:
- Single Function Reentrancy Attack
- Cross Function Reentrancy Attack
- Cross Contract Reentrancy Attack
- Read Only Reentrancy Attack