Callback Frame Truth as a Single Security Object

  • Name: callback frame truth as a single security object
  • Category: callback-systems reference / in-flight execution-authenticity checklist / router-and-helper design note / temporary-capability binding reference
  • Summary: Healthy callback-driven systems do not ask themselves only did a trusted contract call back? They ask a stronger question: does this callback belong to the exact in-flight execution frame we meant to resume? That frame is a single security object: the canonical callback source or provider, the acting account on whose behalf the protocol is operating, the exact target position / order / market / account being touched, the stage-specific privilege window that is supposed to be open, the exact payload or route segment the callback may resume, and the balance bucket, ownership ledger, or payout path that may be mutated before control returns. This note exists to keep that positive design target explicit instead of leaving readers to infer it only by inverting incidents like f(x) Protocol, Predy Finance, GMX V1, or Exactly Protocol.
  • Why this note exists:
    • Several modern callback incidents do not fail because the callback caller was obviously fake; they fail because a real callback inherited stale or overly broad authority from a different in-flight operation.
    • A boolean like inFlashLoan, isLeverageEnabled, or this helper is currently active is usually too weak once routers, helpers, or keeper paths compose multiple providers, multiple targets, or multiple stages.
    • The useful design question is often not was the callback trusted in isolation? but what exact execution frame was this callback supposed to resume, and what locally checkable fields proved that?

The six joints a healthy callback frame should keep fused

  1. Canonical callback source

    • The protocol needs one exact answer to which lender, router, pool, keeper path, token hook, or bridge adapter is allowed to resume this frame.
    • msg.sender is a known provider is not enough if multiple providers can share one ambient context bit or one temporary capability window.
    • This is the joint f(x) lost when a genuine Balancer callback inherited Morpho-framed authority.
  2. Acting account / beneficiary identity

    • The callback frame must bind who the protocol is acting for: the user, vault, position owner, liquidator, or delegated helper beneficiary.
    • Healthy systems do not let a callback inherit a stale recipient, account, or approval surface from a previous stage or a counterfeit endpoint.
    • This is the joint that matters when helper contracts can rebound from msg.sender to a victim account mid-flow, as in Exactly Protocol.
  3. Target object identity

    • The frame must bind which exact position, order, vault, market, loan, or NFT approval the callback may touch.
    • A trusted callback source does not imply authority over every live object the protocol can operate on.
    • The important question is which exact object was this callback expected to settle or mutate, and why only that one?
  4. Stage-specific capability window

    • The frame must bind what temporary privilege is open right now: flash-loan repayment, keeper order execution, liquidation settlement, deleverage unwind, or refund handling.
    • Healthy systems treat that privilege as narrow and stage-local, not as a generic protocol is currently busy bit.
    • If one module opens the privilege and another module can spend it, both modules still need to agree on the same frame identity and lifecycle. A callback should not be able to reach only one half of a supposedly atomic multi-module transition.
    • This is the joint GMX V1 lost when an ETH refund callback arrived while isLeverageEnabled was still open and could therefore call Vault.increasePosition() directly.
  5. Exact callback payload or resume path

    • The frame must bind which internal function, route step, or callback payload the external call is allowed to resume.
    • the callback can self-call into the router is too broad when the payload still determines acting account, target object, or payout meaning.
    • A healthy system does not let attacker-shaped calldata pick a fresh privileged internal path merely because some trusted callback is in flight.
  6. Post-callback balance and payout provenance

    • The frame must bind which balances or accounting deltas belong to this in-flight operation when control returns.
    • whatever balance the router or vault holds at the end is not a safe payout rule if nested execution could have pulled third-party collateral or corrupted cross-module accounting into the same bucket.
    • Cross-module accounting marks that later feed price, solvency, or payout logic must also stay frame-atomic. If one callback can mutate size in one contract without the paired average price, debt mark, or ownership ledger update in another, the protocol has already let the frame split into incompatible economic stories.
    • This is the joint f(x) lost when the outer close flow swept all router-held collateral, and the joint GMX lost when GLP AUM trusted split short-accounting state as if it all belonged to one consistent order-execution frame.

