Predy Finance PredyPool Callback-Locker Cross-Pair Liquidity Migration Drain (2024-05-14)

  • Name: Predy Finance PredyPool callback-locker cross-pair liquidity migration drain
  • Date: 2024-05-14
  • Protocol: Predy Finance / PredyPool
  • Chain: Arbitrum
  • Category: callback-frame authority reuse / cross-pair liquidity migration / balance-provenance failure
  • Loss: about 83.9 WETH plus 219,585.737814 USDC, commonly reported as roughly $464k
  • Summary: Predy’s pool let anyone create a new trading pair and then call trade() on that pair, which opened a callback window where the caller became the current locker and could use privileged pool functions like take() and supply(). The attacker created a fresh USDC/WETH pair they themselves were allowed to trade, entered trade() on that new pair, then inside predyTradeAfterCallback() used take() to pull the real USDC and WETH backing existing pair 2 out of PredyPool and immediately supply() those same assets into attacker-owned pair 3. Predy’s final settlement check only cared that the pool’s aggregate token balances matched before and after the callback, not that liquidity had stayed attached to the same pair. Once the callback finished, pair 3 held the assets, the attacker held the supply claims, and a normal withdraw() drained the pool.
  • Trigger / entry point:
    • On 2024-05-14 07:41:28 UTC, the attacker targeted PredyPool on Arbitrum via exploit transaction 0xbe163f651d23f0c9e4d4a443c0cc163134a31a1c2761b60188adcfd33178f50f.
    • Predy’s own postmortem and third-party reconstructions identify the vulnerable PredyPool contract as 0x9215748657319b17fecb2b5d086a3147bfbc8613, the primary attacker EOA as 0x76b02ab483482740248e2ab38b5a879a31c6d008, and the exploit contract used in the callback flow as 0x7b8b944ab2f24c829504a7a6d70fce5298f2147c.
    • Predy already had two live pairs: pair id 1 for bridged USDC/WETH and pair id 2 for native USDC/WETH; the attacker first registered pair id 3 with the same native USDC/WETH assets so they could become the authorized trader for that new pair.
    • The decisive entry point was the trade() → TradeLogic.callTradeAfterCallback() flow, which intentionally let the active locker call privileged liquidity-moving functions during the callback before globalData.finalizeLock() closed the frame.
  • Root cause:
    • Predy treated some callback for the active locker is in progress as if it proved this locker may only settle liquidity movements consistent with this specific pair and this specific trade.
    • The temporary callback authority was pool-global rather than pair-scoped: once the attacker became the locker for pair 3, take() could pull token balances funded by pair 2.
    • Predy’s post-callback validation focused on aggregate pool token deltas, so moving assets out of one pair and back into another pair could still look balanced at the pool level.
    • In effect, Predy authenticated the callback frame too loosely and then accepted same total balances returned as proof that no economic theft had happened, even though pair ownership and withdrawal rights had been rewritten underneath that aggregate balance.
  • Exploit path:
    1. The attacker deployed an attack contract ahead of time and registered a new pair (pair id 3) using the same native USDC / WETH market already used by Predy’s existing pair id 2.
    2. They called trade() on pair id 3, which set the attack contract as the active locker and invoked the caller’s predyTradeAfterCallback() hook.
    3. Inside that callback, the attacker called PredyPool.take() to withdraw the full USDC and WETH liquidity then backing the older live pair.
    4. Still inside the same callback window, they called PredyPool.supply() to deposit those tokens into attacker-controlled pair id 3, minting themselves the corresponding supply position there.
    5. Predy’s callback finalization accepted the maneuver because the pool’s aggregate token balances were back where they started by the end of the callback, even though the funds had been reassigned from pair 2 to pair 3.
    6. After the callback closed, the attacker called withdraw() on pair id 3 and removed 219,585.737814 USDC plus about 83.9 WETH as the now-legitimate supplier to that attacker-created pair.
  • What the attacker needed to be true:
    • They had to be able to register a new pair and become the contract allowed to trade through that pair’s callback flow.
    • The callback locker privilege had to be broad enough to call take() against pool-wide liquidity rather than liquidity strictly namespaced to the in-flight pair.
    • The protocol had to allow take() and supply() during the callback because legitimate leverage flows depended on that temporary authority.
    • Final callback settlement had to validate only token balance differences at the whole-pool level, not provenance such as which pair lost liquidity and which pair gained the withdrawal right.
    • The attacker-created pair had to remain withdrawable under ordinary supply accounting after the callback finished.
  • Technical anatomy worth remembering:
    • This was not a classic reenter and double-spend before a balance update exploit. The sharper failure was that a real callback frame exposed a temporary capability that was too broad for the pair and stage it was supposed to represent.
    • The concrete authority leak was onlyByLocker: once TradeLogic.callTradeAfterCallback() initialized the lock for the in-flight trader, the callback could call take() and supply() against pool-global liquidity rather than liquidity namespaced to the active pair.
    • The theft did not require falsifying balances. Predy’s pool ended the callback with the same token totals it started with; what changed was which pair those assets backed and therefore who could withdraw them afterward.
    • That makes this a good companion to pure balance-delta bugs: aggregate conservation is not enough when the protocol also needs provenance about which sub-ledger or market owns the conserved balance. Put more compactly, balances stayed real but pair-level provenance did not.
    • The attacker did not need a fake Uniswap pool, oracle manipulation, or stolen key. They only needed the right to open one callback on a self-created pair and then use that temporary authority against liquidity attributed to another pair.
  • Representative transactions and addresses:
    • Main exploit tx: 0xbe163f651d23f0c9e4d4a443c0cc163134a31a1c2761b60188adcfd33178f50f
    • Vulnerable PredyPool: 0x9215748657319b17fecb2b5d086a3147bfbc8613
    • Exploit contract: 0x7b8b944ab2f24c829504a7a6d70fce5298f2147c
    • Primary attacker EOA: 0x76b02ab483482740248e2ab38b5a879a31c6d008
    • Ethereum bridge-out destination later highlighted in public analysis: 0xeDe4E01347C012BD57302ea606095FB1eC5c848E
  • Response / mitigation:
    • Predy publicly acknowledged the incident, halted the affected contracts, and said no further damage could occur because the exploited path had been stopped.
    • The team later published a postmortem and said the public-facing functions used in the attack had been disabled while they considered a secure relaunch after further audit work.
    • Predy also sent an onchain message to the exploiter offering a 10% bounty in exchange for returning funds, and told users to revoke related Permit2 approvals as an additional precaution even though the drained assets came from the pool itself rather than from direct user-wallet pulls.

