All incidents

Arcadia Self-Liquidation Bypass

Share
Jul 10, 2023 01:16 UTCAttackLoss: 148.24 WETH, 59,433.38 USDCPending manual check1 exploit txWindow: Atomic
Estimated Impact
148.24 WETH, 59,433.38 USDC
Label
Attack
Exploit Tx
1
Addresses
3
Attack Window
Atomic
Jul 10, 2023 01:16 UTC → Jul 10, 2023 01:16 UTC

Exploit Transactions

TX 1Optimism
0xca7c1a0fde444e1a68a8c2b8ae3fb76ec384d1f7ae9a50d26f8bfdd37c7a0afe
Jul 10, 2023 01:16 UTCExplorer

Victim Addresses

0xd417c28af20884088f600e724441a3bab38b22ccOptimism
0x9aa024d3fd962701ed17f76c17cab22d3dc9d92dOptimism
0x3ae354d7e49039ccd582f1f3c9e65034ffd17badOptimism

Loss Breakdown

148.24WETH
59,433.38USDC

Similar Incidents

Root Cause Analysis

Arcadia Self-Liquidation Bypass

1. Incident Overview TL;DR

Arcadia Finance on Optimism was exploited in transaction 0xca7c1a0fde444e1a68a8c2b8ae3fb76ec384d1f7ae9a50d26f8bfdd37c7a0afe at block 106676495. The attacker flash-loaned seed WETH and USDC, created two new Arcadia vaults, borrowed nearly all available liquidity from Arcadia's WETH and USDC lending pools, then self-liquidated both vaults from inside vaultManagementAction() after withdrawing all collateral.

The root cause is a state-ordering bug in Arcadia's Vault logic at 0x3ae354d7e49039ccd582f1f3c9e65034ffd17bad. Vault.liquidateVault() clears isTrustedCreditorSet, trustedCreditor, liquidator, and then fixedLiquidationCost before control returns to the enclosing Vault.vaultManagementAction() frame. Because vaultManagementAction() recomputes getUsedMargin() only after the external action callback returns, the final solvency check sees zero used margin and does not revert, even though the vault's debt-backed collateral has already been removed.

2. Key Background

Arcadia vaults are margin accounts. Their core safety condition is that a vault with open debt must remain solvent under getCollateralValue() >= getUsedMargin().

Anyone can create a fresh Arcadia vault through the public Factory at 0x00cb53780ea58503d3059fc02ddd596d0be926cb. That makes the owner role needed for vaultManagementAction() permissionless in practice.

Arcadia lending pools on Optimism expose public doActionWithLeverage() and liquidateVault(address) entry points. In this incident the affected pools were:

  • WETH pool 0xd417c28af20884088f600e724441a3bab38b22cc
  • USDC pool 0x9aa024d3fd962701ed17f76c17cab22d3dc9d92d

Arcadia's MainRegistry allowlists action handlers for vaultManagementAction(). Arcadia's own ActionMultiCall handler at 0x2de7bbaaab48eac228449584f94636bb20d63e65 was allowlisted at the exploited pre-state and can execute arbitrary external calls with arbitrary calldata.

3. Vulnerability Analysis & Root Cause Summary

The vulnerability is a solvency-check bypass caused by mutable liability state being cleared during a nested liquidation. vaultManagementAction() withdraws assets to an action handler, lets that handler perform arbitrary external calls, redeposits whatever the handler returns, and only then evaluates the final health check. That design assumes the vault's debt-tracking state is still intact when the check runs. The assumption is false because liquidateVault() can be reached during the callback through the public lending-pool liquidation path, and liquidateVault() clears the trusted creditor state before returning. Once isTrustedCreditorSet becomes false, getUsedMargin() returns zero, so the post-action branch if (usedMargin > fixedLiquidationCost) is skipped entirely. The result is that borrowed assets can be removed permanently while Arcadia is left with empty liquidations and bad debt exposure.

Relevant excerpt from the verified Arcadia Vault logic:

function getUsedMargin() public view returns (uint256 usedMargin) {
    if (!isTrustedCreditorSet) return 0;
    usedMargin = ITrustedCreditor(trustedCreditor).getOpenPosition(address(this)) + fixedLiquidationCost;
}

