NFT Dealers

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

Sellers Can Extract Fake Sale Share Without Any Purchase Ever Occurring

Author Revealed upon completion

Sellers Can Extract Fake Sale Proceeds Without Any Purchase Ever Occurring

Description

The intended flow is that collectUsdcFromSelling() should only release sale proceeds after a real buyer has purchased the NFT.

In the current implementation, collectUsdcFromSelling() only checks that the listing is inactive. However, cancelListing() also sets isActive to false. This allows a seller to cancel their own listing and then call collectUsdcFromSelling() even though no purchase ever happened. As a result, the seller can extract unearned USDC from the contract.

In the worst case, a malicious seller can first set an arbitrarily high listing price, then cancel the listing and use that inflated price as the basis for an illegitimate payout.

function collectUsdcFromSelling(uint256 _listingId) external onlySeller(_listingId) {
Listing memory listing = s_listings[_listingId];
require(!listing.isActive, "Listing must be inactive to collect USDC");
//@audit - High - Protocol did not keep track if the buying happenned or not, so attacker can use 'cancelListing' function to set listing.isActive to 'false' and 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);
usdc.safeTransfer(msg.sender, amountToSeller);
}

Risk

Likelihood:

  • Any seller can trigger the vulnerable state by listing an NFT, canceling it, and then calling collectUsdcFromSelling().

  • The exploit uses normal protocol actions and does not require privileged access or unusual timing.

Impact:

  • A seller can claim proceeds even though no buyer paid for the NFT.

  • Protocol-held USDC can be stolen, including funds that should remain reserved for other users.

Proof of Concept

The following PoC can be put to test/NFTDealersTest.t.sol:

function testCanCollectWithoutBuying() public revealed whitelisted {
usdc.mint(address(nftDealers), 10000e6);
uint256 userWithCash_startingBalance = usdc.balanceOf(userWithCash);
vm.startPrank(userWithCash);
usdc.approve(address(nftDealers), 20e6);
nftDealers.mintNft();
nftDealers.list(1, 4000e6);
nftDealers.cancelListing(1);
nftDealers.collectUsdcFromSelling(1);
uint256 userWithCash_endingBalance = usdc.balanceOf(userWithCash);
console.log("userWithCash_startingBalance: %s", userWithCash_startingBalance);
console.log("userWithCash_endingBalance: %s", userWithCash_endingBalance);
console.log("protocolEndingBalance: %s", usdc.balanceOf(address(nftDealers)));
assertGt(userWithCash_endingBalance, userWithCash_startingBalance);
}

Recommended Mitigation

Do not let collectUsdcFromSelling() infer a valid sale from !isActive. Instead, only credit claimable proceeds inside buy(), and let collectUsdcFromSelling() withdraw from a dedicated mapping of amounts already earned by the seller. Since cancelListing() never credits that mapping, a canceled listing cannot be used to claim sale proceeds.

+mapping(uint256 => mapping(address => uint256)) public claimableAmount;
function buy(uint256 _listingId) external payable {
Listing memory listing = s_listings[_listingId];
if (!listing.isActive) revert ListingNotActive(_listingId);
require(listing.seller != msg.sender, "Seller cannot buy their own NFT");
activeListingsCounter--;
bool success = usdc.transferFrom(msg.sender, address(this), listing.price);
require(success, "USDC transfer failed");
_safeTransfer(listing.seller, msg.sender, listing.tokenId, "");
+ claimableAmount[_listingId][listing.seller] += listing.price;
s_listings[_listingId].isActive = false;
emit NFT_Dealers_Sold(msg.sender, listing.price);
}
function collectUsdcFromSelling(uint256 _listingId) external {
Listing memory listing = s_listings[_listingId];
+ require(claimableAmount[_listingId][msg.sender] > 0, "No claimable proceeds");
+ uint256 saleAmount = claimableAmount[_listingId][msg.sender];
+ uint256 fees = _calculateFees(saleAmount);
+ uint256 amountToSeller = saleAmount - fees;
uint256 collateralToReturn = collateralForMinting[listing.tokenId];
+ claimableAmount[_listingId][msg.sender] = 0;
totalFeesCollected += fees;
amountToSeller += collateralToReturn;
usdc.safeTransfer(msg.sender, amountToSeller);
}

Support

FAQs

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

Give us feedback!