All incidents

Platypus LP Cross-Asset Mispricing

Share
Jul 11, 2023 16:54 UTCAttackLoss: 5,837.32 USDCPending manual check1 exploit txWindow: Atomic
Estimated Impact
5,837.32 USDC
Label
Attack
Exploit Tx
1
Addresses
3
Attack Window
Atomic
Jul 11, 2023 16:54 UTC → Jul 11, 2023 16:54 UTC

Exploit Transactions

TX 1Avalanche
0x4b544e5ffb0420977dacb589a6fb83e25347e0685275a3327ee202449b3bfac6
Jul 11, 2023 16:54 UTCExplorer

Victim Addresses

0xbe52548488992cc76ffa1b42f3a58f646864df45Avalanche
0x06f01502327de1c37076bea4689a7e44279155e9Avalanche
0xdea7bf752ef25301dbb2e9288338a1a9013ec194Avalanche

Loss Breakdown

5,837.32USDC

Similar Incidents

Root Cause Analysis

Platypus LP Cross-Asset Mispricing

1. Incident Overview TL;DR

Platypus Finance was exploited on Avalanche in block 32470737 by transaction 0x4b544e5ffb0420977dacb589a6fb83e25347e0685275a3327ee202449b3bfac6. The transaction was adversary-crafted, included from EOA 0xc64afc460290ed3df848f378621b96cb7179521a, and routed execution through attacker contract 0x16a3c9e492dee1503f46dea84c52c6a0608f1ed8. The path was permissionless: public working capital, a public Aave V3 USDC flash loan, public Platypus pool and asset contracts, and no privileged roles or private orderflow. The attacker contract started with 24333340760 raw USDC units, borrowed 85000000000 more, deposited the full 109333340760 into LP-USDC, withdrew USDT.e from the same aggregate account via withdrawFromOtherAsset, swapped that USDT.e back to USDC, repaid Aave 85042500000 raw USDC units, and finished with 30128157738 raw USDC units. The net profit predicate was therefore 5794816978 raw USDC units on the attacker contract, while gas was paid separately by the EOA in 0.0253052525 AVAX.

The root cause is a share-accounting bug in the historical Platypus pool implementation at 0x7e1333a39abed9a5664661957b80ba01d2702b1e. withdrawFromOtherAsset treated LP balances from different assets as interchangeable by decimals only, even though each asset had a different liability-per-share exchange rate. _deposit compounded the issue by minting extra LP when the pool was globally under-covered. That let cheap LP-USDC shares redeem too much LP-USDT.e value and leak value out of the pool.

2. Key Background

Platypus asset accounting depends on three variables per asset: cash, liability, and LP totalSupply. An LP token's economic value is not just its decimals; it is the asset's liability-per-share ratio, liability / totalSupply. Cross-asset conversion is only safe if the protocol normalizes between those per-asset exchange rates.

The historical Platypus design also rewarded deposits into an under-covered system. When the global equilibrium coverage ratio dropped below 1e18, _deposit scaled LP mint output upward with 1 / eqCov, so an under-covered asset could mint more LP units than the liability added by the deposit.

The exploit required two assets in the same aggregate account. LP-USDC at 0x06f01502327de1c37076bea4689a7e44279155e9 and LP-USDT.e at 0xdea7bf752ef25301dbb2e9288338a1a9013ec194 both resolved to aggregate account 0x1655E447B7281E014E54Cf0c1aD976b006E2B3DC, which made withdrawFromOtherAsset available between them. The seed trace also shows LP-USDC was under-covered while LP-USDT.e remained withdrawable, which is the exact condition that made the value mismatch exploitable.

3. Vulnerability Analysis & Root Cause Summary

This incident is an ATTACK, not a pure MEV arbitrage, because the protocol's accounting allowed the attacker to withdraw more liability-equivalent value than it burned. The core bug sits in historical Pool::withdrawFromOtherAsset, which converted LP between assets using decimals instead of each asset's liability-per-share exchange rate. That meant LP minted cheaply in one asset could be spent as if it were equally valuable LP in a better-covered asset. Historical _deposit made the mispricing larger by over-minting LP whenever the pool-wide equilibrium coverage ratio was below 1e18. The invariant that should have held was straightforward: the liability burned on the initial asset must match the liability-equivalent value that authorizes the wanted-asset withdrawal. Instead, the protocol burned only LP-USDC liability while pricing the withdrawal from LP-USDT.e's own share ratio. The trace-backed outcome was a one-transaction extraction of USDT.e value that was later converted back to USDC and retained as profit.

