Planet Finance — Failed Oracle Manipulation Attack (BSC)

On 2026-03-11, a failed attempt was made to exploit Planet Finance, a Compound-fork lending protocol on BNB Smart Chain, via an oracle price manipulation attack. Transaction 0x330ccbfa... was initiated by attacker EOA 0x2eb7c45f but reverted with status 0x0, consuming 38,751,495 of 40,000,000 gas (96.9%), apparently due to out-of-gas before the exploit could complete. No funds were lost; the attack was unsuccessful. The intended mechanism was to inflate the AQUA token’s oracle-reported price by manipulating a PancakeSwap V2 AMM pair’s reserves in the same transaction, use the inflated AQUA collateral to borrow USDT far above its true value, and drain the protocol’s USDT lending market.


Root Cause

Vulnerable Contract

  • Contract: UniswapAnchoredView (PriceOracle)
  • Address: 0xe235ee21299b232b13e9a119e553cc5d8a56cd0c
  • Proxy: No (direct deployment)
  • Source type: Verified (0xe235ee21299b232b13e9a119e553cc5d8a56cd0c/UniswapAnchoredView.sol)

The oracle is used by the Gammatroller (Comptroller) for every collateral/borrow liquidity check, and by accrueInterest() on every market interaction.

Vulnerable Function

  • Function: fetchAnchorPrice(bytes32 symbolHash, TokenConfig memory config, uint conversionFactor) → called indirectly via validate(address gToken)
  • Selector (validate): 0x207c64fb
  • File: UniswapAnchoredView.sol

For markets configured with PriceSource.UNISWAP, validate() calls fetchAnchorPrice()pokeWindowValues()currentCumulativePrices() (from UniswapLib.sol), which computes the counterfactual cumulative price using live reserves from the AMM pair.

Vulnerable Code

// UniswapLib.sol — UniswapV2OracleLibrary
function currentCumulativePrices(
    address pair
) internal view returns (uint price0Cumulative, uint price1Cumulative, uint32 blockTimestamp) {
    blockTimestamp = currentBlockTimestamp();
    price0Cumulative = IUniswapV2Pair(pair).price0CumulativeLast();
    price1Cumulative = IUniswapV2Pair(pair).price1CumulativeLast();

    (uint112 reserve0, uint112 reserve1, uint32 blockTimestampLast) = IUniswapV2Pair(pair).getReserves();
    if (blockTimestampLast != blockTimestamp) {
        uint32 timeElapsed = blockTimestamp - blockTimestampLast;
        // counterfactual — uses CURRENT reserves                         // <-- VULNERABILITY
        price0Cumulative += uint(FixedPoint.fraction(reserve1, reserve0)._x) * timeElapsed;
        price1Cumulative += uint(FixedPoint.fraction(reserve0, reserve1)._x) * timeElapsed;
    }
}

The fetchAnchorPrice function then computes the TWAP:

// UniswapAnchoredView.sol — fetchAnchorPrice
function fetchAnchorPrice(bytes32 symbolHash, TokenConfig memory config, uint conversionFactor)
    internal virtual returns (Error, uint)
{
    (uint nowCumulativePrice, uint oldCumulativePrice, uint oldTimestamp) = pokeWindowValues(config);
    // ...
    uint timeElapsed = block.timestamp - oldTimestamp;
    // TWAP over [oldObservation, now]
    FixedPoint.uq112x112 memory priceAverage =
        FixedPoint.uq112x112(uint224((nowCumulativePrice - oldCumulativePrice) / timeElapsed)); // <-- VULNERABILITY
    uint rawUniswapPriceMantissa = priceAverage.decode112with18();
    // ... scaled to USD
}

Why It’s Vulnerable

Expected behavior: The PriceSource.UNISWAP configuration is intended to use a time-weighted average price (TWAP) that smooths out short-term price spikes and resists single-block manipulation. A genuine TWAP should require the manipulated price to be sustained over a period equal to anchorPeriod before it influences the computed price.

Actual behavior: currentCumulativePrices() computes the nowCumulativePrice as a counterfactual: it takes the last stored cumulative price and adds fraction(reserve1, reserve0) * timeElapsed, where reserve0/reserve1 are the current live reserves read at the moment of the oracle call. If a large swap is executed in the same transaction just before the oracle is read, the current reserves reflect the post-swap state. The counterfactual then propagates that manipulated reserve ratio into the cumulative price accumulator for the current block’s time-slice, shifting the TWAP immediately rather than gradually.

