f(x) Protocol Double-Flash-Loan Stale-Callback-Context Victim-Position Drain Near Miss (2025-06-17)

  • Name: f(x) Protocol double-flash-loan stale-callback-context victim-position drain near miss
  • Date: 2025-06-17
  • Protocol: f(x) Protocol
  • Chain: Ethereum
  • Category: reentrancy / nested flash-loan callback / stale execution-context reuse / access-control bypass / approval-backrun near miss
  • Loss: no realized theft was publicly reported; ChainSecurity disclosed a live critical vulnerability that could have let attackers back-run victim NFT approvals, close victim CDPs, and sweep their collateral until the f(x) team removed Balancer flash-loan support and added additional hardening
  • Summary: f(x) Protocol’s router (0x33636D49FbefBE798e15e7F356E8DBef543CC708) supported both Morpho and Balancer flash-loan callback paths, but authenticated both with the same ambient LibRouter.flashLoanContext == HAS_FLASH_LOAN bit. In the normal close flow, closeOrRemovePositionFlashLoanV2() opened a Morpho flash loan and encoded msg.sender as the authorized recipient for the later self-call into onCloseOrRemovePositionFlashLoanV2(). The critical mistake was that the router assumed any later flash-loan callback arriving while that single bit stayed set belonged to the same trusted execution frame. ChainSecurity showed that an attacker could regain control during the swap leg of their own close, open a second Balancer flash loan before the first callback finished, and make Balancer’s genuine receiveFlashLoan() callback execute attacker-supplied calldata directly against onCloseOrRemovePositionFlashLoanV2(). By pointing that nested self-call at a victim position whose NFT had just approved the router, the attacker could make f(x) pull the victim NFT, close the victim CDP, leave the released collateral in router balance, then resume the outer attacker-owned close path and sweep that collateral out through the router’s final balanceOf(this) / convertAndTransferOut(..., msg.sender) payout step.
  • Trigger / entry point:
    • The vulnerable surface lived in the f(x) periphery router’s leveraged position-close flow, especially PositionOperateFlashLoanFacetV2.closeOrRemovePositionFlashLoanV2().
    • That path opened a flash loan, then relied on callback facets for Morpho and Balancer to dispatch the next privileged step by raw address(this).call(...) into router logic.
    • Both callback facets only checked msg.sender against their respective flash-loan provider and verified that one shared router storage flag still equaled HAS_FLASH_LOAN.
    • The privileged close hook onCloseOrRemovePositionFlashLoanV2() was gated only by onlySelf, so any approved callback facet that could trigger an internal self-call with attacker-shaped calldata could reach it.
    • Victims routinely granted the router approval to transfer their position NFT before calling close or remove flows, creating the exact approval surface the exploit needed to back-run.
  • Root cause:
    • f(x) used one ambient flashLoanContext bit as if it proved an entire execution envelope rather than merely proving that some router flash-loan frame was active.
    • The callback facets did not bind provider identity, expected callback payload, acting user, target position, or nonce-like execution state together before dispatching address(this).call(...).
    • onCloseOrRemovePositionFlashLoanV2() assumed that any self-call reaching it must have come from the router’s own outer wrapper, where recipient had already been hardcoded to msg.sender.
    • Nested flash loans broke that assumption: a second provider callback inherited the first provider’s still-live context bit and turned stale ambient state into authorization for a fresh attacker-supplied internal call.
  • Exploit path:
    1. The attacker watched for a victim transaction approving the f(x) router to transfer the victim’s position NFT.
    2. They opened a minimal attacker-controlled f(x) position so they had a legitimate outer position-close flow to wrap the exploit around.
    3. They called closeOrRemovePositionFlashLoanV2() on their own position with crafted swap parameters that, as described by ChainSecurity, let attacker-controlled code regain execution during the repayment / swap leg.
    4. The router invoked a Morpho flash loan and set the shared router flashLoanContext to HAS_FLASH_LOAN.
    5. Morpho’s callback entered onMorphoFlashLoan(), which saw the flag set and performed a raw self-call into onCloseOrRemovePositionFlashLoanV2() for the attacker’s outer position-close flow.
    6. During that outer close flow’s swap step, the attacker-triggered callback surface opened a second flash loan from Balancer before the Morpho-framed execution had unwound.
    7. Balancer’s receiveFlashLoan() callback was genuine, so the provider-origin check passed. Because the stale shared context bit was still set, the router also accepted this second callback as if it belonged to the original trusted flow.
    8. receiveFlashLoan() then executed attacker-supplied calldata through address(this).call(userData), this time targeting onCloseOrRemovePositionFlashLoanV2() with the victim’s position and the victim’s address as recipient.
    9. Inside that nested self-call, the router used the victim’s prior NFT approval to transferFrom(recipient, address(this), position), closed the victim’s CDP via IPoolManager.operate(...), and returned the NFT to the victim — but the released collateral stayed in router balance.
    10. Control returned to the attacker’s original outer close flow. Its final payout logic recomputed amountOut = IERC20(collateralToken).balanceOf(address(this)) and then sent that full balance to msg.sender, so the victim’s released collateral was swept out as if it were the attacker’s own close proceeds.
    11. The attacker repaid both flash loans within the same transaction and kept the victim collateral spread.
  • What the attacker needed to be true:
    • A victim had to expose a live router approval for their position NFT, typically by submitting an approval transaction that the attacker could back-run.
    • The router needed both the Morpho and Balancer flash-loan callback paths live at the same time.
    • One shared flashLoanContext bit had to be the only router-side proof that a callback belonged to the trusted in-flight operation.
    • The attacker needed a way, via the user-controlled swap / repayment leg highlighted by ChainSecurity, to regain execution during the first flash-loan callback and initiate the second one.
    • The final outer payout path had to treat router balance at the end of my close as equivalent to collateral released from my own position, even though nested execution could have pulled third-party collateral into that same balance.
  • Technical anatomy worth remembering:
    • This was not a fake-callback-origin bug in the usual spoofed pool sense. The Balancer callback really did come from Balancer. The failure was that f(x) reused stale ambient context from a different provider and treated it as sufficient authorization for a new internal call.
    • onlySelf was not meaningful protection once the callback facets themselves were willing to issue raw address(this).call(...) dispatch on attacker-shaped calldata.
    • The incident is a clean example of why a single shared inFlashLoan bit is too weak for composable routers. The real security object was provider + callback payload + acting account + target position + stage of execution, not one byte saying some flash loan exists.
    • The theft path depended on the router’s accounting choice to sweep all post-close collateral held by the router out to the outer caller. Once the nested call had closed the victim position, the attacker did not need a separate withdrawal primitive.
    • The exploit also preserved an approval-timing lesson: even NFT approvals that exist only briefly before an intended user action can be enough if a router can be tricked into substituting another user’s position inside its callback frame.
  • Representative contracts and artifacts:
    • Vulnerable f(x) router cited by ChainSecurity: 0x33636D49FbefBE798e15e7F356E8DBef543CC708
    • Vulnerable outer close entrypoint: PositionOperateFlashLoanFacetV2.closeOrRemovePositionFlashLoanV2()
    • Privileged inner close hook: PositionOperateFlashLoanFacetV2.onCloseOrRemovePositionFlashLoanV2()
    • Morpho callback facet: MorphoFlashLoanCallbackFacet.onMorphoFlashLoan()
    • Balancer callback facet: FlashLoanCallbackFacet.receiveFlashLoan()
    • Shared ambient context storage: LibRouter.RouterStorage.flashLoanContext
    • f(x) commit adding Morpho flash loans on top of the existing flash-loan architecture: 18355e17c4dd155bf373392f2d86da2032fcc59e (use morpho to flashloan)
    • f(x) mitigation commit removing flash loans and adding hardening: 31a3a90bf1179dd945148627602b149de9e8b963 (remove flashloan and add missing nonReentrant (#11))
  • Response / mitigation:
    • ChainSecurity privately disclosed the issue after identifying a live exploit path during internal research.
    • The f(x) team removed Balancer flash-loan integration, which cut off the nested cross-provider callback route ChainSecurity had weaponized in the proof of concept.
    • The published mitigation commit also notes added nonReentrant hardening and whitelist changes, reflecting that the problem was not just one bad caller check but a broader callback-surface design issue.
    • No public draining transaction is known because the issue was disclosed and mitigated before a live theft was reported.

Pattern tags

  • trusted callback origin and trusted in-flight execution frame were treated as the same security claim
  • one shared flash-loan context bit authenticated Morpho and Balancer callbacks that belonged to different provider / payload / victim-position stories
  • router self-call dispatch on attacker-shaped calldata turned onlySelf-gated logic into callback-rebound authority
  • outer-close payout swept ambient router collateral balance as if it were equivalent to proceeds from the caller's own position unwind

Why the exploit worked

  • f(x) authenticated a broad ambient condition (some flash loan is active) when the real security object was much narrower: this exact provider callback belongs to this exact user, position, stage, and payout bucket.
  • Because both callback facets accepted that same shared flashLoanContext bit and then issued raw address(this).call(...) dispatch, a genuine Balancer callback could inherit stale Morpho authority and rebind it to attacker-chosen calldata.
  • onlySelf did not defend onCloseOrRemovePositionFlashLoanV2() once the router’s own callback facets were willing to self-call arbitrary encoded payloads. The router effectively vouched for attacker-shaped inner calls as long as one coarse context flag stayed live.
  • The victim approval mattered because the nested close path still performed a legitimate-looking transferFrom(recipient, address(this), position). The exploit did not need to forge ownership; it only needed to swap the execution frame so the router consumed one user’s approval inside another user’s payout path.
  • The final cash-out depended on a second accounting shortcut: the outer close flow treated IERC20(collateralToken).balanceOf(address(this)) as if it represented only the attacker’s own proceeds. Once nested execution had stranded victim collateral in that same router balance, the outer flow turned ambient inventory into attacker-settleable value.

Detection and audit pivots

  • Inventory every callback-capable provider path in a router and ask whether provider-origin checks are also binding the same actor, position, nonce, stage, and expected calldata envelope, or merely proving that some approved lender called back.
  • Search for shared ambient booleans like HAS_FLASH_LOAN, inCallback, or isExecuting that authorize multiple providers or subflows. Those flags are usually too weak once callbacks can nest or compose.
  • Treat any router callback facet that performs raw address(this).call(userData) or equivalent self-dispatch as a privileged execution trampoline. Review it the same way you would review a generic arbitrary-call surface, because that is economically what it becomes when context binding is weak.
  • Follow payout provenance all the way to the end of the outer flow. If the last step sweeps balanceOf(this) or another ambient balance bucket, ask what stops nested execution from pulling third-party assets into that bucket before payout finalizes.
  • Model approval timing adversarially. Brief NFT or token approvals that exist only to support a legitimate user close are still exploitable if a router can substitute another user’s asset or position inside a callback-authenticated frame.
  • When adding a second flash-loan provider to an existing router architecture, re-audit the whole callback trust model rather than only the new provider-specific origin check. Multi-provider support often turns active flash loan from a local fact into a cross-provider authority leak.

Failure-prevention checklist

  • Bind flash-loan callbacks to one explicit execution-frame object: provider, caller, target position, authorized recipient, stage, nonce, and expected callback selector or payload hash.
  • Do not let multiple providers share one ambient authorization bit. Each provider callback should consume a provider-specific, single-use frame that cannot be inherited by nested or later callbacks.
  • Replace raw self-dispatch on attacker-shaped calldata with tightly typed internal entrypoints or pre-committed selector / argument bundles.
  • Keep payout buckets local to the committed operation. Amount released for this close is safer than whatever collateral token balance the router holds after nested execution.
  • Separate approval consumption from callback-authenticated frame reuse. A user approval for my close should not be reusable inside any other in-flight close, even if the router itself remains the approved spender.
  • Collapse temporary capability windows before attacker-influenced swap legs, nested flash-loan requests, or any other externally steerable delivery edge.
  • Fuzz nested-provider sequences like Morpho callback -> attacker regains control -> Balancer callback -> victim-position substitution -> outer balance sweep rather than only same-provider or same-function recursion.
  • Treat router integrations that add lenders, swappers, or callback adapters as re-authentication events for the entire execution envelope, not as isolated feature additions.

Audit prompts

  • What exact object proves that this callback belongs to the intended in-flight operation: provider only, or provider plus actor, target position, stage, and expected payload?
  • Can one genuine callback source inherit stale authorization from another provider or from an earlier stage of the same flow?
  • Which router facets can issue self-calls on attacker-shaped calldata, and what narrower machine-checkable frame constrains those calls?
  • Does any onlySelf or internal-only hook rely on the honesty of a callback trampoline that itself is authorized by a broad ambient bit?
  • Where does the router turn ambient balances into user payout, and could nested execution contaminate that bucket with third-party collateral first?
  • If a victim grants a temporary NFT or token approval for their own close, what concrete check prevents another user’s in-flight callback frame from consuming that approval?
  • When a new flash-loan or swap provider is added, which existing callback-context assumptions become cross-provider assumptions without anyone explicitly rewriting them?
  • If the outer flow reverts after a nested callback has moved victim collateral into router balance, which invariants guarantee that no later path can still sweep that inventory?

Lessons that should already be standard

  • Trusted lender called back is not enough. Routers need to authenticate the full in-flight execution frame, not just callback origin.
  • Shared inFlashLoan bits are design smells in composable routers. Once multiple lenders or nested flows exist, that flag usually authenticates far less than downstream code assumes.
  • onlySelf is not meaningful isolation if a callback adapter can make the contract call itself on attacker-shaped calldata.
  • Approval surfaces and callback surfaces compose. A victim’s short-lived approval can become a theft primitive when router context lets the attacker substitute whose position is being settled.
  • Ambient balance sweeps are dangerous settlement shortcuts. If the protocol cannot explain why balanceOf(this) contains only one operation’s proceeds, it has not really authenticated payout provenance.
  • Adding providers is not mere integration work. Each new callback source changes the authority graph of every old callback guard that shares state with it.

Why this incident matters

  • It preserves a modern router-layer reentrancy lesson that is easy to miss in reviews: callback came from a trusted lender and callback belongs to the exact in-flight operation I think it does are different security claims.
  • It also shows how periphery routers can become approval-amplified theft surfaces even when core CDP contracts are not directly broken. The victim only approved an NFT transfer for a legitimate close, but the router’s stale callback context let that approval be rebound into someone else’s execution frame.
  • The case is especially useful because the value extraction was mechanically crisp: close victim position inside nested callback, strand collateral in router balance, then let outer attacker flow claim that balance as ordinary proceeds.

Internal linkages

  • Core recurring pattern: callback-frame-authority-reuse.
  • Parent family: reentrancy.
  • Sharper positive-design companion: callback-frame-truth-security-object — the callback path needed one authenticated in-flight execution frame binding lender, acting account, target position, stage, and payout bucket, not a shared ambient boolean.
  • Ordering companion: temporary-capability-closure-before-external-delivery — the outer flash-loan capability should have been dead or sharply narrowed before the attacker-controlled swap leg and nested Balancer callback could try to consume it again.
  • Adjacent design lens: settlement-envelope-truth-security-object — useful for the neighboring question of what one protocol-authored settlement envelope downstream consumers should trust once the callback frame itself is already authentic.
  • End-to-end routing companion: route-commitment-across-callback-frame-and-settlement-envelope — useful when the sharper design question is how the outer Morpho/Balancer route should have kept callback-stage lender identity, victim-position scope, and later settlement / payout meaning pinned across one lifecycle rather than reusing one ambient flash-loan context bit.

Control surface