Three callback-settlement questions reviewers should not collapse

  1. Did a real callback for a live frame arrive?

    • This is the familiar first question: is the provider genuine, is the stage open, and is the protocol even supposed to be resuming a callback right now?
    • f(x), GMX, and Exactly all show why stopping here is not enough.
  2. Did that frame deserve authority over this exact inventory, market, pair, or sub-ledger?

    • A genuine callback does not automatically get to touch every pool balance, every victim account, or every live market that shares the same helper.
    • Predy is the crisp reminder that real locker is active is still weaker than this locker may move only this pair's liquidity bucket.
  3. Did settlement preserve the same payout meaning and ownership scope, rather than merely returning the same aggregate balances?

    • The pool ended with the same tokens, the router still looks solvent, or the outer function can finish is not yet proof that the same actor, pair, position, or ledger still owns the resulting withdrawal right.
    • The callback frame is still broken if a real callback can quietly migrate value from one internal owner ledger to another and final checks only notice whole-contract conservation.

What sound design looks like

  • One callback source, one actor, one target, one stage, one payload meaning, one payout meaning

    • The protocol should be able to answer all six questions explicitly at callback time instead of reconstructing them from ambient state and attacker-shaped calldata.
  • The frame is recorded before external control leaves

    • Trusted local state for the in-flight frame should be written before the external call or refund path hands control away.
    • If the protocol tries to infer the frame only after callback re-entry has begun, it is already trusting the wrong object.
  • The frame is consumed once

    • A callback frame should behave like a single-use capability with a nonce-like lifecycle: open, consume once, close.
    • Nested or repeated callbacks should not be able to inherit the same authority unless the protocol intentionally opens a new frame with new committed fields.
    • In multi-contract flows, consume once has to mean every contract whose state the frame authenticates consumes the same frame, not that one contract clears its bit while another still accepts the old context.
  • Temporary privilege should be narrower than module-wide liveness

    • leverage is enabled, flash loan is active, or helper is executing is usually not the right privilege object.
    • The safer object is provider X may call function Y once for actor A, target T, stage S, with payload hash H, and only mutate payout bucket B.
  • Payout logic spends frame-local deltas, not ambient balances

    • If the protocol pays out by raw balanceOf(this) or trusts a cross-module mark that another callback could skew mid-frame, the payout surface is part of the callback-authenticity problem.
    • Healthy systems separate assets touched by this frame from assets merely present when the frame ended.
    • Healthy systems also separate aggregate balances came back from the same pair / position / beneficiary still owns the resulting payout right.
    • The same rule applies to modular accounting state: if redemption, solvency, or AUM trusts a bundle of values across several contracts, that bundle needs one shared callback-frame story or it is not really frame-local.

Useful positive design shapes

1. Frame-hash or slot-committed callback state

  • Before making the external call, commit one explicit frame object in storage: provider, actor, target, stage, allowed internal selector or payload hash, and payout bucket.
  • The callback then proves it is consuming that object, not merely that some callback-capable stage is active.
  • This is the callback-side analog of route-hash commitment and timelock action-bundle commitment.

2. Provider-specific callback slots instead of one ambient bit

  • If multiple lenders, routers, or callback-capable providers exist, each should have a separate committed frame or a provider-bound discriminator.
  • some flash loan exists is weaker than Morpho may now call this exact callback once for position 123 owned by user U.
  • This is the clean design answer to f(x)-style cross-provider frame confusion.

3. Target and actor written before the callback, never decoded from it

  • The callback should not get to redefine which user, which NFT approval, which order, or which market is being acted on.
  • Those fields belong in trusted pre-committed state, not in calldata the callback supplies on arrival.
  • This is the clean design answer to helper-layer actor rebinding and victim-position substitution.

4. Close capability windows before refunds or user-directed transfers

  • If the protocol must issue ETH refunds, token sends, or other user-directed transfers during a privileged path, the temporary privilege should close before those transfers happen whenever possible.
  • A refund that occurs while the privileged frame is still open is not a harmless UX step; it is another callback entrypoint inside the privileged window.
  • This is the clean design answer to GMX V1-style capability leakage through refund paths.

5. Selector allowlists for internal self-calls from callbacks

  • If a callback must dispatch into internal logic, it should do so through a tightly bounded selector set with pre-committed arguments or hashes.
  • A generic address(this).call(userData) inside a trusted callback usually means the protocol is authenticating the wrong object.
  • Trusted callback origin is not a substitute for trusted callback meaning.