Put simply: any swap that changes the AMM reserves before validate() is called in the same transaction partially corrupts the TWAP by incorporating the manipulated reserves into the counterfactual component. The larger the time since the last genuine on-chain price update (blockTimestampLast), the larger the weight of the attacker-controlled counterfactual term.

  • Normal flow: Reserves change slowly across many blocks; TWAP smoothly reflects fair market price.
  • Attack flow: Attacker executes a large swap in the same transaction → reserves skewed → currentCumulativePrices() returns counterfactually inflated nowCumulativePrice → TWAP price elevated → collateral value overestimated → protocol allows borrowing more than is collateralised.

Attack Execution

High-Level Flow

  1. Attacker EOA 0x2eb7c45f calls attack(200000e18, ...) on attacker contract 0xf35ad357.
  2. Attacker contract calls flash(address(this), 200_000 USDT, 0, ...) on the PancakeSwap V3 pool 0x172fcd41, borrowing 200,000 USDT (18 decimals, BSC USDT).
  3. Inside the flash callback pancakeV3FlashCallback, the attacker: a. Approves and swaps ~5,000 USDT for WBNB on PancakeSwap V2 via 0x10ed43c7, receiving WBNB via pair 0x16b9a828. b. Transfers ~7.78 WBNB to the AQUA/WBNB PancakeSwap V2 pair 0x7cde1a8e. c. Calls swap() on 0x7cde1a8e directly, receiving ~8,723,689 AQUA tokens (0xb3cb6d2f). This inflates WBNB reserves and deflates AQUA reserves in the pair, making AQUA appear more expensive per WBNB.
  4. Attacker approves and mints all 8,723,689 AQUA into the Planet AQUA cToken market 0xcd221e1504 (proxy) → implementation 0x62535b49. The oracle validate() is called, which now reads the manipulated reserves, inflating the AQUA price TWAP.
  5. Attacker calls enterMarkets on the Comptroller to use both the AQUA cToken and USDT cToken as collateral/borrow markets.
  6. Attacker mints 195,000 USDT into the USDT cToken market 0x045e2df6 (proxy) → impl 0x2f64203e, providing the lending pool liquidity to borrow against.
  7. Attacker executes a 10-cycle macro loop: each macro-cycle contains 7–9 sub-borrows of USDT from 0x045e2df6, followed by a single repayBorrow of the accumulated debt, a tiny redeem, and a fresh mint of slightly more USDT.
    • Each sub-borrow calls accrueInterest() on both the collateral market (AQUA cToken) and the USDT market, which triggers validate() on the oracle (207 times for gUSDT, 1 time for gAQUA = 208 total). The gAQUA validate() read the manipulated pair 0x7cde1a8e once and stored the inflated AQUA price; all subsequent getUnderlyingPrice(gAQUA) calls (654 total) return this stored inflated price from oracle state.
    • Total unique borrow() calls: 88; total unique repayBorrow() calls: 10.
    • The sub-borrow amounts decrease geometrically per round (e.g., 91,642 → 57,925 → 6,832 → 805 → … → 1.32 USDT), draining the available liquidity bounded by the Comptroller’s liquidity check on the inflated collateral.
  8. The attacker intends to hold the accumulated net-positive USDT debt position and exit without repaying (or repay flash loan fee only), pocketing the difference. The final repay amounts grow per cycle (155,999 → 157,050 → … → 170,145 USDT), suggesting each cycle adds slightly more USDT to the attacker’s net position.
  9. The transaction runs out of gas during the changeUserBorrowDiscount call in macro-cycle 11 at call index 12,923 — specifically when iterating all 13 lending markets to update discount factors — and reverts. All state changes are rolled back.

Detailed Call Trace

