All incidents

Platypus Stale Collateral Withdrawal

Share
Feb 16, 2023 19:16 UTCAttackLoss: 33,044,533.64 USP, 2,403,762.19 USDC +5 morePending manual check1 exploit txWindow: Atomic
Estimated Impact
33,044,533.64 USP, 2,403,762.19 USDC +5 more
Label
Attack
Exploit Tx
1
Addresses
3
Attack Window
Atomic
Feb 16, 2023 19:16 UTC → Feb 16, 2023 19:16 UTC

Exploit Transactions

TX 1Avalanche
0x1266a937c2ccd970e5d7929021eed3ec593a95c68a99b4920c2efa226679b430
Feb 16, 2023 19:16 UTCExplorer

Victim Addresses

0x66357dcace80431aee0a7507e2e361b7e2402370Avalanche
0xff6934aac9c94e1c39358d4fdcf70aeca77d0ab0Avalanche
0x061da45081ace6ce1622b9787b68aa7033621438Avalanche

Loss Breakdown

33,044,533.64USP
2,403,762.19USDC
1,946,900.84USDC.e
1,552,550.94USDT
1,217,581.62USDT.e
687,369.44BUSD
691,984.96DAI.e

Similar Incidents

Root Cause Analysis

Platypus Stale Collateral Withdrawal

1. Incident Overview TL;DR

On Avalanche block 26,343,614, transaction 0x1266a937c2ccd970e5d7929021eed3ec593a95c68a99b4920c2efa226679b430 let an unprivileged attacker drain value from Platypus Finance in a single flash-loan transaction. The attacker EOA 0xeff003d64046a6f521ba31f39405cb720e953958 called exploit contract 0x67afdd6489d40a01dae65f709367e1b1d18a5322, borrowed 44,000,000 USDC from Aave V3, minted and staked LPUSDC, borrowed 41,794,533.641783253909672 USP against that LP collateral, then used MasterPlatypusV4.emergencyWithdraw(4) to pull the LP collateral back out before PlatypusTreasure re-evaluated the position on the reduced collateral base.

The root cause is a stale-collateral ordering bug across two public Platypus components. Historical MasterPlatypusV4 implementation 0xc007f27b757a782c833c568f5851ae1dfe0e6ec7 called platypusTreasure.isSolvent(msg.sender, address(poolInfo[_pid].lpToken), true) before transferring the LP token back to the user and before zeroing user.amount, while historical PlatypusTreasure implementation 0xbcd6796177ab8071f6a9ba2c3e2e0301ee91bef5 treated masterPlatypus.getUserInfo(pid, user).amount as the source of LP collateral. That ordering let the solvency check observe collateral that was about to disappear. After repaying the flash loan, the attacker retained token balances valued at 41,544,683.636572669965801520 USD under the report's stated one-dollar pricing assumption for USP and the retained stablecoins.

2. Key Background

Platypus split the relevant LP-backed borrowing logic across two contracts:

  • MasterPlatypusV4 at proxy 0xff6934aac9c94e1c39358d4fdcf70aeca77d0ab0 managed staked LP positions by pool id.
  • PlatypusTreasure at proxy 0x061da45081ace6ce1622b9787b68aa7033621438 let users borrow USP against collateral, including LP collateral.
  • Pool at proxy 0x66357dcace80431aee0a7507e2e361b7e2402370 minted and redeemed the LPUSDC token 0xaef735b1e7ecfaf8209ea46610585817dc0a2e16.

The important design detail is that PlatypusTreasure did not keep an internal LP collateral balance for LP markets. Instead, it delegated LP collateral accounting to MasterPlatypus. For the LPUSDC market, solvency and borrow-limit checks flowed through MasterPlatypusV4.getUserInfo(pid, user).amount. That made the staking contract's ordering around emergencyWithdraw security-critical for lending safety.

The attack was ACT-style because every required component was public and permissionless:

  • Aave V3 on Avalanche exposed flashLoanSimple for USDC.
  • Platypus Pool deposit and withdraw entrypoints were public.
  • MasterPlatypusV4.deposit and MasterPlatypusV4.emergencyWithdraw were public.
  • PlatypusTreasure.borrow was public for eligible collateral positions.

No privileged role, compromised key, hidden mempool visibility, or attacker-specific artifact was required.

3. Vulnerability Analysis & Root Cause Summary

This incident is an ATTACK root cause, not a pure MEV arbitrage. PlatypusTreasure expected a borrower with outstanding USP debt to remain backed by the LP position that its solvency logic used as collateral. That invariant failed because the collateral source lived in another contract and MasterPlatypusV4.emergencyWithdraw validated solvency too early.

