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 viavalidate(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 inflatednowCumulativePrice→ TWAP price elevated → collateral value overestimated → protocol allows borrowing more than is collateralised.
Attack Execution
High-Level Flow
- Attacker EOA
0x2eb7c45fcallsattack(200000e18, ...)on attacker contract0xf35ad357. - Attacker contract calls
flash(address(this), 200_000 USDT, 0, ...)on the PancakeSwap V3 pool0x172fcd41, borrowing 200,000 USDT (18 decimals, BSC USDT). - Inside the flash callback
pancakeV3FlashCallback, the attacker: a. Approves and swaps ~5,000 USDT for WBNB on PancakeSwap V2 via0x10ed43c7, receiving WBNB via pair0x16b9a828. b. Transfers ~7.78 WBNB to the AQUA/WBNB PancakeSwap V2 pair0x7cde1a8e. c. Callsswap()on0x7cde1a8edirectly, 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. - Attacker approves and
mints all 8,723,689 AQUA into the Planet AQUA cToken market0xcd221e1504(proxy) → implementation0x62535b49. The oraclevalidate()is called, which now reads the manipulated reserves, inflating the AQUA price TWAP. - Attacker calls
enterMarketson the Comptroller to use both the AQUA cToken and USDT cToken as collateral/borrow markets. - Attacker mints 195,000 USDT into the USDT cToken market
0x045e2df6(proxy) → impl0x2f64203e, providing the lending pool liquidity to borrow against. - Attacker executes a 10-cycle macro loop: each macro-cycle contains 7–9 sub-borrows of USDT from
0x045e2df6, followed by a singlerepayBorrowof the accumulated debt, a tinyredeem, and a freshmintof slightly more USDT.- Each sub-borrow calls
accrueInterest()on both the collateral market (AQUA cToken) and the USDT market, which triggersvalidate()on the oracle (207 times for gUSDT, 1 time for gAQUA = 208 total). The gAQUAvalidate()read the manipulated pair0x7cde1a8eonce and stored the inflated AQUA price; all subsequentgetUnderlyingPrice(gAQUA)calls (654 total) return this stored inflated price from oracle state. - Total unique
borrow()calls: 88; total uniquerepayBorrow()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.
- Each sub-borrow calls
- 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.
- The transaction runs out of gas during the
changeUserBorrowDiscountcall 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:
| Function | Signature | Selector |
|---|---|---|
borrow(uint256) | GToken.sol | 0xc5ebeaec |
repayBorrow(uint256) | GToken.sol | 0x0e752702 |
mint(uint256) | GToken.sol | 0xa0712d68 |
redeem(uint256) | GToken.sol | 0xdb006a75 |
flash(address,uint256,uint256,bytes) | PancakeV3Pool | 0x490e6cbc |
pancakeV3FlashCallback(uint256,uint256,bytes) | AttackerContract | 0xa1d48336 |
validate(address) | Oracle | 0x207c64fb |
Key Call Counts (from decoded_calls.json, 12,924 total calls)
| Operation | Count |
|---|---|
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 Oracle | 208 |
getUnderlyingPrice() on Oracle | 654 |
getAccountLiquidity() on Comptroller | 194 (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 from0xd99c7f6cand0x7cde1a8e - Chainlink pairs:
0xb97ad0e74and0xcf7a2efb2(latestRoundData) — used for USDT pricing only - The oracle reads
getReserves()on the manipulated0x7cde1a8epair during the singlevalidate(gAQUA)call. This stores an inflated AQUA price inprices[AQUA_symbolHash]. All 207 subsequentvalidate(gUSDT)calls and 654getUnderlyingPrice(gAQUA)calls return this cached inflated value from storage without re-reading the AMM pair. SinceblockTimestampLast < blockTimestampfor0x7cde1a8eat 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
0x7cde1a8eonly once (duringvalidate(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 —
balanceOfon0x3ee2200e...returning 32 bytes - Reverted call: idx=12922 —
getAccountSnapshoton0x0be66da1...(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%)
Related URLs
- TenArmor alert: https://twitter.com/TenArmorAlert
- Planet Finance: https://planet.finance/
- BscScan transaction: https://bscscan.com/tx/0x330ccbfa69bc46712dc68b3ada182d104a0240629017394a923e3db6c9313349
- Attacker contract: https://bscscan.com/address/0xf35ad357f4e9ff9ca0fcdcedf848cfd56b155539
- gUSDT market (proxy): https://bscscan.com/address/0x045e2df638ebec29130dd3be61161cba5f00a9c8
- gAQUA market (proxy): https://bscscan.com/address/0xcd221e1504442671671d3330cb8e916a5edc3fc7
- PriceOracle: https://bscscan.com/address/0xe235ee21299b232b13e9a119e553cc5d8a56cd0c