SushiSwap RouteProcessor2 Fake-Pool Callback / Approval Drain (2023-04-09)

  • Name: SushiSwap RouteProcessor2 fake-pool callback / approval drain
  • Date: 2023-04-09
  • Protocol: SushiSwap
  • Chain: multichain (Ethereum, Arbitrum, Arbitrum Nova, Avalanche, Boba, BNB Chain, Fantom, Fuse, Gnosis, Moonbeam, Moonriver, Optimism, Polygon, and Polygon zkEVM)
  • Category: router approval abuse / callback-origin authentication failure / fake-pool settlement spoof
  • Loss: about 600k later rescued by HYDN and Sushi citing roughly 1,800 WETH drained from the main initial victim alone
  • Summary: Sushi’s newly deployed RouteProcessor2 router let user-supplied route bytes choose the Uniswap-v3-style pool address to call. The router then stored that attacker-chosen address in lastCalledPool and treated any later uniswapV3SwapCallback() from that address as legitimate. An attacker could therefore point the router at a fake pool they controlled, reenter the callback, and make RouteProcessor2 execute safeTransferFrom(victim, msg.sender, amount) against wallets that had already approved the router. The protocol did not need to custody victim assets first. Its spender authority plus an attacker-chosen callback counterparty was enough.
  • Trigger / entry point:
    • Sushi soft-launched RouteProcessor2 on April 8, 2023 as a new cross-pool router for frontend swaps and deployed it across 14 EVM networks.
    • On Ethereum mainnet, the vulnerable router address was 0x044b75f554b886a065b9567891e45c79542d7357.
    • processRoute() accepted offchain-generated route bytes and eventually reached swapUniV3() when the route selected pool type 1.
    • In swapUniV3(), RouteProcessor2 read the pool address directly from the route bytes, assigned it to lastCalledPool, and called IUniswapV3Pool(pool).swap(...).
    • In uniswapV3SwapCallback(), the only origin check was require(msg.sender == lastCalledPool), after which the router decoded (tokenIn, from) and executed safeTransferFrom(from, msg.sender, amount) when from != address(this).
  • Root cause:
    • Sushi let attacker-shaped route bytes choose the pool contract whose callback would later be trusted.
    • The intended security boundary for a Uniswap v3-style callback is not merely the same address called back; it is the canonical pool for the exact swap context called back.
    • RouteProcessor2 never recomputed or authenticated a canonical pool from a trusted factory, fee tier, token ordering, or pre-recorded swap intent. It only compared the callback sender to the same attacker-selected address it had just stored.
    • That made the lastCalledPool check circular. If the attacker could choose the pool, they could also choose who passed the callback check.
    • Once the callback was accepted, the router’s pre-existing ERC-20 allowance authority over user wallets turned the fake settlement into a direct wallet drain.
  • Exploit path:
    1. The attacker identified wallets that had already approved RouteProcessor2 as an ERC-20 spender.
    2. They deployed or used a malicious contract that could impersonate the pool side of a Uniswap-v3-style swap flow.
    3. They called processRoute() with route bytes selecting their malicious contract as the pool for swapUniV3().
    4. RouteProcessor2 set lastCalledPool to that attacker-controlled address and invoked its swap() function.
    5. The fake pool immediately reentered uniswapV3SwapCallback() and supplied attacker-chosen settlement context so the router believed it owed token payment for the swap.
    6. Because msg.sender matched the attacker-chosen lastCalledPool, the callback passed authentication.
    7. RouteProcessor2 decoded the victim payer information and executed safeTransferFrom(victim, msg.sender, amount), pulling approved tokens directly out of the victim wallet into the attacker-controlled contract.
    8. The exploit was replayed across wallets and chains until public warnings, revocations, MEV competition, and whitehat rescues reduced the remaining surface.
  • What the attacker needed to be true:
    • Victims needed live approvals to RouteProcessor2 large enough to make theft worthwhile.
    • The route encoding had to let the attacker name an arbitrary pool for the Uniswap-v3 swap path.
    • Callback authentication had to rely on lastCalledPool instead of a canonical-pool derivation or a tightly bound in-flight swap record.
    • The callback settlement path had to keep trusting decoded (tokenIn, from) payer data strongly enough to execute safeTransferFrom() against the victim.
    • The router’s immutable, non-pausable deployment meant responders could not simply freeze the contract while users revoked approvals.
  • Technical anatomy worth remembering:
    • This was not a generic arbitrary-call bug in the Socket / LI.FI sense. The decisive money path was a fake settlement callback.
    • The router’s lock() modifier did not save it because uniswapV3SwapCallback() was intended to be reachable during the swap; the attacker only needed to make the router believe their contract was the legitimate pool.
    • The critical design mistake was turning lastCalledPool into an authority oracle even though the route bytes themselves chose the pool.
    • In approval-funded-route terms, Sushi never kept one route-local authority object binding the canonical pool, the victim payer, and the meaning of the settlement hop before transferFrom() became reachable.
    • Public reproduction code made the exploit mechanics unusually legible: the attacker-controlled pool’s swap() implementation could directly invoke RouteProcessor2.uniswapV3SwapCallback(...) with attacker-chosen token and payer data.
    • The whitehat response used the same exploit path in reverse: rescue teams raced attackers and MEV bots to drain vulnerable balances into recovery wallets before blackhats could do the same.
  • Representative transactions and addresses:
    • Vulnerable Ethereum RouteProcessor2 router: 0x044b75f554b886a065b9567891e45c79542d7357
    • HYDN / Sushi-labeled whitehat recovery wallet: 0x74ebb8e8d0b0cc65f06040eb0f77b5da0e33ffee
    • Public Tenderly debugger tx used in the widely shared exploit reproduction repository: 0xea3480f1f1d1f0b32283f8f282ce16403fe22ede35c0b71a732193e56c5c45e8
    • Example post-exploit MEV reward transaction cited by Sushi: 0x04b166e7b4ab5105a8e9c85f08f6346de1c66368687215b0e0b58d6e5002bc32
  • Response / mitigation:
    • Sushi rolled back frontend upgrades that routed users into RouteProcessor2 and publicly urged users to revoke approvals immediately.
    • Because the deployed contract was immutable and could not be paused, Sushi and HYDN chose whitehat rescue and cross-chain watcher deployment rather than an onchain freeze.
    • HYDN reported rescuing more than 750k had been saved across multiple networks and that recovered ETH was being routed through a claims process.
    • Sushi moved toward a fixed router version and explicitly highlighted stricter review, less reliance on unlimited approvals, and better rollout discipline as lessons from the incident.