The historical MasterPlatypusV4 code performed the check against the pre-withdraw staking state. The historical PlatypusTreasure code then interpreted that same staking state as current collateral. As a result, the borrower could pass isSolvent, receive the LP tokens back, and only afterwards have the staked amount cleared. The lending side was never given a chance to reject the withdrawal on the post-withdraw state that actually mattered.

The exploitable breakpoint is therefore concrete and code-level: MasterPlatypusV4.emergencyWithdraw(uint256) invoked platypusTreasure.isSolvent before pool.lpToken.transfer(address(msg.sender), user.amount) and before user.amount = 0. The broken invariant is equally concrete: a user with positive USP debt must not be able to remove the LP position that PlatypusTreasure uses as collateral.

4. Detailed Root Cause Analysis

Historical staking-side bug

The validator independently retrieved the verified source for historical implementation 0xc007f27b757a782c833c568f5851ae1dfe0e6ec7. The critical portion of MasterPlatypusV4.emergencyWithdraw was:

function emergencyWithdraw(uint256 _pid) public nonReentrant {
    PoolInfo storage pool = poolInfo[_pid];
    UserInfo storage user = userInfo[_pid][msg.sender];

    if (address(platypusTreasure) != address(0x00)) {
        (bool isSolvent, ) = platypusTreasure.isSolvent(
            msg.sender,
            address(poolInfo[_pid].lpToken),
            true
        );
        require(isSolvent, "remaining amount exceeds collateral factor");
    }

    pool.lpToken.transfer(address(msg.sender), user.amount);
    pool.sumOfFactors -= user.factor;
    user.amount = 0;
    user.factor = 0;
    user.rewardDebt = 0;
}

Snippet origin: historical verified MasterPlatypusV4 implementation on Snowtrace.

This ordering is the bug. The function asked PlatypusTreasure whether the position was solvent before it reduced the LP collateral that PlatypusTreasure depends on.

Lending-side collateral dependency

The validator also independently retrieved the verified source for historical implementation 0xbcd6796177ab8071f6a9ba2c3e2e0301ee91bef5. The relevant PlatypusTreasure logic was:

function _isSolvent(
    address _user,
    ERC20 _token,
    bool _open
) internal view returns (bool solvent, uint256 debtAmount) {
    uint256 debtShare = userPositions[_token][_user].debtShare;
    if (debtShare == 0) return (true, 0);

    debtAmount =
        (debtShare * (totalDebtAmount + _interestSinceLastAccrue())) /
        totalDebtShare;
    solvent = debtAmount <= (_open ? _borrowLimitUSP(_user, _token) : _liquidateLimitUSP(_user, _token));
}

function _getCollateralAmount(ERC20 _token, address _user) internal view returns (uint256) {
    CollateralSetting storage setting = collateralSettings[_token];
    if (setting.isLp) {
        return setting.masterPlatypus.getUserInfo(setting.pid, _user).amount;
    } else {
        return userPositions[_token][_user].collateralAmount;
    }
}

function _borrowLimitUSP(address _user, ERC20 _token) internal view returns (uint256) {
    uint256 amount = _getCollateralAmount(_token, _user);
    uint256 totalUSD = _tokenPriceUSD(_token, amount);
    return (totalUSD * collateralSettings[_token].collateralFactor) / 10000;
}

Snippet origin: historical verified PlatypusTreasure implementation on Snowtrace.

This proves the cross-contract dependency. For LP collateral, PlatypusTreasure directly trusted MasterPlatypusV4.getUserInfo(pid, user).amount. That meant the lending-side solvency answer was only as current as the staking-side state transition order.

On-chain execution showing stale collateral

The seed trace for transaction 0x1266a937c2ccd970e5d7929021eed3ec593a95c68a99b4920c2efa226679b430 shows the exploit in the exact order required by the buggy code:

PlatypusTreasure::positionView(... LPUSDC ...) -> borrowLimitUSP: 41794533641783253909672000
PlatypusTreasure::borrow(LPUSDC, 41794533641783253909672000)
MasterPlatypusV4::emergencyWithdraw(4)
  PlatypusTreasure::isSolvent(attacker, LPUSDC, true)
    MasterPlatypusV4::getUserInfo(4, attacker)
      -> amount: 44000100592104
  Asset::transfer(attacker, 44000100592104)
  storage change: user.amount -> 0
Pool::withdraw(USDC, 44000100592104, 0, attacker, ...)

Snippet origin: collected Avalanche execution trace for the incident transaction.

That trace matters for two reasons:

  1. The stale collateral amount 44000100592104 LP units is read inside the isSolvent call.
  2. The same LP amount is then transferred back to the attacker before the staking position is zeroed.

The trace therefore confirms the reported breakpoint at runtime, not just in source code.

Post-withdraw state proving the invariant break