[depth=0] EOA 0x2eb7c45f
  CALL [depth=1] 0xf35ad357 :: attack(200000e18, 5000e18, 5000e18, 85864846) [0x29b62c2c]
    CALL [depth=2] 0x172fcd41 (PancakeV3Pool) :: flash(0xf35ad357, 200000e18, 0, ...) [0x490e6cbc]
      STATICCALL [depth=3] 0x55d398326 (USDT) :: balanceOf(0x172fcd41)
      STATICCALL [depth=3] 0xbb4cdb9 (WBNB) :: balanceOf(0x172fcd41)
      CALL [depth=3] 0x55d398326 (USDT) :: transfer(0xf35ad357, 200000e18)  -- flash loan delivered
      CALL [depth=3] 0xf35ad357 :: pancakeV3FlashCallback(fee0=20e18, 0, ...) [0xa1d48336]

        -- Swap USDT -> WBNB via PancakeSwap V2 --
        CALL [depth=4] 0x55d398326 (USDT) :: approve(PancakeRouter, 5000e18)
        CALL [depth=4] 0x10ed43c7 (PancakeV2Router) :: swapExactTokensForTokens(5000e18 USDT, 0, [USDT->WBNB], 0xf35ad357, ...)
          STATICCALL [depth=5] 0x16b9a82 (USDT/WBNB pair) :: getReserves()
          CALL [depth=5] 0x55d398326 :: transferFrom(0xf35ad357 -> 0x16b9a82, 5000e18)
          CALL [depth=5] 0x16b9a82 :: swap(0, 7.78e18 WBNB, 0xf35ad357, "")
            CALL [depth=6] 0xbb4cdb9 (WBNB) :: transfer(0xf35ad357, 7.78e18)

        -- Direct manipulation: WBNB -> 8.72M AQUA on 0x7cde1a8e pair --
        STATICCALL [depth=4] 0xbb4cdb9 :: balanceOf(0xf35ad357)
        STATICCALL [depth=4] 0x7cde1a8e (AQUA/WBNB pair) :: getReserves()
        CALL [depth=4] 0xbb4cdb9 :: transfer(0x7cde1a8e, 7.78e18 WBNB)
        CALL [depth=4] 0x7cde1a8e :: swap(8723689.4e18 AQUA, 0, 0xf35ad357, "")  -- AQUA price manipulated
          CALL [depth=5] 0xb3cb6d2f (AQUA) :: transfer(0xf35ad357, 8723689.4e18)

        -- Mint AQUA as collateral into AQUA cToken market --
        CALL [depth=4] 0xb3cb6d2f :: approve(0xcd221e15, 8723689.4e18)
        CALL [depth=4] 0xcd221e15 (gAQUA Proxy) :: mint(8723689.4e18) [0xa0712d68]
          DELEGATECALL 0xcd221e15 -> 0x62535b49 :: mint(8723689.4e18)
            STATICCALL -> Comptroller :: getOracle()
            CALL -> 0xe235ee21 (Oracle) :: validate(0xcd221e15)  -- reads manipulated pair
              STATICCALL -> 0xd99c7f6c :: price0CumulativeLast, price1CumulativeLast, getReserves()
              STATICCALL -> 0x7cde1a8e :: price0CumulativeLast, price1CumulativeLast, getReserves()
                                                               -- counterfactual uses manipulated reserves
            CALL -> Comptroller :: mintAllowed(...)

        -- Enter markets --
        CALL [depth=4] Comptroller 0x1e0c9d09 :: enterMarkets([0xcd221e15, 0x045e2df6])

        -- Mint USDT into USDT cToken market (initial position) --
        CALL [depth=4] 0x55d39832 (USDT) :: approve(0x045e2df6, MaxUint)
        CALL [depth=4] 0x045e2df6 (gUSDT Proxy) :: mint(195000e18) [0xa0712d68]
          DELEGATECALL -> 0x2f64203e :: mint(195000e18)
            CALL -> Oracle :: validate(0x045e2df6)  -- Chainlink + TWAP update for USDT

        -- *** Main borrow-repay loop (10 macro-cycles, 88 total borrows) ***
        [Cycle 1: 7 sub-borrows then repay]
        CALL [depth=4] Comptroller :: getAccountLiquidity(0xf35ad357)  -- verify liquidity
        CALL [depth=4] 0x045e2df6 :: getCash(); totalReserves()
        CALL [depth=4] 0x045e2df6 :: borrow(91642e18)  [0xc5ebeaec]
          DELEGATECALL -> 0x2f64203e :: borrow(91642e18)
            CALL -> Oracle :: validate(0x045e2df6)  -- TWAP reread, still manipulated
            CALL -> Comptroller :: borrowAllowed(0x045e2df6, 0xf35ad357, 91642e18)
              CALL -> Oracle :: getUnderlyingPrice(gAQUA)  -- inflated price
              CALL -> Oracle :: getUnderlyingPrice(gUSDT)
              getAccountSnapshot(0xf35ad357) for all markets in portfolio
            CALL 0xb3cb6d2f :: balanceOf(0x045e2df6); transfer(0xf35ad357, 91642e18)  -- USDT sent
        CALL [depth=4] 0x045e2df6 :: updateUserDiscount  -- reward accounting
        ... [5 more sub-borrows, geometrically decreasing amounts] ...
        CALL [depth=4] 0x045e2df6 :: repayBorrow(155999.8e18)  [0x0e752702]
        CALL [depth=4] 0x045e2df6 :: redeem(tiny gToken amount)
        CALL [depth=4] 0x045e2df6 :: mint(196313.4e18)  -- re-collateralize with profit
        [Cycles 2-10: same pattern with growing repay amounts]
        ... [10th cycle completes repayBorrow(170145.7e18) and redeem/remint] ...

        -- Cycle 11: borrow(8726.9e18), borrow(8726.9e18), borrow(8028.7e18) ...
        CALL [depth=4] 0x045e2df6 :: borrow(8726.9e18)
          CALL -> 0x9de8ca41 :: updateUserDiscount (iterates 13 markets)
            STATICCALL -> 0x0be66da1 :: getAccountSnapshot  -- REVERT (out of gas)
        <-- TRANSACTION REVERTS HERE (OOG at index 12923, depth 8)

