Understanding and exploiting reentrancy while safeMint()-ing NFTs.
safeMint() function expects that the recipient contract should implement onERC721Received which is basically a sign of the contract supporting ERC721 so that tokens won’t get stuck once someone sends it to that address.
And this is how it works…
In ERC721 OZ implementation whenever a token is transferred to a contract via safeTransferFrom or minted by safeMint() it calls onERC721Received on that contract.
It must return a solidity selector for onERC721Received to confirm the token transfer. If any other value is returned or the interface is not implemented by the recipient, the transfer will be reverted.
Example to understand (and to exploit):
It's setting the price in the constructor and there are two functions:
- buyNFT(): which is a payable function and takes a native token e.g ETH/BNB after that it sets canClaim mapping to true for msg.sender.
- claim(): is for claiming the NFT which uses safeMint() to mint the NFT and sets canClaim to false for the msg.sender.
The way safeMint() checks that the to address can handle the NFT is how we discussed above — by calling onERC721Received on to address. so here control flow moves to to address. and this is where it creates/increases an attack surface.
Let's write an exploit for the above contract :
Attacker smart contract to exploit the NFTContract, Here it is
It has a constructor which takes the NFTContract address so it can be used later and two functions:
- buyAndClaimNftsWithTrick() is the function that carries the whole attack from buying NFT with buyNFT() and claiming it with the claim().
- A malicious onERC721Received() which reenters (claims NFT again) and returns 4 bytes selector.
Let's discuss the exploit first:
When the attacker calls AttackerContract : buyAndClaimNftsWithTrick() it first buys the NFT with buyNFT() now after that it calls claim() it safemints the NFT to msg.sender, Here the msg.sender is AttackerContract which implements malicious onERC721Received which will reenter to call claim() again. if you see the malicious onERC721Received it contains an if statement which will set the cnt to false and will call the claim() again to get an extra NFT.
The intention behind setting cnt to false is simple and that is to avoid a loop when claim calls onERC721Received while safeminting NFTs to AttackerContract.
Let’s test the exploit…
I am using foundry so writing test case in solidity 🔥
The test file contains some variables that we are using in some test functions.
- setUp(): is similar to Mocha’s beforeEach(), Here we are going to create instances with which we are interacting in test functions.
- testBuyAndHack(): is the function that tests the exploit. It transfers 1e18 native token to attacker contract and calls buyAndClaimNftsWithTrick() which performs a reentrancy attack and then on the last line we have an assertion to check the NFT balance of attacker contract is 2 (i.e greater than 1 and we got that extra 1 NFT by reentering )
Why it happened :
If we check SampleERC721:claim() we can see it lacks the Checks Effects Interactions pattern and the nature of safeMint() function implementation makes it vulnerable to reentrancy.
How to prevent it 💡:
This situation can be prevented by following Checks Effects Interactions pattern where canClaim can be set to false before safeminting happens. So even though the caller can reenter but if he calls claim() after reentering then the function will revert because of the check added on the first line of claim() which checks for canClaim[msg.sender] should be true.
Code used in this article is damn vulnerable, do not use in production.This article is for educational purpose only.