Platypus Stale Collateral Withdrawal
Exploit Transactions
0x1266a937c2ccd970e5d7929021eed3ec593a95c68a99b4920c2efa226679b430Victim Addresses
0x66357dcace80431aee0a7507e2e361b7e2402370Avalanche0xff6934aac9c94e1c39358d4fdcf70aeca77d0ab0Avalanche0x061da45081ace6ce1622b9787b68aa7033621438AvalancheLoss Breakdown
Similar Incidents
Paribus Redeem Reentrancy
23%SwapX Stale-Allowance Drain
23%Mosca double-withdrawal exploit via helper on BNB
23%HedgePay Staking Proxy Repeated forceExit Withdrawal Drain
22%0VIX ovGHST Oracle Inflation
22%NFTX Doodles collateral accounting flaw enables flash-loan ETH extraction
22%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:
MasterPlatypusV4at proxy0xff6934aac9c94e1c39358d4fdcf70aeca77d0ab0managed staked LP positions by pool id.PlatypusTreasureat proxy0x061da45081ace6ce1622b9787b68aa7033621438let users borrow USP against collateral, including LP collateral.Poolat proxy0x66357dcace80431aee0a7507e2e361b7e2402370minted and redeemed theLPUSDCtoken0xaef735b1e7ecfaf8209ea46610585817dc0a2e16.
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
flashLoanSimplefor USDC. - Platypus Pool deposit and withdraw entrypoints were public.
MasterPlatypusV4.depositandMasterPlatypusV4.emergencyWithdrawwere public.PlatypusTreasure.borrowwas 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:
- The stale collateral amount
44000100592104LP units is read inside theisSolventcall. - 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 == 0PlatypusTreasure.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
- The attacker EOA
0xeff003d64046a6f521ba31f39405cb720e953958called exploit contract0x67afdd6489d40a01dae65f709367e1b1d18a5322in transaction0x1266a937c2ccd970e5d7929021eed3ec593a95c68a99b4920c2efa226679b430. - The exploit contract borrowed
44,000,000USDC from Aave V3 usingflashLoanSimple. - It deposited the USDC into Platypus Pool and received
44,000,100,592,104LPUSDC. - It staked that LPUSDC into
MasterPlatypusV4pool id4. - It queried
PlatypusTreasure.positionViewand borrowed the full reported limit,41,794,533.641783253909672USP. - 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. - The exploit contract received the LPUSDC back, then immediately redeemed it through Platypus Pool into
43,999,999.921036USDC. - It swapped part of the borrowed USP into other stablecoins to cover the Aave premium and diversify the extracted value.
- It repaid
44,022,000USDC 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"with18decimalsUSDC:"2403762189097"with6decimalsUSDC.e:"1946900836223"with6decimalsUSDT:"1552550943906"with6decimalsUSDT.e:"1217581624092"with6decimalsBUSD:"687369440244482886082500"with18decimalsDAI.e:"691984961226933170047020"with18decimals
Affected public protocol components:
- Platypus Pool
0x66357dcace80431aee0a7507e2e361b7e2402370 - MasterPlatypusV4
0xff6934aac9c94e1c39358d4fdcf70aeca77d0ab0 - PlatypusTreasure
0x061da45081ace6ce1622b9787b68aa7033621438
7. References
- Incident transaction:
0x1266a937c2ccd970e5d7929021eed3ec593a95c68a99b4920c2efa226679b430on Avalanche block26,343,614. - Historical
MasterPlatypusV4proxy0xff6934aac9c94e1c39358d4fdcf70aeca77d0ab0and implementation0xc007f27b757a782c833c568f5851ae1dfe0e6ec7. - Historical
PlatypusTreasureproxy0x061da45081ace6ce1622b9787b68aa7033621438and implementation0xbcd6796177ab8071f6a9ba2c3e2e0301ee91bef5. - Platypus Pool proxy
0x66357dcace80431aee0a7507e2e361b7e2402370andLPUSDCtoken0xaef735b1e7ecfaf8209ea46610585817dc0a2e16. - Validator-reviewed evidence sources: collected execution trace, collected balance diff, collected transaction metadata, and Snowtrace verified source for the two historical implementations.