Pattern tags

  • legitimate callback frame, wrong pair-scoped authority object
  • pool-global locker privilege was treated as if it authenticated pair-local liquidity rights
  • aggregate token-balance conservation was mistaken for conservation of pair-level supplier ownership
  • permissionless pair creation plus callback-time supply rights created a fresh namespace that could inherit withdraw claims over legacy liquidity

Why the exploit worked

  • Predy authenticated the callback too coarsely. onlyByLocker proved only that some authorized locker was active, not that this locker could touch only the liquidity economically owned by the in-flight pair.
  • During predyTradeAfterCallback(), the attacker could call both take() and supply() while the lock remained live. That made the callback frame powerful enough not just to borrow execution inventory, but to rewrite which pair held the supplier-side claim over that inventory.
  • Predy’s end-of-callback check looked only at aggregate pool token balances. Because the attacker moved USDC and WETH out of pair 2 and back into the pool under pair 3, the pool-level diff still looked clean even though pair-level ownership had changed.
  • Permissionless pair creation completed the monetization path. The attacker did not need to fake balances or forge a pool; they only needed a fresh pair namespace they controlled so the migrated liquidity would later be withdrawable through ordinary accounting.
  • The final withdraw() succeeded because Predy treated pair 3 now has supplied liquidity as sufficient proof that the same party deserved the redemption right, even though the liquidity had been sourced from pair 2 during a broader callback frame.

Detection and audit pivots

  • Trace the pair id, market id, or sub-ledger id through every callback-callable helper. If take() or supply() can move value without proving it belongs to the same in-flight pair, the callback frame is already too broad.
  • Compare pool stayed balanced checks against pair-level ownership stayed conserved checks. Any review that stops at aggregate token deltas will miss cross-ledger custody migration bugs like Predy.
  • Model the full adversarial sequence register pair -> trade on new pair -> callback -> take old-pair assets -> supply into new pair -> withdraw. If that path works while all final pool balances net to zero, settlement provenance is under-specified.
  • Alert on sudden liquidity migration from mature pairs into brand-new pairs, especially when the new pair received no prior third-party funding and the transfer happened inside a single trade callback.
  • Treat permissionless market creation as part of the same security review as callback privileges. A harmless-looking anyone can create a pair feature becomes custody-relevant once callback-time helpers can retitle pooled inventory.
  • Review temporary lock / mutex variables as execution-frame credentials, not mere reentrancy guards. Ask what exact actor, pair, stage, and inventory bucket they authenticate before any privileged helper trusts them.