4. Detailed Root Cause Analysis

4.1 Invariant and breakpoint

The violated invariant was:

// Cross-asset withdrawals must conserve liability-equivalent value.
burnedInitialAssetLiability == wantedAssetLiabilityUsedForWithdrawal;

The historical pool logic, as summarized from the verified implementation referenced in the collected evidence, broke that invariant at the conversion step:

// Historical Pool logic
liquidity = amount * initialAsset.totalSupply() / initialAsset.liability();
if (eqCov < 1e18) {
    liquidity = liquidity.wdiv(eqCov);
}

liquidityInInitialAssetDP =
    liquidity * 10**initialAsset.decimals() / 10**wantedAsset.decimals();

burnedLiability =
    initialAsset.liability() * liquidityInInitialAssetDP / initialAsset.totalSupply();

withdrawnAmount = _withdrawFrom(wantedAsset, liquidity);

The bug is the mismatch between burnedLiability, which is computed from the initial asset, and withdrawnAmount, which is priced from the wanted asset's own accounting.

4.2 Pre-state and why the exploit window existed

The seed trace immediately before the deposit path shows the exact state that made the exploit possible:

Asset::totalSupply() -> 920270929028      // LP-USDC
Asset::liability()   -> 873821945995
Asset::cash()        -> 689203949727

Asset::totalSupply() -> 428491997999      // LP-USDT.e
Asset::liability()   -> 428470175659
Asset::cash()        -> 562535689285

Independent Avalanche RPC validation at the fork point also confirms:

PlatypusPool::getEquilibriumCoverageRatio() = 976486188780554184
LP-USDC aggregateAccount() = 0x1655E447B7281E014E54Cf0c1aD976b006E2B3DC
LP-USDT.e aggregateAccount() = 0x1655E447B7281E014E54Cf0c1aD976b006E2B3DC

Those numbers matter because LP-USDC was under-covered and globally the pool was below equilibrium, so _deposit minted inflated LP-USDC. LP-USDT.e, by contrast, still had enough coverage to satisfy the post-withdrawal checks.

4.3 The value leak in the exploit transaction

The transaction trace shows the whole leak in one path:

Pool::deposit(USDC, 109333340760, attacker, ...)
emit Deposit(... amount: 109333340760, liquidity: 117917773047, ...)

Pool::withdrawFromOtherAsset(USDC, USDT.e, 117917773047, 0, attacker, ...)
Asset::removeLiability(111667061818)
Asset::transferUnderlyingToken(attacker, 115240655531)
emit Withdraw(... amount: 115240655531, liquidity: 117917773047, ...)

Pool::swap(USDT.e, USDC, 115240655531, 0, attacker, ...)
emit Swap(... fromAmount: 115240655531, toAmount: 115170657738, ...)

The deposit leg added only 109333340760 liability units to LP-USDC but minted 117917773047 LP-USDC units because the pool was under-covered. The withdrawal leg then treated those 117917773047 LP-USDC units as 117917773047 units of LP-USDT.e withdrawal liquidity because both assets used 6 decimals. The protocol therefore transferred 115240655531 raw USDT.e units out of LP-USDT.e while removing only 111667061818 raw liability units from LP-USDC. That gap is the protocol loss mechanism.

The supporting asset implementation shows that the asset contracts themselves simply executed whatever accounting the pool instructed:

function transferUnderlyingToken(address to, uint256 amount) external onlyPool {
    IERC20(_underlyingToken).safeTransfer(to, amount);
}

function removeCash(uint256 amount) external onlyPool {
    require(_cash >= amount, "PTL:INSUFFICIENT_CASH");
    _cash -= amount;
}

