Arcadia Finance vaultManagementAction() liquidation reentrancy drain (2023-07-10)

  • Name: Arcadia Finance vaultManagementAction() liquidation reentrancy drain
  • Date: 2023-07-10
  • Protocol: Arcadia Finance
  • Chain: Ethereum and Optimism
  • Category: cross-contract reentrancy / optimistic vault-management accounting failure / liquidation-state desynchronization
  • Loss: about $459,030.44 across Ethereum and Optimism
  • Summary: Arcadia’s vaultManagementAction() deliberately let a vault owner withdraw assets to a multicall helper, perform arbitrary allowed actions, and only then run the final health check. That optimistic flow assumed nothing else could mutate the vault’s debt state before the check. The attacker proved otherwise. Using flash liquidity and then doActionWithLeverage() to lever a fresh vault, they called vaultManagementAction() to pull all collateral out of the vault and, during the multicall window, re-entered through LendingPool.liquidateVault(). Liquidation routed through Liquidator back into Vault.liquidateVault(), started an auction, and removed the vault’s debt while the collateral was already outside the vault. When control returned to vaultManagementAction(), Arcadia saw a vault with 0 assets and 0 debt, so the final solvency check passed and the attacker kept the withdrawn collateral. Arcadia’s own loss accounting later broke the damage out as about 103,200.653468 USDC plus 11.047195997555799542 ETH on Ethereum and 59,427.425162 USDC plus 148.224144212923406305 ETH on Optimism.
  • Trigger / entry point:
    • vaultManagementAction() let a vault owner optimistically withdraw assets from the vault to a multicall contract before proving the vault would still be healthy at function exit.
    • During that external-call window, the attacker could call back into Arcadia through LendingPool.liquidateVault().
    • Liquidation crossed modules as LendingPool -> Liquidator -> Vault.liquidateVault(), so the vault’s debt state changed while the original vault-management action was still in progress.
  • Root cause:
    • Arcadia’s vault-management flow relied on deferred health checking but did not block liquidation-driven re-entry into the same vault while assets were temporarily outside the vault.
    • The broken invariant was simple: the system assumed assets removed for optimistic external actions and vault debt / liquidation status could not be changed independently during one in-flight management call.
    • Once liquidation was allowed during that window, Arcadia erased the vault’s debt and started an auction against a temporarily empty vault, converting what should have been an unhealthy intermediate state into a final state that trivially passed validation.
    • The protocol’s own postmortem explicitly rejected the idea that this was mainly an untrusted-input bug; the decisive issue was missing reentrancy protection on the liquidation path while vaultManagementAction() was mid-flight.
  • Exploit path:
    1. The attacker took flash liquidity and opened fresh Arcadia vaults on Ethereum and Optimism.
    2. On the representative USDC path described by Arcadia, the attacker created a new vault and deposited 20,672 USDC.
    3. The attacker called doActionWithLeverage() to borrow 103,210 USDC from the liquidity pool and immediately redeposit it, leaving the vault with about 123,882 USD of assets against 103,210 USDC of debt.
    4. The attacker then called vaultManagementAction(), the optimistic vault-management function.
    5. Inside that flow, the attacker withdrew all vault assets to the multicall helper, leaving the vault temporarily with 0 assets and the original debt still recorded.
    6. Before vaultManagementAction() could run its final health check, the attacker re-entered Arcadia by calling LendingPool.liquidateVault().
    7. The liquidation path routed through Liquidator into Vault.liquidateVault(), successfully initiated liquidation against the now-empty vault, and removed the debt from the vault state while the attacker-controlled call still held the withdrawn collateral outside.
    8. Control returned to vaultManagementAction(). Because the vault now showed 0 assets and 0 debt, the end-of-function health check passed.
    9. The attacker repeated the pattern across USDC and WETH pools on Ethereum and Optimism, repaid flash liquidity, and retained the withdrawn collateral.
  • What the attacker needed to be true:
    • The attacker had to control the vault so they could call vaultManagementAction() and shape the optimistic asset-withdrawal flow.
    • The function had to transfer assets out before the health check finalized.
    • LendingPool.liquidateVault() had to remain reachable during that window, and the liquidation path had to be able to mutate vault debt state before the original call returned.
    • Arcadia’s final validation had to look only at the post-liquidation state (0 debt) rather than at whether the withdrawn collateral had ever come back.
    • The targeted pools needed enough liquidity that leveraged vaults on both chains could be opened and unwound profitably.
  • Technical anatomy worth remembering:
    • This was a clean cross-contract reentrancy, not same-function recursion. The vulnerable window opened in Vault.vaultManagementAction(), but the profitable mutation happened through LendingPool and Liquidator back into the vault.
    • The exploit shows the danger of optimistic accounting patterns that intentionally allow intermediate insolvency and rely on one final end-of-function check. If any sibling entrypoint can rewrite debt, auction, or collateral status during that window, the final check becomes meaningless.
    • Arcadia’s own example is crisp: after the attacker withdrew all assets, the vault looked liquidatable; after liquidation removed the debt, the same vault looked healthy again, even though the assets never returned.
    • The incident is also a useful complement to Fuse: both exploits monetized stale solvency, but Arcadia’s stale window lived in an optimistic vault-management function rather than in an ETH-transfer callback.
  • Representative transactions and addresses:
    • Optimism exploit tx: 0xc598bf554a738ebeec234930e4bffe1c7a4266a1bbbdfae63b6951e448a17fc5
    • Ethereum exploit tx: 0x01e2683bfdb945fcb6af7ed6a656dbabb04d7c8135125184fd29f5665fd0546c
    • Arcadia’s postmortem example of a USDC-side exploit transaction: 0xc598bf554a738ebeec234930e4bffe1c7a4266a1bbbdfae63b6951e448a17fc5
    • Key contracts / modules called out by Arcadia: Vault, LendingPool, Liquidator, vaultManagementAction(), doActionWithLeverage(), and liquidateVault()
  • Response / mitigation:
    • Arcadia paused contracts related to the affected liquidity pools, disabled debt increase and repayment flows, and set the interest rate to 0 to prevent additional interest accrual during incident handling.
    • The protocol said the concrete fix was to add a reentrancy guard on the vault, especially around liquidateVault() while vaultManagementAction() is executing.
    • Arcadia published a chain-by-chain loss split: on Ethereum about 103,200.653468 USDC and 11.047195997555799542 ETH, and on Optimism about 59,427.425162 USDC and 148.224144212923406305 ETH, for the reported total of $459,030.44.
    • Arcadia also began recovery and law-enforcement work immediately after the exploit.

