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
callsIERC721Receiver(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
_checkOnERC721Received
callsonERC721Received()
in the attacker contract:- Attacker calls
mintNFT(20)
again via reentrancy - At this moment
totalSupply()
isN+1
notN+20
– this is the main point - So we can generate 18 additional NFTs from
totalSupply()
ofN+1
- It will check whether
N+1+18
is 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