Pattern tags

  • user-supplied route bytes chose the pool first, so the later last-called-pool callback check only re-accepted attacker-authored counterparty identity instead of proving one canonical pool for the hop
  • callback-origin truth and payer truth never stayed fused, because the same fake settlement callback that authenticated the pool also kept authority over which approved wallet would satisfy the debt
  • real ERC-20 allowance authority on victim wallets turned a counterfeit Uniswap-v3-style settlement obligation into an immediate wallet drain without requiring protocol treasury custody
  • immutable high-activity router plus dormant infinite approvals converted one callback-authentication bug into a revocation race and whitehat mempool war rather than a clean pause-and-patch event

Why the exploit worked

  • Sushi’s own postmortem and the published RouteProcessor2 source line up on the decisive mistake: swapUniV3() read the pool address directly from route bytes, wrote that attacker-chosen address into lastCalledPool, and uniswapV3SwapCallback() then treated msg.sender == lastCalledPool as sufficient proof that settlement was legitimate. That was a circular authenticity story, not a canonical-pool proof.
  • Once the attacker could choose the pool, the router’s callback gate became attacker-authored policy. The later callback no longer proved this exact canonical Uniswap v3 pool for this in-flight hop is asking to be paid; it only proved the same address the attacker already told us to trust is now calling back.
  • Because RouteProcessor2 already held spender authority through prior user approvals, the exploit did not need a generic arbitrary-call surface or protocol escrow. Accepting one fake settlement callback was enough to make ordinary safeTransferFrom() logic pull real assets from approved wallets into the attacker-controlled pool.
  • The healthier preserved lesson is therefore sharper than be careful with approvals: Sushi let route planning, callback counterparty identity, and wallet-spend authority drift apart, so a plausible-looking settlement edge could directly monetize dormant wallet allowances.