Financial Impact

The transaction reverted; no funds were transferred and no ERC-20 Transfer events were emitted. From funds_flow.json:

  • Attacker gains: None (empty — "summary": "No attacker gains detected")
  • Attacker loss: Gas cost only — the attacker EOA spent approximately 0.039 BNB (38,751,495 gas × 1 Gwei gas price) on the failed transaction.
  • Protocol impact: Zero. All state changes were rolled back on revert.

Intended profit (had the attack succeeded): The attacker’s strategy across 10 repay-cycles shows net-positive USDT accumulation. The growing repay amounts (155,999 → 170,145 USDT) against a flash loan principal of 200,000 USDT + 20 USDT fee suggest the attacker aimed to extract approximately 50,000–93,000 USDT net from the USDT lending pool. TenArmor’s alert estimated ~$93,000 in potential losses, consistent with the attack’s design intent.


Evidence

Transaction & Receipt

  • Tx hash: 0x330ccbfa69bc46712dc68b3ada182d104a0240629017394a923e3db6c9313349
  • Block: 85,864,846 (BSC mainnet)
  • Status: 0x0 (REVERTED)
  • Gas used / limit: 38,751,495 / 40,000,000 (96.9%)
  • Receipt logs: Empty — confirms revert rolled back all state

Selector Verification

Verified with cast sig:

FunctionSignatureSelector
borrow(uint256)GToken.sol0xc5ebeaec
repayBorrow(uint256)GToken.sol0x0e752702
mint(uint256)GToken.sol0xa0712d68
redeem(uint256)GToken.sol0xdb006a75
flash(address,uint256,uint256,bytes)PancakeV3Pool0x490e6cbc
pancakeV3FlashCallback(uint256,uint256,bytes)AttackerContract0xa1d48336
validate(address)Oracle0x207c64fb

Key Call Counts (from decoded_calls.json, 12,924 total calls)

OperationCount
borrow() on gUSDT (depth 3)88
repayBorrow() on gUSDT (depth 3)10
redeem() on gUSDT (depth 3)10
mint() on gUSDT (depth 3)11 (initial + 10 re-mints)
validate() on Oracle208
getUnderlyingPrice() on Oracle654
getAccountLiquidity() on Comptroller194 (97 proxy + 97 impl via DELEGATECALL)

Oracle Manipulation Mechanics

  • Target pair: 0x7cde1a8ee90e7b03fbd554dfea9c341326719f0f (AQUA/WBNB PancakeSwap V2)
  • Swap executed: 7.78 WBNB → 8,723,689 AQUA (direct swap() call)
  • Oracle type for AQUA market: PriceSource.UNISWAP — uses DEX pair TWAPs from 0xd99c7f6c and 0x7cde1a8e
  • Chainlink pairs: 0xb97ad0e74 and 0xcf7a2efb2 (latestRoundData) — used for USDT pricing only
  • The oracle reads getReserves() on the manipulated 0x7cde1a8e pair during the single validate(gAQUA) call. This stores an inflated AQUA price in prices[AQUA_symbolHash]. All 207 subsequent validate(gUSDT) calls and 654 getUnderlyingPrice(gAQUA) calls return this cached inflated value from storage without re-reading the AMM pair. Since blockTimestampLast < blockTimestamp for 0x7cde1a8e at the time of the gAQUA validate, the counterfactual TWAP computation incorporated the attacker-skewed reserve ratio.

Note: Call flow derived from on-chain trace. The oracle reads the manipulated pair 0x7cde1a8e only once (during validate(gAQUA) at trace index 31–33). The persistent price impact comes from the stored oracle state, not from repeated AMM reads.

Revert Location

  • Last call before revert: idx=12921 — balanceOf on 0x3ee2200e... returning 32 bytes
  • Reverted call: idx=12922 — getAccountSnapshot on 0x0be66da1... (PlanetCToken Market12) returning 0 bytes
  • Called from: 0x9de8ca41 (PlanetAuxiliary_1) changeUserBorrowDiscount → iterates 13 markets
  • Root cause of revert: Out of gas — 38,751,495 / 40,000,000 gas consumed (96.9%)