Pattern tags

Why the exploit worked

  • vaultManagementAction() deliberately allowed a vault to become temporarily unhealthy while assets sat outside the vault, but Arcadia did not make that intermediate state private to the management flow. A sibling entrypoint could still observe it and act on it.
  • The decisive mismatch was temporal, not merely input-validation-related: the management path treated assets temporarily gone, debt still present as an allowed work-in-progress state, while LendingPool.liquidateVault() treated the exact same moment as authoritative liquidation truth.
  • Liquidation then changed the wrong half of the vault first. Once Liquidator -> Vault.liquidateVault() removed the debt and started an auction while the collateral was already outside the vault, Arcadia had converted a deliberately temporary insolvent state into a final 0 assets / 0 debt state that trivially passed the end-of-function health check.
  • The final solvency check therefore authenticated the wrong object. It did not prove the same collateral that left the vault came back before debt was cleared; it only observed that the vault happened to end with no debt after a re-entrant sibling path rewrote state mid-flight.
  • Flash liquidity and doActionWithLeverage() scaled the attack but did not create the bug. The real monetizable primitive was that one vault-management frame could externalize collateral while another entrypoint could independently zero the debt ledger before the original frame closed.

Detection and audit pivots

  • For any withdraw now, validate later helper, ask which sibling entrypoints can still mutate debt, liquidation flags, collateral eligibility, auction state, or vault ownership before the original frame closes.
  • Treat temporary collateral release as a capability boundary. If assets leave a vault before solvency re-check, liquidation, debt settlement, and other state-finalizing paths should usually be blocked or scoped to that in-flight action.
  • Fuzz cross-entrypoint sequences rather than only same-function recursion: vaultManagementAction() -> external multicall -> LendingPool.liquidateVault(), plus nearby variants that toggle collateral state, migrate debt, or trigger auctions mid-frame.
  • Review final health checks for object identity, not just final numbers. The question is not only does the vault end healthy? but did the same debt and the same collateral relationship survive the whole action without a sibling path rewriting one side independently?
  • Trace all places where optimistic helper flows assume deferred validation. If a leverage, rebalance, or multicall helper gets to violate core solvency assumptions transiently, every other entrypoint touching the same vault must explicitly know that the state is provisional.
  • Keep monitoring that can distinguish vault ended with zero debt because the user repaid from vault ended with zero debt because liquidation fired during an unfinished management frame. Those are economically different endings even if a naive final-state check renders them both as healthy.

