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):
-
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.
-
L326: _revertIfNotLiquidatable(creditAccount) — calls calcDebtAndCollateral with DEBT_COLLATERAL task → useSafePrices: false → uses MAIN price feed. Returns frozen CollateralDebtData memory collateralDebtData including totalValue and totalValueUSD.
-
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.
-
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:
- 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.
- Call
PriceFeedStore.updatePrices directly with the LOW VAA → prices drop.
- Call
liquidateCreditAccount with onDemandPriceUpdates passing the same LOW VAA (or a lower-timestamp VAA that gets rejected as stale, keeping prices LOW).
- Health check at L326 sees LOW prices → target account appears unhealthy →
cdd.totalValue is frozen at LOW.
- During multicall at L345, if any adapter call triggers
PriceFeedStore.updatePrices with the HIGH VAA (either directly or via a side effect), prices increase.
_getRemainingFunds at L342 reads CURRENT (HIGH) prices → remainingFunds is inflated.
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:
- Target account has non-underlying collateral tokens (quoted tokens with balances > 1)
- Those tokens use updatable price feeds (Pyth or Redstone as main feed)
- 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
_getRemainingFunds should use the same frozen totalValue from collateralDebtData rather than re-reading live prices.
- Alternatively,
PriceFeedStore.updatePrices should be paused or rate-limited during liquidation to prevent price changes between health check and seizure.
- Consider adding a price staleness check in
_getRemainingFunds to ensure prices haven't changed since the health check.
Gearbox V3: Price Oracle Manipulation During Liquidation Multicall
Summary
The
liquidateCreditAccountfunction inCreditFacadeV3.solhas a price manipulation window between the health check (which freezesCollateralDebtData) and the_getRemainingFundscall (which reads live prices), during which the multicall executes adapter calls. SincePriceFeedStore.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):L317-324:
onDemandPriceUpdatesis processed fromcalls[0]— updatesPriceFeedStoreprices BEFORE health check.SKIP_PRICE_UPDATES_CALL_FLAGis set to prevent re-processing in multicall.L326:
_revertIfNotLiquidatable(creditAccount)— callscalcDebtAndCollateralwithDEBT_COLLATERALtask →useSafePrices: false→ uses MAIN price feed. Returns frozenCollateralDebtData memory collateralDebtDataincludingtotalValueandtotalValueUSD.L345:
_multicall(creditAccount, calls, collateralDebtData.enabledTokensMask, flags)— executes remaining multicall calls (adapters, withdrawCollateral, etc.).SKIP_COLLATERAL_CHECK_FLAGis set, so no collateral check runs after multicall.L357:
CreditManagerV3.liquidateCreditAccount(collateralDebtData, ...)— inside:calcLiquidationPayments(cdd)computesminRemainingFunds = totalFunds - amountToPoolWithFeeusing FROZENcdd.totalValue * liquidationDiscount_getRemainingFunds(creditAccount, enabledTokensMask)revalues non-underlying tokens using CURRENT prices via_convertToUSD(priceOracle, balance, token)and_convertFromUSD(priceOracle, totalValueUSD, underlying)if (remainingFunds < minRemainingFunds) revert— checks live funds against frozen expectationThe Vulnerability
PriceFeedStore.updatePrices()(permissionless repo,contracts/instance/PriceFeedStore.solL262-271) isexternal overridewith zero access control — noonlyOwner, noauth, no modifier. It only checks that the feed is in the_updatablePriceFeedsset, then callsIUpdatablePriceFeed(feed).updatePrice(data)with attacker-controlled data.Both
PythPriceFeed.updatePrice()andRedstonePriceFeed.updatePrice()validate signatures (VAA/signer), preventing arbitrary price injection. However, an attacker can:PriceFeedStore.updatePricesdirectly with the LOW VAA → prices drop.liquidateCreditAccountwithonDemandPriceUpdatespassing the same LOW VAA (or a lower-timestamp VAA that gets rejected as stale, keeping prices LOW).cdd.totalValueis frozen at LOW.PriceFeedStore.updatePriceswith the HIGH VAA (either directly or via a side effect), prices increase._getRemainingFundsat L342 reads CURRENT (HIGH) prices →remainingFundsis inflated.remainingFunds (HIGH) >= minRemainingFunds (LOW)→ check passes, liquidator receives excess funds.Key:
minRemainingFundsvsremainingFundsMismatchminRemainingFunds(fromcalcLiquidationPayments): computed from FROZENcdd.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
Code References
CreditFacadeV3.solL306-365:liquidateCreditAccount— multicall between health check and seizureCreditFacadeV3.solL534-536:onDemandPriceUpdatesonly at index 0,SKIP_PRICE_UPDATES_CALL_FLAGprevents reprocessingCreditManagerV3.solL306-374:liquidateCreditAccount— frozencddfor payments, live prices for_getRemainingFundsCreditManagerV3.solL856-883:_getRemainingFunds— uses_convertToUSD(current prices)CreditLogic.solL67-101:calcLiquidationPayments— usescdd.totalValue(frozen)PriceFeedStore.solL262-271:updatePrices— permissionless, no access controlPriceOracleV3.solL187-191:_getPrice— reads from price feed (current state)Recommendation
_getRemainingFundsshould use the same frozentotalValuefromcollateralDebtDatarather than re-reading live prices.PriceFeedStore.updatePricesshould be paused or rate-limited during liquidation to prevent price changes between health check and seizure._getRemainingFundsto ensure prices haven't changed since the health check.