Three callback-and-approval promotions this incident collapsed

  1. A route-selectable pool-like counterparty could become the next callback source at all.
    • processRoute() let attacker-shaped route bytes choose the pool passed into swapUniV3(), and the router then stored that address as lastCalledPool before leaving trusted control flow.
    • That only answered some pool-shaped object is now in the callback slot, not this is the one canonical Uniswap v3 counterparty Sushi intended to trust for this hop.
  2. That attacker-selected counterparty was over-promoted into canonical settlement truth for the hop.
    • uniswapV3SwapCallback() accepted msg.sender == lastCalledPool even though the same route bytes had already authored lastCalledPool in the first place.
    • The real authentication break was therefore not just a malicious pool exists; it was the router treated the same attacker-authored address as the canonical hop counterparty and debt-defining settlement source.
  3. That accepted callback story still inherited live wallet-spend authority through standing approvals.
    • Once the fake callback was admitted, RouteProcessor2 could settle the invented debt with ordinary safeTransferFrom(victim, msg.sender, amount) logic against any wallet that had already approved the router.
    • The final authority jump was from this callback looks settleable to this router may now consume real allowance from this exact wallet for this exact fake hop, which is why a counterfeit pool identity became an immediate user-balance drain instead of a harmless failed trade.

Detection and audit pivots

  • Trace every callback-capable swap path that can reach transferFrom() and ask what exact locally committed fact proves the pool, payer, token semantics, and route stage were all fixed before control left the router.
  • Flag any design where route bytes or callback data both choose the pool and later prove the pool. If the callback check only replays attacker-shaped counterparty identity through storage like lastCalledPool, the authenticity story is probably circular.
  • Compare direct-pool integrations against the safer upstream router assumptions developers think they inherited. If the optimization removed factory-derived pool recomputation or a stronger in-flight route object, verify what replaced it.
  • Treat immutable spender routers as wallet-custody surfaces. Reviewers should ask not only can this callback move protocol balances? but which already-approved user wallets become reachable if this callback is admitted once?
  • Include incident-response design in review: if the callback path is public, allowance-backed, and non-pausable, can responders disable one pool type or one route family quickly, or is the only defense a mass revocation and whitehat race?

Why this incident matters

  • It is one of the clearest early examples of a router approval drain where the decisive trust failure lived in callback authentication, not just in a naked arbitrary .call().
  • It preserves a distinct cousin of the later ParaSwap bug: Sushi trusted an attacker-chosen pool because route bytes themselves decided who the callback sender should be.
  • It also shows why immutable high-activity spender contracts are dangerous: once an approval-drain bug is live, responders may be forced into mempool races and user-revocation campaigns instead of a clean pause.

