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 through PlatypusTreasure. They then called emergencyWithdraw() on MasterPlatypusV4, which ran platypusTreasure.isSolvent() against the still-intact staking position, only consumed the boolean half of that return value, and transferred the LP collateral out before setting user.amount = 0. Unlike the ordinary withdraw() 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 proxy 0xff6934aac9c94e1c39358d4fdcf70aeca77d0ab0, whose implementation had been deployed before Platypus later integrated PlatypusTreasure borrowing.
    • emergencyWithdraw() consulted platypusTreasure.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), but emergencyWithdraw() only enforced the boolean side. As long as the debt was still below the configured pre-withdraw borrow limit, the call passed even though debtAmount remained 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, so PlatypusTreasure saw the reduced collateral there. emergencyWithdraw() diverged from that canonical sequencing.
    • PlatypusTreasure proxy 0x061da45081ace6ce1622b9787b68aa7033621438 allowed the staked LP-USDC position to back freshly minted USP, so the integration bug turned a valid borrow into instantly unserviceable debt.
  • 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.amount was 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 in emergencyWithdraw(). The exploit lived in that divergence, not only in the word solvent by 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 solvent meant.
  • Exploit path:
    1. The attacker used EOA 0xeff003d64046a6f521ba31f39405cb720e953958 and main attack contract 0x67afdd6489d40a01dae65f709367e1b1d18a5322 in transaction 0x1266a937c2ccd970e5d7929021eed3ec593a95c68a99b4920c2efa226679b430.
    2. 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.
    3. They staked that LP-USDC into MasterPlatypusV4, then borrowed about 41,794,533.641783253909672000 USP from PlatypusTreasure at the system’s roughly 95% borrow limit.
    4. 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.
    5. 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.
    6. 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, and 692.0K DAI.e before liquidity constraints made further swaps unattractive.
    7. The remaining USP became bad debt and the stablecoin depegged because the collateral that had backed it had already been removed from the system.
  • What the attacker needed to be true:
    • MasterPlatypusV4 had to accept staked LP tokens as collateral backing for PlatypusTreasure borrows.
    • 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() did isSolvent(...) -> rewarder reset -> LP transfer -> user.amount = 0, whereas the ordinary withdraw() flow updated the position before checking solvency.
    • That makes Platypus a clean helper path stopped reusing the canonical economic boundary case, 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 reassurance exemplar: 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 emergency or escape hatch functions 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
    • PlatypusTreasure proxy: 0x061da45081ace6ce1622b9787b68aa7033621438
    • PlatypusTreasure implementation: 0xbcd6796177ab8071f6a9ba2c3e2e0301ee91bef5
    • MasterPlatypusV4 proxy: 0xff6934aac9c94e1c39358d4fdcf70aeca77d0ab0
    • MasterPlatypusV4 implementation: 0xc007f27b757a782c833c568f5851ae1dfe0e6ec7
    • LP-USDC token: 0xaef735b1e7ecfaf8209ea46610585817dc0a2e16
    • USP token proxy: 0xdacde03d7ab4d81feddc3a20faa89abac9072ce2
  • 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() whenever debtAmount != 0.
    • Platypus’s own 2023-02-23 recovery update said the same exploiter executed three related attacks totaling about $9.19M; the team reported about 2.4M USDC recovered from the first attack contract, 1.5M USDT frozen 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 emergency paths that were originally designed under simpler assumptions.

Three debt-exit promotions this incident collapsed

  1. A staked LP-backed borrow position existed at all

    • Before emergencyWithdraw() ran, the strongest true fact was only that the user still had a live MasterPlatypusV4 stake and a live USP debt position that PlatypusTreasure was 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.
  2. That pre-withdraw position still counted as canonical post-exit solvency truth for this exact helper path

    • This was the decisive mistake. emergencyWithdraw() asked isSolvent(...) while the collateral was still present, consumed only the boolean half of the answer, and silently promoted that pre-state reassurance into the stronger claim this exit is economically safe even after the stake leaves.
    • The helper therefore treated solvent before collateral is removed as if it were the same thing as still solvent after this exact collateral-release path finishes.
  3. 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 PlatypusTreasure holding 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.

Pattern tags

  • solvency-check-bypass
  • The exploit was not borrow too much because price moved; it was release 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 exit exemplar 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 ordinary withdraw() 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 inside PlatypusTreasure.
  • 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 and emergencyWithdraw() 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, and withdraw without rewards paths 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 token as 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 == 0 or debt-family exhaustion gate in front of every emergencyWithdraw() / emergencyExit() path that releases collateral back to the user.
  • Reuse one shared post-state collateral-release core across withdraw() and emergencyWithdraw() 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 returns debtAmount, 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 mixed stake-backed collateral + live debt positions.
  • 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 solvent and debtAmount, 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

  • Emergency is an operational label, not permission to bypass the liability boundary.
  • Solvent before withdrawal is weaker than safe 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 to debt == 0 or reused the ordinary post-state solvency boundary instead of trusting a pre-withdraw boolean. This is the clean incident-side anchor for the note’s post-state meaning, not pre-state reassurance shorthand.

Control surface