Failure-prevention checklist

  • Treat vaultManagementAction() as one explicit in-flight frame: vault id, acting owner or manager, withdrawn-asset inventory, allowed external leg, and the exact debt / liquidation state that must still match when the frame closes.
  • Block liquidation, auction start, debt write-down, collateral-membership changes, and any other state-finalizing sibling entrypoint while assets are temporarily externalized for management work. If a vault is mid-optimistic action, the rest of the system should know that its solvency state is provisional.
  • Keep temporary collateral release and debt-state mutation on one invariant-maintenance path. If a vault may become transiently undercollateralized during a helper flow, no other path should be able to treat that same transient state as money-good liquidation truth.
  • Re-check the identity of the returned collateral, not only the final ratio. The closing validation should prove that the same vault debt and the same reserve-supporting assets made it through the whole frame without a sibling path zeroing one side independently.
  • Use stage-specific locks or frame-local status rather than broad end-of-function optimism. We will check health later is only safe if every sibling mutator is forced to wait until later too.
  • Prefer escrowed or staged post-action settlement when arbitrary external interactions are required. If users must leave the vault, interact elsewhere, and come back, isolate that route so liquidation cannot reinterpret the half-finished state mid-flight.
  • Fuzz withdraw all collateral -> nested liquidation -> return and nearby sequences across every debt-mutating or auction-mutating entrypoint, not just same-function recursion. The bug class is cross-entrypoint disagreement about when vault truth is final.
  • Add monitoring that distinguishes debt cleared because the borrower repaid inside the intended flow from debt cleared because liquidation fired while the intended flow was still open. Those states should never look equivalent operationally.

Audit prompts

  • While vaultManagementAction() is in progress, which exact entrypoints can still change debt, liquidation status, auction state, collateral eligibility, or vault ownership before the original frame closes?
  • What concrete storage flag or frame object tells sibling modules that the vault’s solvency state is provisional rather than final right now?
  • Does the closing health check prove that withdrawn collateral actually returned to the same debt-bearing vault, or does it only observe a final assets / debt snapshot that another path could have rewritten?
  • Can liquidation ever consume a state that was intentionally made temporarily unhealthy for a management, rebalance, leverage, or migration helper? If so, why is that state being published as authoritative at all?
  • Which external legs inside vaultManagementAction() can re-enter Arcadia indirectly through other modules rather than through the same function?
  • If the protocol pauses on one side of the state machine, does the other side still have enough authority to finalize debt or start auctions against unfinished vault state?
  • What tests prove that debt removal, collateral withdrawal, and auction start cannot be reordered into a profitable final 0 assets / 0 debt state while collateral remains outside the vault?
  • If a future helper needs the same optimistic validate at exit pattern, what machine-checkable rule prevents it from reintroducing the same cross-entrypoint disagreement under a different name?

Lessons that should already be standard

  • Validate at the end is not a security property by itself. It only works when every sibling entrypoint agrees that the intermediate state is provisional and therefore unusable for liquidation, payout, or pricing.
  • Liquidation is not just a cleanup path; it is a privileged state transition. If it can observe half-finished helper state, it becomes a reentrancy primitive even without exotic token hooks.
  • A vault’s debt ledger and its reserve-supporting collateral are one security object. Clearing one while the other is temporarily elsewhere is not helpful progress; it is balance-sheet corruption.
  • Allowlisted external actions do not save an optimistic flow if the real bug is internal disagreement about when vault truth becomes final.
  • Cross-contract reentrancy often means one module is respecting a temporary fiction while another module is already spending against it as reality.
  • Final-state checks can be fooled when they authenticate the wrong object. The healthy question is not only did the vault end solvent? but did the same debt-collateral relationship survive the entire frame?
  • When a design intentionally permits transient insolvency, every nearby mutator must be reviewed as if it were handling raw uncommitted state, because it is.

Why this incident matters

  • It is a compact example of how do external actions now, prove health later can fail inside a lending protocol even when the external action surface is nominally allowlisted.
  • The exploit did not require a weird token standard, compiler bug, or governance capture. It only required one optimistic state machine and one sibling liquidation path that disagreed about when vault state was final.
  • Arcadia is a useful study case for vault-based lending designs because the key lesson is architectural: if liquidation can observe deliberately incomplete intermediate state, liquidation becomes a reentrancy primitive.
  • It is also a clean positive-design reminder that temporary collateral release is already a privileged capability boundary. If a system lets assets leave before solvency is re-proven, then debt-erasing or auction-starting paths must either inherit that provisional-state knowledge or stay shut.

Internal linkages

  • Core recurring pattern: reentrancy.
  • Sharper vault-state lens: collateral-membership-desynchronization — Arcadia split which assets still need to support this debt across an optimistic management flow and a sibling liquidation path that could rewrite debt state mid-flight.
  • Accounting / solvency lens: solvency-check-bypass.
  • Sharper positive-design companion: callback-frame-truth-security-object — Arcadia needed one authenticated vault-management frame binding vault id, withdrawn-collateral inventory, allowed external leg, debt state, and the prohibition on liquidation-style sibling finalization until that frame closed.
  • Positive-design companion: helper-path-invariant-reuse-and-fail-closed-discipline — the exploit is also a clean reminder that a leverage / management helper cannot inherit looser temporal assumptions than the core vault and liquidation state machine it manipulates.
  • Positive-design companion: temporary-capability-closure-before-external-delivery — useful when the sharper design question is why Arcadia let vault assets leave custody before the system had closed the debt / liquidation capabilities that could still reinterpret that temporary state.

Control surface