6. Frame-local asset buckets and settlement accounting

  • The protocol should track which collateral, debt, refund, or output amount belongs to the current frame, instead of sweeping whatever assets happen to sit in contract balance at the end.
  • This matters even when callback origin is real. A real callback can still drag unrelated value into the same contract or desynchronize accounting elsewhere before final payout.
  • A safe frame ends by paying the delta it produced, not by emptying the room it happens to be standing in.

Design questions worth asking first

  • What exact callback frame is supposed to be live right now?
  • Which fields of that frame are committed before external control leaves?
  • Could a second provider, a nested callback, or a refund path inherit the same authority without opening a fresh frame?
  • Can the callback redefine the acting account, target object, or internal selector through attacker-shaped calldata?
  • Does the temporary privilege close before any user-directed transfer, refund, or external hook?
  • When the frame ends, does payout come from frame-local accounting or from ambient balances / cross-module state that another callback could have polluted?

Red flags that usually mean the protocol is authenticating the wrong callback object

  • msg.sender is a trusted flash-loan provider with no statement about which specific operation it is resuming
  • inFlashLoan == true or isLeverageEnabled == true used as if that proved actor, target, and stage
  • onlySelf or router-internal dispatch relied on after an attacker-controlled callback still chooses the self-call payload
  • the callback can decode the account or position it needs from userData when those values were never pre-committed locally
  • we settle by whatever balance is left afterward used as if nested callbacks cannot pull in third-party value
  • the caller is a real keeper / lender / router used as if that alone proved the in-flight execution frame is the intended one

Failure-prevention checklist

  • Commit one explicit callback-frame object before any external call leaves the protocol: provider, actor, target, stage, allowed selector or payload hash, and payout bucket.
  • Replace ambient inFlashLoan, isLeverageEnabled, isExecuting, or onlySelf assumptions with provider-specific, stage-specific, single-use callback capabilities.
  • Bind actor identity and target object in trusted local state before the callback, and refuse any callback path that tries to source those fields from attacker-controlled calldata on arrival.
  • Close temporary privilege windows before refunds, ETH sends, token sends, or other user-directed transfers whenever the path can be structured that way.
  • If a callback must self-dispatch, constrain it to a narrow selector allowlist plus pre-committed arguments or a payload hash; do not let the callback choose fresh privileged meaning.
  • Settle from frame-local deltas, escrow buckets, or explicitly tracked balance changes rather than balanceOf(this) or other ambient balances that nested execution can pollute.
  • Reconcile callback completion on ownership scope as well as token totals. The same aggregate balances came back is weaker than the same actor / pair / position kept the withdrawal or claim right those balances were meant to back.
  • If several contracts contribute state to the same priced or spendable outcome, bind those writes to one shared frame identity so a callback cannot mutate only part of the economic object the protocol later trusts.
  • Consume callback frames exactly once and clear them on both success and failure paths; retries or nested callbacks should need a fresh committed frame.
  • Separate provider namespaces so one real callback source cannot inherit authority from a different lender, router, keeper, or helper flow that happened to be active earlier in the transaction.
  • Treat refund paths, receiver hooks, permit callbacks, and helper-market callbacks as first-class re-entry surfaces in design and testing, not as harmless UX plumbing.
  • Fuzz and adversarially simulate multi-provider nesting, stale-context reuse, counterfeit actor rebinding, and payout-bucket pollution in the same transaction.

