NFT Dealers

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

Reentrancy in buy()

Author Revealed upon completion

Reentrancy in buy() allows external callbacks to observe stale listing state, causing inconsistent marketplace accounting

Risk

  • Impact: Low

  • Likelihood: Medium

Description:

The buy() function performs external interactions before fully finalizing the listing state in storage.

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, "");
s_listings[_listingId].isActive = false;
emit NFT_Dealers_Sold(msg.sender, listing.price);
}

The root cause is that activeListingsCounter is decremented before the external calls, while s_listings[_listingId].isActive is only set to false after usdc.transferFrom(...) and _safeTransfer(...) complete.

This violates the Checks-Effects-Interactions pattern.

Because _safeTransfer(...) may invoke onERC721Received(...) when the buyer is a contract, external code can execute while the protocol is in an inconsistent intermediate state.

During that callback, the following condition can hold simultaneously:

  • totalActiveListings() == 0

  • s_listings[_listingId].isActive == true

This creates a real reentrancy window where external code can observe stale listing state before the purchase flow is fully finalized.

Impact:

No direct theft of funds was demonstrated from this issue alone.

However, the issue causes inconsistent marketplace state during external execution:

  • the active listing counter is already decremented

  • the corresponding listing still remains active in storage

This can break assumptions made by listing-dependent logic and increases the attack surface for cross-function reentrancy and unsafe state observation.

Since the demonstrated PoC does not show direct or indirect fund loss, the most appropriate classification is:

Likelihood is medium because exploitation requires a contract buyer capable of receiving the NFT through _safeTransfer(...) and executing logic in onERC721Received(...), but the reentrancy surface is real and reproducible.

Proof of Concept:

A malicious buyer contract implementing onERC721Received(...) was used to observe protocol state during _safeTransfer(...).

The test setup:

  1. A seller lists token #1

  2. A malicious buyer contract calls buy(1)

  3. During _safeTransfer(...), the buyer’s onERC721Received(...) callback reads:

    • totalActiveListings()

    • s_listings(1).isActive

PoC test:

function test_Buy_ReentrancyShowsInconsistentListingState() public {
assertEq(nftDealers.totalActiveListings(), 1);
(, , , , bool isActiveBefore) = nftDealers.s_listings(1);
assertTrue(isActiveBefore);
maliciousBuyer.buyListedNft(1, NFT_PRICE);
assertTrue(maliciousBuyer.callbackTriggered());
// State observed during onERC721Received()
assertEq(maliciousBuyer.observedActiveListings(), 0);
assertTrue(maliciousBuyer.observedIsActive());
// Final state after buy() completes
(, , , , bool isActiveAfter) = nftDealers.s_listings(1);
assertFalse(isActiveAfter);
assertEq(nftDealers.totalActiveListings(), 0);
assertEq(nftDealers.ownerOf(1), address(maliciousBuyer));
}

Observed execution:

  1. Before buy(1)

    • totalActiveListings() = 1

    • s_listings(1).isActive = true

  2. During onERC721Received(...)

    • totalActiveListings() = 0

    • s_listings(1).isActive = true

  3. After buy() finishes

    • totalActiveListings() = 0

    • s_listings(1).isActive = false

Illustrative output:

=== BEFORE BUY ===
totalActiveListings: 1
listing.isActive: true
=== DURING CALLBACK (observed by malicious buyer) ===
observedActiveListings: 0
observedIsActive: true
=== AFTER BUY ===
totalActiveListings: 0
listing.isActive: false
new owner: 0xF62849F9A0B5Bf2913b396098F7c7019b51A820a

This confirms that external code executes while the protocol exposes stale listing state, validating the reentrancy surface reported by Slither.

Recommended Mitigation:

Apply Checks-Effects-Interactions by fully closing the listing before making any external calls.

A safer implementation is:

function buy(uint256 _listingId) external payable nonReentrant {
Listing memory listing = s_listings[_listingId];
if (!listing.isActive) revert ListingNotActive(_listingId);
require(listing.seller != msg.sender, "Seller cannot buy their own NFT");
s_listings[_listingId].isActive = false;
activeListingsCounter--;
bool success = usdc.transferFrom(msg.sender, address(this), listing.price);
require(success, "USDC transfer failed");
_safeTransfer(listing.seller, msg.sender, listing.tokenId, "");
emit NFT_Dealers_Sold(msg.sender, listing.price);
}

Additionally, using nonReentrant provides defense in depth against future reentrancy paths.

Support

FAQs

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

Give us feedback!