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 ambientLibRouter.flashLoanContext == HAS_FLASH_LOANbit. In the normal close flow,closeOrRemovePositionFlashLoanV2()opened a Morpho flash loan and encodedmsg.senderas the authorizedrecipientfor the later self-call intoonCloseOrRemovePositionFlashLoanV2(). 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 genuinereceiveFlashLoan()callback execute attacker-supplied calldata directly againstonCloseOrRemovePositionFlashLoanV2(). 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 finalbalanceOf(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.senderagainst their respective flash-loan provider and verified that one shared router storage flag still equaledHAS_FLASH_LOAN. - The privileged close hook
onCloseOrRemovePositionFlashLoanV2()was gated only byonlySelf, 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.
- The vulnerable surface lived in the f(x) periphery router’s leveraged position-close flow, especially
- Root cause:
- f(x) used one ambient
flashLoanContextbit 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, whererecipienthad already been hardcoded tomsg.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.
- f(x) used one ambient
- Exploit path:
- The attacker watched for a victim transaction approving the f(x) router to transfer the victim’s position NFT.
- They opened a minimal attacker-controlled f(x) position so they had a legitimate outer position-close flow to wrap the exploit around.
- 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. - The router invoked a Morpho flash loan and set the shared router
flashLoanContexttoHAS_FLASH_LOAN. - Morpho’s callback entered
onMorphoFlashLoan(), which saw the flag set and performed a raw self-call intoonCloseOrRemovePositionFlashLoanV2()for the attacker’s outer position-close flow. - 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.
- 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. receiveFlashLoan()then executed attacker-supplied calldata throughaddress(this).call(userData), this time targetingonCloseOrRemovePositionFlashLoanV2()with the victim’spositionand the victim’s address asrecipient.- 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 viaIPoolManager.operate(...), and returned the NFT to the victim — but the released collateral stayed in router balance. - 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 tomsg.sender, so the victim’s released collateral was swept out as if it were the attacker’s own close proceeds. - 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
flashLoanContextbit 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 closeas equivalent tocollateral 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 poolsense. 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. onlySelfwas not meaningful protection once the callback facets themselves were willing to issue rawaddress(this).call(...)dispatch on attacker-shaped calldata.- The incident is a clean example of why a single shared
inFlashLoanbit is too weak for composable routers. The real security object wasprovider + callback payload + acting account + target position + stage of execution, notone 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.
- This was not a fake-callback-origin bug in the usual
- 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))
- Vulnerable f(x) router cited by ChainSecurity:
- 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
nonReentranthardening 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 claimone shared flash-loan context bit authenticated Morpho and Balancer callbacks that belonged to different provider / payload / victim-position storiesrouter self-call dispatch on attacker-shaped calldata turned onlySelf-gated logic into callback-rebound authorityouter-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
flashLoanContextbit and then issued rawaddress(this).call(...)dispatch, a genuine Balancer callback could inherit stale Morpho authority and rebind it to attacker-chosen calldata. onlySelfdid not defendonCloseOrRemovePositionFlashLoanV2()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, orisExecutingthat 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 loanfrom 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 closeis safer thanwhatever collateral token balance the router holds after nested execution. - Separate approval consumption from callback-authenticated frame reuse. A user approval for
my closeshould 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 sweeprather 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
onlySelfor 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 backis not enough. Routers need to authenticate the full in-flight execution frame, not just callback origin.- Shared
inFlashLoanbits are design smells in composable routers. Once multiple lenders or nested flows exist, that flag usually authenticates far less than downstream code assumes. onlySelfis 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 lenderandcallback belongs to the exact in-flight operation I think it doesare 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
-
The decisive leverage sat in the shared
LibRouter.flashLoanContextflag, the rawaddress(this).call(...)callback dispatch in both flash-loan callback facets, the attacker-recoverable control flow inside the swap leg, the victim’s prior NFT approval to the router, and the outer payout logic that swept the router’s entire remaining collateral balance to the attacker’smsg.sender. -
The dangerous mistake was not merely
nested flash loans exist. It was letting a second flash-loan callback inherit the first one’s authority and then treating whatever collateral was left in router balance as if it necessarily belonged to the outer caller’s own position unwind. -
Sources:
- https://www.chainsecurity.com/blog/f-x-protocol-circumventing-access-control-with-a-double-flash-loan-attack
- https://github.com/AladdinDAO/fx-protocol-contracts/blob/a7e203efc8f3579a6e9aea34e7540aba9323f269/contracts/periphery/facets/PositionOperateFlashLoanFacetV2.sol
- https://github.com/AladdinDAO/fx-protocol-contracts/blob/a7e203efc8f3579a6e9aea34e7540aba9323f269/contracts/periphery/facets/MorphoFlashLoanCallbackFacet.sol
- https://github.com/AladdinDAO/fx-protocol-contracts/blob/a7e203efc8f3579a6e9aea34e7540aba9323f269/contracts/periphery/facets/FlashLoanCallbackFacet.sol
- https://github.com/AladdinDAO/fx-protocol-contracts/blob/a7e203efc8f3579a6e9aea34e7540aba9323f269/contracts/periphery/facets/FlashLoanFacetBase.sol
- https://github.com/AladdinDAO/fx-protocol-contracts/blob/a7e203efc8f3579a6e9aea34e7540aba9323f269/contracts/periphery/libraries/LibRouter.sol
- https://github.com/AladdinDAO/fx-protocol-contracts/commit/18355e17c4dd155bf373392f2d86da2032fcc59e
- https://github.com/AladdinDAO/fx-protocol-contracts/commit/31a3a90bf1179dd945148627602b149de9e8b963
-
Last reviewed: 2026-07-20 UTC