The post-withdraw condition described in root_cause.json is also evidence-backed. The same transaction sequence leaves the attacker with zero staked LP in MasterPlatypus while debt remains in PlatypusTreasure:

  • MasterPlatypusV4.getUserInfo(4, attacker).amount == 0
  • PlatypusTreasure.positionView(attacker, LPUSDC).debtAmountUSP > 0
  • The attacker is still able to redeem the released LP back into underlying USDC

That is the precise non-monetary success predicate: collateral was removed while the debt position remained open.

5. Adversary Flow Analysis

  1. The attacker EOA 0xeff003d64046a6f521ba31f39405cb720e953958 called exploit contract 0x67afdd6489d40a01dae65f709367e1b1d18a5322 in transaction 0x1266a937c2ccd970e5d7929021eed3ec593a95c68a99b4920c2efa226679b430.
  2. The exploit contract borrowed 44,000,000 USDC from Aave V3 using flashLoanSimple.
  3. It deposited the USDC into Platypus Pool and received 44,000,100,592,104 LPUSDC.
  4. It staked that LPUSDC into MasterPlatypusV4 pool id 4.
  5. It queried PlatypusTreasure.positionView and borrowed the full reported limit, 41,794,533.641783253909672 USP.
  6. It called MasterPlatypusV4.emergencyWithdraw(4). Because the solvency check ran before the staking state was reduced, PlatypusTreasure still saw the full LP stake and approved the withdrawal.
  7. The exploit contract received the LPUSDC back, then immediately redeemed it through Platypus Pool into 43,999,999.921036 USDC.
  8. It swapped part of the borrowed USP into other stablecoins to cover the Aave premium and diversify the extracted value.
  9. It repaid 44,022,000 USDC to Aave and kept the remaining USP and stablecoins.

The balance diff confirms the end-state holdings on the exploit contract:

[
  { "token_symbol": "USP", "amount": "33044533641783253909672000", "decimal": 18 },
  { "token_symbol": "USDC", "amount": "2403762189097", "decimal": 6 },
  { "token_symbol": "USDC.e", "amount": "1946900836223", "decimal": 6 },
  { "token_symbol": "USDT", "amount": "1552550943906", "decimal": 6 },
  { "token_symbol": "USDT.e", "amount": "1217581624092", "decimal": 6 },
  { "token_symbol": "BUSD", "amount": "687369440244482886082500", "decimal": 18 },
  { "token_symbol": "DAI.e", "amount": "691984961226933170047020", "decimal": 18 }
]

Snippet origin: collected post-transaction balance-diff artifact for the exploit contract.

6. Impact & Losses

The immediate security impact was an LP-backed borrowing invariant failure in Platypus. A borrower ended the transaction with zero LP collateral in MasterPlatypus while still owing USP in PlatypusTreasure. That is the protocol-level failure condition even before any mark-to-market profit calculation.

The economic impact is the attacker's retained balance set after Aave repayment. Under the explicit pricing assumption used in the audited root cause artifact, USP and each retained stablecoin are valued at 1 USD per token. On that basis, the post-transaction holdings total exactly 41,544,683.636572669965801520 USD. The stated flash-loan fee is 22,000 USDC, and the seed balance diff separately records a gas payment of 0.56432484 AVAX by the sender EOA.

Losses recorded in smallest units:

  • USP: "33044533641783253909672000" with 18 decimals
  • USDC: "2403762189097" with 6 decimals
  • USDC.e: "1946900836223" with 6 decimals
  • USDT: "1552550943906" with 6 decimals
  • USDT.e: "1217581624092" with 6 decimals
  • BUSD: "687369440244482886082500" with 18 decimals
  • DAI.e: "691984961226933170047020" with 18 decimals

Affected public protocol components:

  • Platypus Pool 0x66357dcace80431aee0a7507e2e361b7e2402370
  • MasterPlatypusV4 0xff6934aac9c94e1c39358d4fdcf70aeca77d0ab0
  • PlatypusTreasure 0x061da45081ace6ce1622b9787b68aa7033621438

7. References

  1. Incident transaction: 0x1266a937c2ccd970e5d7929021eed3ec593a95c68a99b4920c2efa226679b430 on Avalanche block 26,343,614.
  2. Historical MasterPlatypusV4 proxy 0xff6934aac9c94e1c39358d4fdcf70aeca77d0ab0 and implementation 0xc007f27b757a782c833c568f5851ae1dfe0e6ec7.
  3. Historical PlatypusTreasure proxy 0x061da45081ace6ce1622b9787b68aa7033621438 and implementation 0xbcd6796177ab8071f6a9ba2c3e2e0301ee91bef5.
  4. Platypus Pool proxy 0x66357dcace80431aee0a7507e2e361b7e2402370 and LPUSDC token 0xaef735b1e7ecfaf8209ea46610585817dc0a2e16.
  5. Validator-reviewed evidence sources: collected execution trace, collected balance diff, collected transaction metadata, and Snowtrace verified source for the two historical implementations.