Audit prompts

  • What exact callback frame does this path believe is live right now, and where is each field of that frame committed before external control leaves?
  • Which fields are proven by trusted local state versus re-decoded from callback calldata, msg.sender, or other attacker-influenced context on return?
  • Can two providers share one ambient execution bit or one temporary capability window, letting a real callback from provider B inherit authority opened for provider A?
  • Can the callback switch actor, target position, beneficiary, order id, or internal selector without violating any locally committed hash or slot?
  • Does any refund, ETH send, receiver hook, or helper-side transfer happen while the privileged frame is still open?
  • When the path settles, does it spend frame-local deltas or whatever balances and accounting marks happen to exist after nested execution finishes?
  • Could the callback leave the protocol whole at aggregate token-count level while silently migrating supplier rights, withdrawal rights, or pair-local ownership to a different internal ledger?
  • If one contract opens the callback frame and another contract updates pricing, debt, or share-accounting state during that frame, what proves they are consuming the same operation rather than a leaked partial resume path?
  • What clears the frame on revert, partial completion, retry, or keeper failure, and can stale context survive long enough for a second callback to consume it?
  • Are there any onlySelf, router-internal dispatch, or helper callbacks whose real security boundary is actually trusted payload meaning, not merely trusted caller?
  • If a callback is genuine but arrives at the wrong stage, from the wrong provider, or for the wrong actor/target pair, what exact check fails closed?
  • Which production telemetry would show callback-frame drift early: overlapping provider windows, repeated frame consumption attempts, unexpected selector use inside callbacks, or payouts that exceed frame-local deltas?

Lessons that should already be standard

  • Trusted callback origin is not the security property; trusted callback frame is. Real providers can still resume the wrong in-flight operation.
  • Ambient liveness bits are almost always too weak for modern routers and helpers. If one boolean authenticates many stages or providers, the protocol is already over-trusting context.
  • Refunds and user-directed sends are callback surfaces whenever temporary authority is still open. UX plumbing does not sit outside the threat model.
  • Actor identity, target identity, and payout meaning belong to pre-committed local state, not to calldata interpreted after re-entry begins.
  • onlySelf is not a defense if attacker-controlled bytes still decide what the self-call means.
  • Settlement-by-ambient-balance is callback-frame corruption in disguise: it lets unrelated value that entered during nested execution inherit the authority of the current frame.
  • Same balances came back is not the same thing as the same payout meaning survived. Aggregate conservation can still hide stolen pair-local, position-local, or beneficiary-local ownership.
  • Multi-provider composition is a first-class security problem. A protocol that safely handles one flash-loan or keeper provider can still fail the moment two real providers share one context object.
  • Cross-contract modularity is a first-class callback risk too. Once one frame’s meaning is split across several contracts, every mark later trusted for pricing, solvency, or payout needs the same frame identity and closure discipline.
  • A callback capability should look more like a single-use signed ticket than like the router is busy right now.

Negative exemplar cluster: what broken callback-frame truth looks like

  • f(x) Protocol is the clean trusted callback, wrong in-flight flash-loan frame exemplar: a genuine Balancer callback inherited a stale Morpho-flash-loan context bit, attacker-shaped self-call payload, victim-approved NFT, and outer payout bucket.
  • Predy Finance is the strong real callback, wrong pair-local ownership scope exemplar: a legitimate trade callback on a fresh pair inherited pool-global locker authority, migrated liquidity out of an older pair, and passed final checks because aggregate pool balances still matched.
  • GMX V1 is the strong refund callback inside still-open temporary capability window exemplar: the callback source was a real ETH transfer during keeper execution, but the protocol still had not closed the privileged leverage frame or kept short-accounting state atomic across modules.
  • Exactly Protocol is the helper-layer actor and target rebound before callback exemplar: the fake market first became the trust anchor, then the re-entrant callback used that stale acting-account context to drain real victim positions.

How to use this note with the exploit corpus

  • Start here when the sharper question is what exact in-flight execution object should this callback-driven router, helper, or keeper path have authenticated before it resumed?
  • Use Reentrancy when the primary lesson is still the classic one: control returned before accounting or permissions were final.
  • Use this note when the narrower design question becomes what one callback frame should have stayed bound together instead of being reconstructed from ambient state?
  • Quick routing rule against the settlement-envelope cluster: if the callback source may even be real but the bug is that actor, target, stage, resume-path, or payout authority leaked across one still-live execution frame, stay here; if the decisive break is instead that a downstream consumer trusted the wrong protocol-authored settlement blob or route envelope, pivot to Settlement envelope truth as a single security object and trusted execution-envelope corruption.
  • Pivot to root-of-trust substitution when a counterfeit market, bank, jar, or verifier had already become the trusted root object before the callback-frame question even arose.
  • Pair with arbitrary-call token-approval drain when the router/helper did not need stale in-flight context at all; it already exposed attacker-directed authority over approvals.

Internal linkages

Control surface