function removeLiability(uint256 amount) external onlyPool {
    require(_liability >= amount, "PTL:INSUFFICIENT_LIABILITY");
    _liability -= amount;
}

That is why the code-level breakpoint belongs in the pool, not the asset proxies.

4.4 Exploit conditions and violated security principles

The ACT window required four concrete conditions:

  1. The attacker needed two assets in the same aggregate account.
  2. The initial asset's LP share price needed to be cheaper than the wanted asset's LP share price.
  3. The wanted asset still needed to satisfy the coverage threshold after the withdrawal.
  4. Public capital sourcing, flash loans, deposit, withdrawFromOtherAsset, and unwind had to remain permissionless.

Those conditions were all met in the incident. The security principles violated were equally concrete: cross-asset share accounting must normalize by per-asset exchange rate, under-coverage mint incentives must not double as cross-asset redemption rights, and aggregate-account withdrawals must conserve liability value.

5. Adversary Flow Analysis

The adversary strategy was a single-transaction, flashloan-assisted share-misvaluation attack. The adversary cluster consisted of EOA 0xc64afc460290ed3df848f378621b96cb7179521a, which submitted the transaction, and attacker contract 0x16a3c9e492dee1503f46dea84c52c6a0608f1ed8, which performed the flash loan, Platypus interactions, repayment, and profit capture. The primary victim-side contracts were the Platypus pool proxy 0xbe52548488992cc76ffa1b42f3a58f646864df45, LP-USDC asset proxy 0x06f01502327de1c37076bea4689a7e44279155e9, and LP-USDT.e asset proxy 0xdea7bf752ef25301dbb2e9288338a1a9013ec194.

The execution flow had three stages:

  1. Public working capital and flash loan. The attacker contract began with 24333340760 raw USDC units and then borrowed 85000000000 raw USDC units from Aave V3, giving it 109333340760 raw USDC units available for the exploit.
  2. Mint cheap LP-USDC. The attacker deposited the full 109333340760 raw USDC units into Platypus and received 117917773047 LP-USDC units because _deposit scaled the mint output by 1 / eqCov in an under-covered pool.
  3. Redeem LP-USDC as LP-USDT.e and unwind. The attacker called withdrawFromOtherAsset(USDC, USDT.e, 117917773047, ...), which burned only 111667061818 LP-USDC liability yet released 115240655531 raw USDT.e units. It then swapped those 115240655531 raw USDT.e units back into 115170657738 raw USDC units, repaid 85042500000 raw USDC units to Aave, and retained the difference.

The inclusion feasibility was fully permissionless. Every primitive used in the transaction was public, and the trace shows no privileged role, no private calldata dependency, and no attacker-only helper beyond the attacker's own deployed contract.

6. Impact & Losses

The measurable protocol loss was borne by LP-USDC. The collector balance diff shows LP-USDC's USDC balance dropped from 689203949727 to 683366632749, a loss of 5837316978 raw USDC units (5837.316978 USDC). Of that extracted value, 5794816978 raw USDC units (5794.816978 USDC) remained on the attacker contract and 42500000 raw USDC units (42.5 USDC) were paid as the Aave flash-loan premium. The sender EOA separately paid 25305252500000000 wei (0.0253052525 AVAX) in gas.

In practical terms, the exploit worsened LP-USDC's already under-covered state by letting the attacker redeem better-priced LP-USDT.e value while underpaying in LP-USDC liability terms. The loss was immediate, single-transaction, and fully realized on-chain.

7. References

  1. Seed transaction 0x4b544e5ffb0420977dacb589a6fb83e25347e0685275a3327ee202449b3bfac6 metadata, trace, and balance diff under the collector seed artifacts.
  2. Historical Platypus pool implementation reference at 0x7e1333a39abed9a5664661957b80ba01d2702b1e.
  3. Historical Platypus asset implementation at 0xb00c1f6ad87a832e39d15581fcd7ca39edf3a9c7, especially transferUnderlyingToken, removeCash, and removeLiability.
  4. Avalanche RPC validation of getEquilibriumCoverageRatio(), cash(), liability(), totalSupply(), and aggregateAccount() at the exploit fork point.