Bid Beasts

First Flight #49
Beginner FriendlyFoundrySolidityNFT
100 EXP
View results
Submission Details
Impact: low
Likelihood: low
Invalid

Buy-Now Logic Circumvents Anti-Self-Bidding Protection

Description

  • The marketplace implements an anti-self-bidding protection mechanism that prevents the current highest bidder from placing subsequent bids on the same auction. This protection is enforced through a require(msg.sender != previousBidder, "Already highest bidder") check in the regular bidding logic, ensuring that once a user becomes the highest bidder, they cannot bid again until someone else outbids them.

  • The buy-now logic executes before the anti-self-bidding protection check and returns early, effectively bypassing the require(msg.sender != previousBidder) validation. This creates an inconsistent security model where the highest bidder is blocked from regular bidding but can circumvent this restriction by triggering the buy-now functionality. While this may be intended behavior for buy-now purchases, the implementation creates a security bypass that undermines the established anti-self-bidding protection, potentially allowing the highest bidder to manipulate the auction outcome through the buy-now mechanism when they should be restricted from further participation until outbid.

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");
@> // --- Buy Now Logic ---
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;
if (previousBidder != address(0)) {
_payout(previousBidder, previousBidAmount);
}
_executeSale(tokenId);
if (overpay > 0) {
_payout(msg.sender, overpay);
}
return;
}
@> require(msg.sender != previousBidder, "Already highest bidder");
emit AuctionSettled(tokenId, msg.sender, listing.seller, msg.value);
// --- Regular Bidding Logic...
}

Risk

Likelihood:

  • This vulnerability will occur whenever the current highest bidder attempts to trigger the buy-now functionality by sending ETH equal to or exceeding the buy-now price, as the buy-now logic executes before the anti-self-bidding protection check and returns early.

Impact:

  • The highest bidder can circumvent the intended anti-self-bidding protection mechanism, creating an inconsistent security model where regular bidding restrictions are bypassed through the buy-now pathway, undermining the contract's security design.

Proof of Concept

  1. Mint NFT

  2. List NFT

  3. Bidder 1 makes first bid becoming previous bidder

  4. Bidder 1 triggers buy now block by placing subsequent bid over buy now price bypassing anti-self-bidding protection mechanism

function test_previousBidderCanBuyNow() public {
vm.startPrank(OWNER);
nft.mint(SELLER);
vm.stopPrank();
vm.startPrank(SELLER);
nft.approve(address(market), TOKEN_ID);
market.listNFT(TOKEN_ID, MIN_PRICE, BUY_NOW_PRICE);
vm.stopPrank();
vm.startPrank(BIDDER_1);
market.placeBid{value: MIN_PRICE+1 ether}(TOKEN_ID);
(address previousBidder, ) = market.bids(TOKEN_ID);
vm.startPrank(BIDDER_1);
market.placeBid{value: BUY_NOW_PRICE+1 ether}(TOKEN_ID);
assertEq(previousBidder, nft.ownerOf(TOKEN_ID));
}

Recommended Mitigation

Move require(msg.sender != previousBidder, "Already highest bidder"); from after the auction end check but before the buy-now logic. This ensures the highest bidder restriction applies to both regular bidding and buy-now functionality, preventing the security bypass.

- remove this code
+ add this code
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");
+ require(msg.sender != previousBidder, "Already highest bidder");
// --- Buy Now Logic ---
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;
if (previousBidder != address(0)) {
_payout(previousBidder, previousBidAmount);
}
_executeSale(tokenId);
if (overpay > 0) {
_payout(msg.sender, overpay);
}
return;
}
- require(msg.sender != previousBidder, "Already highest bidder");
emit AuctionSettled(tokenId, msg.sender, listing.seller, msg.value);
// --- Regular Bidding Logic ---
uint256 requiredAmount;
if (previousBidAmount == 0) {
requiredAmount = listing.minPrice;
require(msg.value > requiredAmount, "First bid must be > min price");
listing.auctionEnd = block.timestamp + S_AUCTION_EXTENSION_DURATION;
emit AuctionExtended(tokenId, listing.auctionEnd);
} else {
requiredAmount = (previousBidAmount / 100) * (100 + S_MIN_BID_INCREMENT_PERCENTAGE);
require(msg.value >= requiredAmount, "Bid not high enough");
uint256 timeLeft = 0;
if (listing.auctionEnd > block.timestamp) {
timeLeft = listing.auctionEnd - block.timestamp;
}
if (timeLeft < S_AUCTION_EXTENSION_DURATION) {
listing.auctionEnd = listing.auctionEnd + S_AUCTION_EXTENSION_DURATION;
emit AuctionExtended(tokenId, listing.auctionEnd);
}
}
bids[tokenId] = Bid(msg.sender, msg.value);
if (previousBidder != address(0)) {
_payout(previousBidder, previousBidAmount);
}
emit BidPlaced(tokenId, msg.sender, msg.value);
}
Updates

Lead Judging Commences

cryptoghost Lead Judge 2 months ago
Submission Judgement Published
Invalidated
Reason: Design choice

Support

FAQs

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

Give us feedback!