Failure-prevention checklist

  • Bind callback authority to one explicit frame object before external control leaves: pair id, active locker, allowed selector set, and the exact liquidity buckets the callback may touch.
  • Make take() and supply() consume pair-local inventory rather than pool-global balances. If a callback is entered for pair 3, it should not be able to move reserves economically owned by pair 2.
  • Reconcile settlement on provenance, not just totals: same aggregate token balances returned is weaker than the same pair kept ownership of the balances it started with.
  • Separate temporary trade-time liquidity access from supplier-right minting. A callback that borrows inventory for execution should not be able to rewrite who owns the withdraw claim for that inventory unless that exact rewrite was pre-committed.
  • Treat public pair creation plus callback-time privileged helper access as a combined authority surface. Permissionless market creation is only safe if newly created markets cannot inherit pooled custody rights over existing markets.
  • Add end-to-end tests for create new pair -> open callback -> move assets from old pair -> re-supply into new pair -> withdraw, and fail the build if aggregate-conservation checks still pass.
  • Keep emergency pause coverage on every path that can reassign supplier claims or move pooled liquidity across internal ledgers during callbacks.

Audit prompts

  • What exact fields does onlyByLocker or equivalent callback auth prove: merely some locker is active, or the specific pair, actor, stage, and inventory bucket this callback may mutate?
  • Can a callback opened for one pair call helper functions that read or write liquidity attributed to a different pair sharing the same tokens?
  • Does final settlement prove pair-level ownership conservation, or only that the pool’s raw token balances ended unchanged?
  • Which functions callable during callbacks can mint, burn, or transfer supplier rights, and are those rights bound to the same market whose liquidity was actually touched?
  • If a new pair is permissionlessly creatable, what stops it from becoming a fresh namespace that inherits temporary authority over legacy pooled assets?
  • Could a malicious callback leave the pool globally solvent in token-count terms while silently migrating withdrawable claims from one internal ledger to another?
  • What telemetry would distinguish legitimate in-callback liquidity movement from cross-pair custody reassignment before a later withdraw() cashes it out?
  • If the callback reverted after take() but before final settlement, which invariants guarantee that partially migrated provenance cannot survive?

Lessons that should already be standard

  • Aggregate balance conservation is not enough. If internal ownership moved from one pair to another, the theft already happened even when the pool still holds the same number of tokens.
  • A real callback from a legitimate in-flight trade can still be the wrong authority object. The question is not only was a locker active? but which pair and which inventory was that locker allowed to touch?.
  • Pair identity is a custody surface, not just product metadata. When supplier claims and withdrawal rights are pair-scoped, pair provenance has to survive every callback intact.
  • Permissionless market creation gets much more dangerous once temporary privileged callbacks can touch pooled inventory. Anyone can list a pair and callbacks can move shared liquidity compose into a custody problem, not just a UX feature.
  • Funds came back to the pool is a dangerously weak settlement story. Protocols need to know whether the same economic object came back to the same owner ledger.
  • Callback-time helper functions deserve the same review intensity as withdraw paths. In Predy, take() and supply() during a legitimate callback were already enough to rewrite who could later cash out real reserves.

Why this incident matters

  • It is a clean example of callback authority reuse where the exploit was not call back from the wrong sender but use a legitimate callback to exercise rights that were broader than the in-flight pair deserved.
  • It also preserves a subtle accounting lesson: a pool can remain globally solvent in raw token-count terms while still losing economically if pair-level entitlement is reassigned under a coarse aggregate balance check.
  • The case broadens the corpus beyond the more familiar fake-pool callback drains by showing that even honest callback senders are dangerous when temporary settlement authority is not scoped tightly enough.

Internal linkages

  • Core recurring pattern: callback-frame-authority-reuse.
  • Sharper positive-design companion: callback-frame-truth-security-object — Predy needed one callback frame binding pair id, active locker, allowed liquidity-movement surface, and the pair-level payout / ownership ledger that final settlement was supposed to preserve.
  • Adjacent accounting lens: balance-delta-provenance-failure — Predy is the cross-pair provenance-under-aggregate-conservation cousin: callback finalization treated pool balances ended unchanged as proof that the intended settlement happened, even though balances stayed real while pair-level provenance was silently migrated from one ledger to another.
  • Adjacent broader family: reentrancy — there is callback-driven control transfer here, but the preserved lesson is less state updated too late than the temporary callback capability was authenticated too coarsely.

Control surface