function liquidateVault(uint256 openDebt) external returns (...) {
    require(msg.sender == liquidator, "V_LV: Only Liquidator");
    trustedCreditor_ = trustedCreditor;
    isTrustedCreditorSet = false;
    trustedCreditor = address(0);
    liquidator = address(0);
    require(getLiquidationValue() < openDebt + fixedLiquidationCost, "V_LV: liqValue above usedMargin");
    fixedLiquidationCost = 0;
    IFactory(IMainRegistry(registry).factory()).liquidate(msg.sender);
    originalOwner = owner;
    _transferOwnership(msg.sender);
}

function vaultManagementAction(address actionHandler, bytes calldata actionData)
    external
    onlyAssetManager
    returns (address, uint256)
{
    _withdraw(outgoing.assets, outgoing.assetIds, outgoing.assetAmounts, actionHandler);
    ActionData memory incoming = IActionBase(actionHandler).executeAction(actionData);
    _deposit(incoming.assets, incoming.assetIds, incoming.assetAmounts, actionHandler);
    uint256 usedMargin = getUsedMargin();
    if (usedMargin > fixedLiquidationCost) {
        require(getCollateralValue() >= usedMargin, "V_VMA: Vault Unhealthy");
    }
    return (trustedCreditor, vaultVersion);
}

4. Detailed Root Cause Analysis

The exploit used one adversary-crafted transaction and no privileged roles, private keys, or victim cooperation.

  1. The sender EOA 0xd3641c912a6a4c30338787e3c464420b561a9467 called attacker contract 0x01a4d9089c243ccaebe40aa224ad0cab573b83c6.
  2. The attacker contract flash-loaned 29.847813623947075968 WETH and 11916676700 USDC from Aave.
  3. It created two fresh Arcadia vaults, one denominated in WETH and one in USDC, then opened trusted margin accounts against the two Arcadia lending pools.
  4. It deposited the flash-loaned seed collateral and borrowed 148239068119735379843 WETH and 59433383500 USDC through doActionWithLeverage(), which transferred the borrowed assets to allowlisted ActionMultiCall.
  5. The attacker then directly invoked vaultManagementAction() on each vault. During those calls, the vault withdrew its assets to ActionMultiCall.
  6. Inside the callback, attacker helper contracts 0x3a524db634a1683917040752eb9cac643c685403 and 0x5b0bceedc1a8d173f71fd81807600bad3c2e36f3 pulled the withdrawn assets from ActionMultiCall and called the public Arcadia liquidation entry points.
  7. Each lending pool called into the liquidator, which in turn called Vault.liquidateVault(openDebt). At that point the vault cleared the trusted-creditor state and transferred vault ownership to Arcadia's liquidator 0xd2a34731586bd10b645f870f4c9dcaf4f9e3823c.
  8. Control returned to the still-open vaultManagementAction() frame. Because isTrustedCreditorSet had already been cleared, getUsedMargin() returned zero and the final solvency branch was skipped.
  9. The outer call succeeded with no collateral redeposited. Arcadia had already burned the debt tokens at liquidation start, so the vaults ended the transaction empty while the pools had already lost the borrowed assets.

Representative trace excerpt from the WETH side:

0x13C0...::vaultManagementAction(ActionMultiCall, ...)
  ActionMultiCall::executeAction(...)
    WETH9::transferFrom(ActionMultiCall, attacker-contract, 178086881743682455811)
    WETH pool::liquidateVault(0x13C0...)
      Liquidator::startAuction(0x13C0..., 148387307187855115223, 3000000000000000000)
        Vault::liquidateVault(148387307187855115223)
          Factory::liquidate(Liquidator)
          emit TrustedMarginAccountChanged(0x0, 0x0)

The USDC side repeats the same pattern against 0x9aa024d3fd962701ed17f76c17cab22d3dc9d92d, with liquidation started on vault 0x6c6515cabf1b90443634be4d88530b226a13a921.

The exploit conditions were all permissionless and public:

  • The attacker only needed to own newly created vaults, which anyone can create.
  • A flash loan was sufficient to satisfy the initial leverage step.
  • Arcadia's own allowlisted ActionMultiCall supplied the arbitrary callback surface.
  • The lending pools' liquidateVault(address) functions were public and reachable during that callback.