Failure-prevention checklist

  • Recompute the canonical callback counterparty from trusted state before honoring settlement. If a route chose a Uniswap v3 pool, the callback should prove the exact factory, token ordering, fee tier, and hop semantics Sushi committed before the external call rather than replaying a remembered attacker-chosen address.
  • Keep one route-local authority object that binds pool identity, payer identity, token semantics, and hop meaning before any callback path can reach transferFrom(). The healthy question is not only who is calling back? but what exact debt are they entitled to settle, for which exact wallet?
  • Reject route formats where the same untrusted bytes both select the pool and later help authenticate that pool. Route bytes picked it and route bytes proved it is a circular trust story.
  • Narrow spender blast radius by default. High-frequency routers should prefer per-swap approvals, Permit2-style bounded allowances, or other expiring / route-scoped spend rights instead of long-lived infinite approvals whenever UX and chain costs allow.
  • Preserve a fast kill path for live spender surfaces. If a router is meant to touch user wallets directly, operators should already know whether they can pause it, disable one pool family, or revoke a route type without redeploying the entire integration surface.
  • Fuzz counterfeit-pool scenarios explicitly: attacker-chosen pool address, callback payer rebinding, token-order flips, and malicious callback payloads that try to spend allowances from wallets unrelated to the initiating swap.
  • Treat rescueability as part of design review. If the fallback for a spender bug is only tell users to revoke plus whitehat mempool competition, the router is carrying operational risk that should have been acknowledged before launch.

Audit prompts

  • What exact locally committed fact proves this callback came from the canonical pool for this hop rather than from the same address the route bytes already told us to trust?
  • Can route bytes, callback bytes, or helper-decoded storage rebind the payer wallet after control leaves the router?
  • If transferFrom() becomes reachable in a callback, which wallets can it reach: only route-local escrow, or any user that ever approved the router?
  • Does the callback recompute pool identity from trusted factory and route semantics, or merely compare msg.sender against a stored attacker-influenced address such as lastCalledPool?
  • What would fail closed if the callback arrived from the wrong pool, with the right pool but wrong payer, or with the right payer but wrong token ordering?
  • If this router shipped across many chains at once, what is the concrete blast-radius control: per-chain pause, per-pool-type disable, approval-scope reduction, or only user revocation campaigns?
  • Did any gas optimization remove a stronger route-authentication layer that the team implicitly assumed still existed?
  • Are incident runbooks written for the case where the vulnerable spender contract is immutable, non-pausable, and already widely approved?

Lessons that should already be standard

  • A callback sender is not authenticated just because the same route bytes selected it earlier. If untrusted input chooses the pool and later lastCalledPool proves the pool, the check is circular.
  • Approval-backed routers are custody systems whether or not they custody funds first. Dormant wallet allowances turn callback bugs into direct wallet drains.
  • Direct spender authority and callback truth have to stay fused. The moment a callback can redefine both who is calling and who pays, the protocol is authenticating the wrong object.
  • Immutable public routers deserve more rollout caution than ordinary stateless helpers. A bug in an immutable, already-approved spender becomes an incident-response race, not just a patch release.
  • Whitehat rescue is not a substitute for kill switches or bounded approvals. It is an emergency tactic with mempool, MEV, and replay risk.
  • Multi-chain soft launches compound approval-surface risk. One bad callback-authentication design can create simultaneous revocation problems across every chain where users were nudged into granting approvals.
  • The clean design question is blunt: which exact pool is entitled to ask this router to spend which exact wallet’s tokens right now, and how was that proven before control returned?

Internal linkages

  • Core recurring pattern: callback-origin-authentication-failure.
  • Positive-design settlement companion: settlement-envelope-truth-security-object — the router never preserved one authenticated settlement object binding the canonical pool, victim payer, token semantics, and callback stage before safeTransferFrom() became reachable.
  • End-to-end route-design companion: route-commitment-across-callback-frame-and-settlement-envelope — RouteProcessor2 let attacker-shaped route bytes choose both the callback frame counterparty and the downstream payer semantics instead of pinning one canonical pool / payer object across the hop.
  • Approval-funded route companion: approval-funded-route-authority-truth-security-object — the missing middle layer was a route-local authority object that should have kept approved spender identity, canonical pool counterparty, victim payer, and settlement meaning fused before the callback could exercise wallet allowance.
  • Adjacent pattern: arbitrary-call-token-approval-drain — both families monetize stale approvals already granted to a router, but Sushi reached the theft primitive through fake-pool callback settlement rather than a generic low-level external call.

Control surface