Platypus Finance USP emergencyWithdraw Solvency-Check Bypass (2023-02-16)
- Name: Platypus Finance USP emergencyWithdraw solvency-check bypass
- Date: 2023-02-16
- Protocol: Platypus Finance
- Chain: Avalanche
- Category: lending-protocol solvency check bypass / emergency-withdraw invariant gap / bad-debt stablecoin drain
- Loss: about $8.5 million
- Summary: The attacker flash-borrowed 44 million USDC, deposited it into Platypus to mint LP-USDC, staked that LP-USDC in
MasterPlatypusV4, and borrowed about 41.79 million USP against it throughPlatypusTreasure. They then calledemergencyWithdraw()onMasterPlatypusV4, which ranplatypusTreasure.isSolvent()against the still-intact staking position, only consumed the boolean half of that return value, and transferred the LP collateral out before settinguser.amount = 0. Unlike the ordinarywithdraw()path, the helper never re-checked solvency after collateral had actually left. That let the attacker pull the full LP collateral back out while keeping the newly minted USP debt outstanding, withdraw almost all of the original USDC, repay the flash loan, and dump part of the unbacked USP into Platypus stablecoin pools for real assets. - Trigger / entry point:
- The vulnerable path sat in
MasterPlatypusV4::emergencyWithdraw()at proxy0xff6934aac9c94e1c39358d4fdcf70aeca77d0ab0, whose implementation had been deployed before Platypus later integratedPlatypusTreasureborrowing. emergencyWithdraw()consultedplatypusTreasure.isSolvent(msg.sender, token, true)before clearing the user’s staked LP amount, so the check still saw the full collateralized position.- That call returned
(bool solvent, uint256 debtAmount), butemergencyWithdraw()only enforced the boolean side. As long as the debt was still below the configured pre-withdraw borrow limit, the call passed even thoughdebtAmountremained live and the user would have zero collateral immediately after withdrawal. - The safer sibling path mattered: public analyses highlighted that
MasterPlatypusV4::withdraw()performed its solvency check after updating the staking position, soPlatypusTreasuresaw the reduced collateral there.emergencyWithdraw()diverged from that canonical sequencing. - PlatypusTreasure proxy
0x061da45081ace6ce1622b9787b68aa7033621438allowed the staked LP-USDC position to back freshly minted USP, so the integration bug turned a valid borrow into instantly unserviceable debt.
- The vulnerable path sat in
- Root cause:
- Platypus integrated a new borrowing module against an older staking contract path whose
emergencyWithdraw()logic was no longer economically safe. - The solvency check was ordered incorrectly: it ran against stale collateral state before
user.amountwas zeroed, transferred, and reflected inside Treasure-side collateral accounting. - The call path also ignored the deeper implication of
isSolvent()returning a debt amount alongside the boolean status; the boolean only said the user was within the pre-withdraw borrow cap, not that emergency withdrawal with live debt was safe. - Put differently, Platypus had one canonical withdrawal sequencing rule in
withdraw()and a weaker helper-path variant inemergencyWithdraw(). The exploit lived in that divergence, not only in the wordsolventby itself. - In short: the protocol let a user remove all collateral from a live debt position because an emergency helper path inherited outdated assumptions about what
solventmeant.
- Platypus integrated a new borrowing module against an older staking contract path whose
- Exploit path:
- The attacker used EOA
0xeff003d64046a6f521ba31f39405cb720e953958and main attack contract0x67afdd6489d40a01dae65f709367e1b1d18a5322in transaction0x1266a937c2ccd970e5d7929021eed3ec593a95c68a99b4920c2efa226679b430. - They borrowed 44 million USDC from Aave v3 on Avalanche, deposited it into Platypus’ USDC asset pool, and received about 44 million LP-USDC at
0xaef735b1e7ecfaf8209ea46610585817dc0a2e16. - They staked that LP-USDC into
MasterPlatypusV4, then borrowed about41,794,533.641783253909672000USP fromPlatypusTreasureat the system’s roughly 95% borrow limit. - Without repaying the USP debt, they called
emergencyWithdraw(). Because the solvency check ran before the stake amount was zeroed, the contract still viewed the position as fully collateralized and released all LP-USDC. - The attacker withdrew nearly all of the original 44 million USDC from the Platypus pool, repaid the flash loan principal, and kept the unbacked USP position alive inside
PlatypusTreasure. - They then dumped part of the minted USP through Platypus pools, swapping it into roughly
2.4258M USDC,1.9469M USDC.e,1.5526M USDT,1.2176M USDT.e,687.4K BUSD, and692.0K DAI.ebefore liquidity constraints made further swaps unattractive. - The remaining USP became bad debt and the stablecoin depegged because the collateral that had backed it had already been removed from the system.
- The attacker used EOA
- What the attacker needed to be true:
MasterPlatypusV4had to accept staked LP tokens as collateral backing forPlatypusTreasureborrows.emergencyWithdraw()had to run its solvency logic before mutating the staking balance, or otherwise fail to treat live debt as a hard stop.- The borrow limit needed to be high enough that the attacker could mint a large USP position while still passing the pre-withdraw solvency check.
- Platypus pools needed enough real stablecoin liquidity for unbacked USP to be dumped into USDC, USDT, DAI.e, BUSD, and bridged stablecoin balances.
- The flash-loan venue needed to be deep enough that the attacker could bootstrap a one-transaction collateral position large enough to matter.
- Technical anatomy worth remembering:
- This was not an oracle manipulation or a rounding exploit. It was a helper-path invariant failure: an emergency exit path no longer matched the debt model added later.
- The bug lived in the integration boundary between staking collateral and stablecoin borrowing, not in USP minting alone.
- Public exploit writeups preserved the crucial sequencing difference:
emergencyWithdraw()didisSolvent(...) -> rewarder reset -> LP transfer -> user.amount = 0, whereas the ordinarywithdraw()flow updated the position before checking solvency. - That makes Platypus a clean
helper path stopped reusing the canonical economic boundarycase, not just a generic bad-solvency story: the emergency wrapper open-coded a weaker cousin of the real collateral-release path. - It is also a crisp
post-state meaning, not pre-state reassuranceexemplar:isSolvent()was called while the collateral was still present, so the helper only proved the old world was healthy and then let the user leave with the backing anyway. isSolvent()itself was not the whole problem; the meaningful mistake was calling it against stale collateral state and treating the boolean as proof that post-withdraw solvency would still hold.- A simple
require(debtAmount == 0)on the emergency path, or just reusing the canonical post-update solvency sequencing, would have blocked the exploit class. - Platypus shows why
emergencyorescape hatchfunctions deserve the same economic review as ordinary deposits, borrows, and liquidations. Once new modules depend on old state, emergency code can become a privileged accounting bypass.
- Representative transactions and artifacts:
- Attacker EOA:
0xeff003d64046a6f521ba31f39405cb720e953958 - Main attack contract:
0x67afdd6489d40a01dae65f709367e1b1d18a5322 - Main exploit tx:
0x1266a937c2ccd970e5d7929021eed3ec593a95c68a99b4920c2efa226679b430 - Later attacker dump / follow-on txs discussed in public analyses:
0x997bfe1fe0284ebbde58fdab7d796aae5e5d3ac1da7b20cf128961e77d35eed4,0x919266aa66d7c9a6af02dead5effc1cc68ab7b87890b52e5fc1e20a7041aa84d PlatypusTreasureproxy:0x061da45081ace6ce1622b9787b68aa7033621438PlatypusTreasureimplementation:0xbcd6796177ab8071f6a9ba2c3e2e0301ee91bef5MasterPlatypusV4proxy:0xff6934aac9c94e1c39358d4fdcf70aeca77d0ab0MasterPlatypusV4implementation:0xc007f27b757a782c833c568f5851ae1dfe0e6ec7- LP-USDC token:
0xaef735b1e7ecfaf8209ea46610585817dc0a2e16 - USP token proxy:
0xdacde03d7ab4d81feddc3a20faa89abac9072ce2
- Attacker EOA:
- Response / mitigation:
- Platypus paused operations after the exploit and publicly attributed the incident to a flaw in the USP solvency-check mechanism around the collateral-holding contract.
- External postmortems converged on the same minimal fix set: either move the solvency check after the stake amount is zeroed / reduced, or forbid
emergencyWithdraw()wheneverdebtAmount != 0. - Platypus’s own 2023-02-23 recovery update said the same exploiter executed three related attacks totaling about
$9.19M; the team reported about2.4M USDCrecovered from the first attack contract,1.5M USDTfrozen via issuer cooperation, and additional recovery discussions with Aave governance, Ava Labs, and Binance around assets stuck or bridged through other routes. - The same update also said the pool would relaunch without USP-related functions, which is the operational version of the core lesson: once the debt module made staked LP positions part of the live solvency boundary, the old emergency path could not stay trusted as-is.
- The incident became a crisp lesson that retrofitting borrowing logic onto legacy staking contracts requires re-auditing every withdrawal path, especially
emergencypaths that were originally designed under simpler assumptions.
Three debt-exit promotions this incident collapsed
-
A staked LP-backed borrow position existed at all
- Before
emergencyWithdraw()ran, the strongest true fact was only that the user still had a liveMasterPlatypusV4stake and a live USP debt position thatPlatypusTreasurewas willing to tolerate under the current collateral state. - That was a reachability / status fact, not yet a statement that the user could safely carry the LP collateral out of the system.
- Before
-
That pre-withdraw position still counted as canonical post-exit solvency truth for this exact helper path
- This was the decisive mistake.
emergencyWithdraw()askedisSolvent(...)while the collateral was still present, consumed only the boolean half of the answer, and silently promoted that pre-state reassurance into the stronger claimthis exit is economically safe even after the stake leaves. - The helper therefore treated
solvent before collateral is removedas if it were the same thing asstill solvent after this exact collateral-release path finishes.
- This was the decisive mistake.
-
That accepted solvency answer still deserved immediate collateral-release authority while USP debt remained live
- Once the helper accepted the stale boolean, it still let the user reclaim all LP-USDC, withdraw the underlying USDC, and leave
PlatypusTreasureholding the now-unbacked USP liability. - This is the cash-out step the protocol should have broken apart: even if a helper can observe an apparently healthy pre-state position, that observation should not inherit authority to release the entire backing object before debt is extinguished or the post-state position is re-proven safe.
- Once the helper accepted the stale boolean, it still let the user reclaim all LP-USDC, withdraw the underlying USDC, and leave
Pattern tags
- solvency-check-bypass
- The exploit was not
borrow too much because price moved; it wasrelease the backing object through a helper path that still read the old pre-withdraw world as if nothing economically final had happened yet. - Platypus is the clean
stale pre-withdraw emergency exitexemplar inside the broader solvency-check-bypass family: the protocol used the vocabulary of solvency, but attached it to the wrong side of the state transition.
Why the exploit worked
MasterPlatypusV4::emergencyWithdraw()and the ordinarywithdraw()path no longer meant the same thing economically. The ordinary path updated the staking position before checking solvency, while the emergency helper asked the question too early and then released the collateral anyway.- Platypus let one boolean answer stand in for a stronger liability claim.
isSolvent()returning true on the pre-withdraw state did not prove that the user could safely leave with the LP backing while USP debt stayed live insidePlatypusTreasure. - The integration seam between staking and borrowing never imposed a second fail-closed boundary. Once LP-USDC had become debt backing, the old emergency path still behaved as if the stake were only a local farming position.
- Downstream liquidity finished the monetization. After the helper released the LP-USDC and the attacker withdrew USDC, Platypus’ own stablecoin pools turned the now-unbacked USP into real external assets.
Detection and audit pivots
- Compare helper exits against the canonical post-state solvency boundary, not just by name but by sequencing. If
withdraw()mutates collateral before checking andemergencyWithdraw()checks before mutating, they are not equivalent safety surfaces. - Trace every place a borrowed position can release collateral through an older staking or rewards contract. Legacy
unstake,emergency,rescue, andwithdraw without rewardspaths should all be presumed dangerous once the same position backs debt. - Treat richer helper return values as audit signals. If
isSolvent()returns both a boolean and a debt amount, reviewers should ask which callers enforce the stronger liability meaning and which ones flatten it into pre-state comfort. - Model
borrow -> emergencyWithdraw -> withdraw underlying -> dump synthetic debt tokenas one adversarial path. Platypus was only profitable because the collateral-release bug and the protocol-owned USP exit liquidity composed cleanly. - Ask where the real collateral-release authority lives. If the staking contract can hand assets back to the user while the lending contract still thinks those assets secure debt, the boundary is already split.
Failure-prevention checklist
- Put an explicit
debt == 0or debt-family exhaustion gate in front of everyemergencyWithdraw()/emergencyExit()path that releases collateral back to the user. - Reuse one shared post-state collateral-release core across
withdraw()andemergencyWithdraw()so the helper cannot validate pre-withdraw safety and then release collateral against stale state. - Treat helper return values as economic objects, not convenience booleans: if
isSolvent()also returnsdebtAmount, emergency paths should enforce the stronger liability meaning instead of only the easiest field. - Re-audit every legacy staking, rewards, and unstake path whenever those positions become borrowable collateral for a new lending or stablecoin module.
- Add adversarial tests for
borrow -> emergencyWithdraw, max-LTV exit attempts, and mixedstake-backed collateral + live debtpositions. - Make panic-mode UX narrower than ordinary withdraw semantics: emergency paths may skip rewards or optional bookkeeping, but they should not widen the user’s ability to leave with backing that still secures debt.
Audit prompts
- Which exact contract still owns the canonical collateral-release boundary here: the staking contract, the lending contract, or an integration seam that both assume the other side will police?
- Does
emergencyWithdraw()prove safety against the post-withdraw position, or does it only show that the old pre-withdraw state looked solvent one line earlier? - If a helper returns both
solventanddebtAmount, which call sites enforce each field, and where is a richer liability signal being flattened into one boolean? - Which helper or unstake paths predate the borrowing module and were left callable after those same LP positions started backing USP debt?
- If a user calls the emergency path at maximum allowed leverage, what exact invariant prevents collateral from leaving while debt remains live inside the borrowing module?
Lessons that should already be standard
Emergencyis an operational label, not permission to bypass the liability boundary.Solvent before withdrawalis weaker thansafe after collateral leaves; Platypus failed because the helper proved the former and then assumed it implied the latter.- Old staking or rewards code becomes new solvency code the moment another module starts treating the same position as debt backing.
- A helper that exposes richer debt semantics than its callers enforce is already signaling drift between interface meaning and economic reality.
- If users are merely expected not to call an emergency path while borrowing, the real rule is still folklore until code enforces it.
Why this incident matters
- Platypus is a clean example of solvency being broken by state ordering and module integration rather than by price manipulation.
- It broadens the accounting-desynchronization pattern beyond Euler’s reserve-donation path into collateral escape hatches that simply stop honoring live debt.
- It also shows how stablecoin insolvency can be created in one call: once collateral can leave before debt is reconciled, the protocol’s own pools become the exit liquidity for unbacked issuance.
Internal linkages
- Core recurring pattern: solvency-check-bypass.
- Sharper helper-path design companion: helper-path-invariant-reuse-and-fail-closed-discipline — the emergency wrapper should have reused the same post-state collateral-release boundary as the ordinary path or failed closed once the staked LP position also backed USP debt.
- Sharper emergency-path design companion: debt-zero-emergency-exit-discipline — once staked LP positions also backed USP debt,
emergencyWithdraw()should have defaulted todebt == 0or reused the ordinary post-state solvency boundary instead of trusting a pre-withdraw boolean. This is the clean incident-side anchor for the note’spost-state meaning, not pre-state reassuranceshorthand.
Control surface
-
The decisive leverage sat in the
MasterPlatypusV4emergency-withdraw ordering, the meaning assigned toisSolvent(), and the integration that let staked LP collateral secure USP debt throughPlatypusTreasure. -
Platypus did not fail because the attacker found exotic market conditions. It failed because an emergency helper path still assumed collateral could be released independently of debt after the protocol architecture had changed.
-
Sources:
- https://medium.com/platypus-finance/update-on-recovery-efforts-after-the-exploitation-a8f64acd5aa5
- https://medium.com/@omniscia.io/platypus-finance-incident-post-mortem-7b71a0a47a5e
- https://www.certik.com/blog/platypus-finance-incident-analysis
- https://rekt.news/platypus-finance-rekt
- https://blog.solidityscan.com/platypus-stablecoin-usp-hack-analysis-withdraw-funds-without-paying-the-debt-9fa3ef06eddc/
- https://snowtrace.io/tx/0x1266a937c2ccd970e5d7929021eed3ec593a95c68a99b4920c2efa226679b430
- https://snowtrace.io/address/0xc007f27b757a782c833c568f5851ae1dfe0e6ec7#code
- https://snowtrace.io/address/0xbcd6796177ab8071f6a9ba2c3e2e0301ee91bef5#code
-
Last reviewed: 2026-08-03 UTC