5. Adversary Flow Analysis

The attacker's execution flow had three concrete stages.

Flash-Loan Bootstrap

The attacker borrowed bootstrap liquidity from Aave in the same transaction. This supplied the initial collateral needed to open two margin accounts and pass Arcadia's initial leverage checks.

Vault Creation and Leveraged Borrow

The attacker created vaults 1157 and 1158, opened trusted margin accounts for the WETH and USDC Arcadia pools, deposited the flash-loaned collateral, and borrowed almost the full available pool balances. The critical design point is that doActionWithLeverage() optimistically transfers borrowed funds to the action handler before only verifying post-action vault state later.

Relevant Arcadia lending-pool logic:

function doActionWithLeverage(...) external {
    _deposit(amountBorrowedWithFee, vault);
    asset.safeTransfer(actionHandler, amountBorrowed);
    (address trustedCreditor, uint256 vaultVersion) = IVault(vault).vaultManagementAction(actionHandler, actionData);
    require(trustedCreditor == address(this) && isValidVersion[vaultVersion], "LP_DAWL: Reverted");
}

function liquidateVault(address vault) external {
    uint256 openDebt = maxWithdraw(vault);
    liquidationInitiator[vault] = msg.sender;
    ILiquidator(liquidator).startAuction(vault, openDebt, maxInitiatorFee);
    _withdraw(openDebt, vault, vault);
}

Self-Liquidation and Profit Sweep

After the nested self-liquidation bypass succeeded on both vaults, the attacker repaid the Aave flash loans and swept the residual profits back to the sender EOA. The final sender balances were:

  • Before: 6659167178883893 wei native and 0 USDC
  • After: 148230760062633611413 wei native and 59427425162 USDC
  • Net increase: 148224100895454727520 wei native and 59427425162 USDC

The trace also shows the exploit contract transferring 59427425162 USDC to the sender EOA and withdrawing 148224144212923406305 wrapped native value before forwarding it as native ETH-equivalent balance to the EOA.

6. Impact & Losses

The exploit immediately drained nearly all liquid borrowable assets from both Arcadia lending pools involved in the transaction:

  • 148239068119735379843 WETH from 0xd417c28af20884088f600e724441a3bab38b22cc
  • 59433383500 USDC from 0x9aa024d3fd962701ed17f76c17cab22d3dc9d92d

The vaults that backed those liabilities were left empty and transferred into liquidation ownership after their collateral had already been withdrawn. That means the protocol entered liquidation with zero-collateral positions instead of recoverable, overcollateralized debt. The attacker realized immediate positive profit in the same transaction, while Arcadia LPs were left exposed to bad debt equal to the unrecovered pool outflows.

7. References

  • Seed exploit transaction: 0xca7c1a0fde444e1a68a8c2b8ae3fb76ec384d1f7ae9a50d26f8bfdd37c7a0afe
  • Seed trace: /workspace/session/artifacts/collector/seed/10/0xca7c1a0fde444e1a68a8c2b8ae3fb76ec384d1f7ae9a50d26f8bfdd37c7a0afe/trace.cast.log
  • Seed balance diff: /workspace/session/artifacts/collector/seed/10/0xca7c1a0fde444e1a68a8c2b8ae3fb76ec384d1f7ae9a50d26f8bfdd37c7a0afe/balance_diff.json
  • Arcadia Factory source artifact: /workspace/session/artifacts/collector/seed/10/0x00cb53780ea58503d3059fc02ddd596d0be926cb/src/Factory.sol
  • Arcadia WETH LendingPool source artifact: /workspace/session/artifacts/collector/seed/10/0xd417c28af20884088f600e724441a3bab38b22cc/lib/arcadia-lending/src/LendingPool.sol
  • Arcadia USDC LendingPool source artifact: /workspace/session/artifacts/collector/seed/10/0x9aa024d3fd962701ed17f76c17cab22d3dc9d92d/lib/arcadia-lending/src/LendingPool.sol
  • Arcadia Vault verified source: https://optimistic.etherscan.io/address/0x3ae354d7e49039ccd582f1f3c9e65034ffd17bad#code
  • Arcadia ActionMultiCall verified source: https://optimistic.etherscan.io/address/0x2de7bbaaab48eac228449584f94636bb20d63e65#code