Skip to content

[SECURITY] Price Oracle Manipulation Risk: Frozen cdd vs Live _getRemainingFunds in liquidateCreditAccount #18

Description

@leanworld7-netizen

Gearbox V3: Price Oracle Manipulation During Liquidation Multicall

Summary

The liquidateCreditAccount function in CreditFacadeV3.sol has a price manipulation window between the health check (which freezes CollateralDebtData) and the _getRemainingFunds call (which reads live prices), during which the multicall executes adapter calls. Since PriceFeedStore.updatePrices() is permissionless (no access control), any external call that triggers a price feed update during the multicall window can cause the liquidator to receive more funds than the frozen collateral data justifies.

Vulnerability Detail

Flow of liquidateCreditAccount (CreditFacadeV3.sol L306-365):

  1. L317-324: onDemandPriceUpdates is processed from calls[0] — updates PriceFeedStore prices BEFORE health check. SKIP_PRICE_UPDATES_CALL_FLAG is set to prevent re-processing in multicall.

  2. L326: _revertIfNotLiquidatable(creditAccount) — calls calcDebtAndCollateral with DEBT_COLLATERAL task → useSafePrices: false → uses MAIN price feed. Returns frozen CollateralDebtData memory collateralDebtData including totalValue and totalValueUSD.

  3. L345: _multicall(creditAccount, calls, collateralDebtData.enabledTokensMask, flags) — executes remaining multicall calls (adapters, withdrawCollateral, etc.). SKIP_COLLATERAL_CHECK_FLAG is set, so no collateral check runs after multicall.

  4. L357: CreditManagerV3.liquidateCreditAccount(collateralDebtData, ...) — inside:

    • L321: calcLiquidationPayments(cdd) computes minRemainingFunds = totalFunds - amountToPoolWithFee using FROZEN cdd.totalValue * liquidationDiscount
    • L342: _getRemainingFunds(creditAccount, enabledTokensMask) revalues non-underlying tokens using CURRENT prices via _convertToUSD(priceOracle, balance, token) and _convertFromUSD(priceOracle, totalValueUSD, underlying)
    • L345: if (remainingFunds < minRemainingFunds) revert — checks live funds against frozen expectation

The Vulnerability

PriceFeedStore.updatePrices() (permissionless repo, contracts/instance/PriceFeedStore.sol L262-271) is external override with zero access control — no onlyOwner, no auth, no modifier. It only checks that the feed is in the _updatablePriceFeeds set, then calls IUpdatablePriceFeed(feed).updatePrice(data) with attacker-controlled data.

Both PythPriceFeed.updatePrice() and RedstonePriceFeed.updatePrice() validate signatures (VAA/signer), preventing arbitrary price injection. However, an attacker can:

  1. Obtain TWO valid signed price updates (VAAs) with different timestamps from the oracle provider — one with a LOW price and one with a HIGH price.
  2. Call PriceFeedStore.updatePrices directly with the LOW VAA → prices drop.
  3. Call liquidateCreditAccount with onDemandPriceUpdates passing the same LOW VAA (or a lower-timestamp VAA that gets rejected as stale, keeping prices LOW).
  4. Health check at L326 sees LOW prices → target account appears unhealthy → cdd.totalValue is frozen at LOW.
  5. During multicall at L345, if any adapter call triggers PriceFeedStore.updatePrices with the HIGH VAA (either directly or via a side effect), prices increase.
  6. _getRemainingFunds at L342 reads CURRENT (HIGH) prices → remainingFunds is inflated.
  7. remainingFunds (HIGH) >= minRemainingFunds (LOW) → check passes, liquidator receives excess funds.

Key: minRemainingFunds vs remainingFunds Mismatch

  • minRemainingFunds (from calcLiquidationPayments): computed from FROZEN cdd.totalValue * liquidationDiscount - amountToPoolWithFee. Uses prices at health-check time.
  • remainingFunds (from _getRemainingFunds): revalues ALL non-underlying tokens at CURRENT oracle prices. Uses prices after multicall.

If prices increase between health check and _getRemainingFunds, remainingFunds > minRemainingFunds, and the excess goes to the liquidator (L350: amountToLiquidator = Math.min(remainingFunds - minRemainingFunds, underlyingBalance)).

Impact

  • Severity: Medium/High (depends on adapter ecosystem — see below)
  • Financial: A liquidator can extract excess collateral by manipulating oracle prices during the multicall window. The profit is proportional to the price difference between the LOW and HIGH oracle updates, multiplied by the non-underlying collateral value.
  • Conditions:
    1. Target account has non-underlying collateral tokens (quoted tokens with balances > 1)
    2. Those tokens use updatable price feeds (Pyth or Redstone as main feed)
    3. Prices can be updated during multicall (direct call or adapter side effect)

Code References

  • CreditFacadeV3.sol L306-365: liquidateCreditAccount — multicall between health check and seizure
  • CreditFacadeV3.sol L534-536: onDemandPriceUpdates only at index 0, SKIP_PRICE_UPDATES_CALL_FLAG prevents reprocessing
  • CreditManagerV3.sol L306-374: liquidateCreditAccount — frozen cdd for payments, live prices for _getRemainingFunds
  • CreditManagerV3.sol L856-883: _getRemainingFunds — uses _convertToUSD (current prices)
  • CreditLogic.sol L67-101: calcLiquidationPayments — uses cdd.totalValue (frozen)
  • PriceFeedStore.sol L262-271: updatePrices — permissionless, no access control
  • PriceOracleV3.sol L187-191: _getPrice — reads from price feed (current state)

Recommendation

  1. _getRemainingFunds should use the same frozen totalValue from collateralDebtData rather than re-reading live prices.
  2. Alternatively, PriceFeedStore.updatePrices should be paused or rate-limited during liquidation to prevent price changes between health check and seizure.
  3. Consider adding a price staleness check in _getRemainingFunds to ensure prices haven't changed since the health check.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions