The expected behavior of buy() is that all state changes (marking the listing as inactive) are completed before any external calls are made. This is the Checks-Effects-Interactions (CEI) pattern recommended by the Ethereum documentation and followed by all major OpenZeppelin contracts.
What actually happens is that _safeTransfer() — an external call that triggers onERC721Received() on any contract recipient — fires while s_listings[_listingId].isActive is still true. The isActive = false state update happens only after the external call returns.
During the onERC721Received() callback, a malicious contract buyer can observe and act on the listing as still-active. While the current ERC721 ownership check incidentally prevents a double-buy (the NFT is already at the buyer's address, so a second _transfer from the seller would revert), this protection is coincidental and fragile. Any future addition of a function that reads isActive — such as a flash-loan integration, a lending feature, or a listing price oracle — could be exploited through this open window.
Likelihood:
Triggered any time a contract address purchases an NFT via buy()
No special preconditions required beyond being a contract buyer
Impact:
No direct fund loss is possible today due to the incidental ERC721 ownership guard
However, the broken CEI ordering is a latent vulnerability that can become directly exploitable if the protocol is extended — any new function checking isActive without independent access controls would be re-enterable from inside the callback
The listing state is inconsistent for the duration of the onERC721Received call, which is an invariant violation
Run with:
Expected console output:
Move s_listings[_listingId].isActive = false to before the _safeTransfer call. This closes the callback window and follows CEI correctly.
Why this works: by the time _safeTransfer fires and triggers onERC721Received, the listing is already marked inactive. Any re-entrant call to buy() on the same listing ID will hit revert ListingNotActive, and cancelListing() will also revert on the same check.
The contest is live. Earn rewards by submitting a finding.
This is your time to appeal against judgements on your submissions.
Appeals are being carefully reviewed by our judges.
The contest is complete and the rewards are being distributed.