Skip to content

Lack of Oracle staleness check can cause over-borrowing and unfair liquidations #794

@blessingblockchain

Description

@blessingblockchain

Summary

Auditor.assetPrice() trusts IPriceFeed.latestAnswer() and only checks price > 0. But it does not verify that the oracle value is fresh (no timestamp/round/heartbeat validation).

During a real world Chainlink stalls (sequencer downtime, feed pauses/delays, node issues), latestAnswer() commonly returns the last valid price without reverting, which this protocol will treat as current.

  • This can enable two issues in this contracts:

  • (1) over-borrow using stale-high collateral prices (bad debt / lender loss)

  • (2) liquidations using stale-low collateral prices (steal from users).

Root cause

  • Auditor.assetPrice():
function assetPrice(IPriceFeed priceFeed) public view returns (uint256) {
  if (address(priceFeed) == BASE_FEED) return basePrice;

  int256 price = priceFeed.latestAnswer();
  if (price <= 0) revert InvalidPrice();
  return uint256(price) * baseFactor;
}
  • No updatedAt, no heartbeat, no answeredInRound checks (the IPriceFeed interface only exposes latestAnswer()/decimals()).

If you notice this price is used throughout critical logic, e.g. collateral/debt valuation:

vars.price = assetPrice(m.priceFeed);
sumCollateral += vars.balance.mulDivDown(vars.price, baseUnit).mulWadDown(adjustFactor);
sumDebtPlusEffects += vars.borrowBalance.mulDivUp(vars.price, baseUnit).divWadUp(adjustFactor);
  • and borrow eligibility:
(uint256 collateral, uint256 debt) = accountLiquidity(borrower, Market(address(0)), 0);
if (collateral < debt) revert InsufficientAccountLiquidity();
  • Liquidation math also depends on assetPrice() (e.g. checkLiquidation, calculateSeize).

Impact

  1. Over-borrow with stale-high collateral price → protocol/lenders eat bad debt

Condition and likelihood: collateral oracle is stale at an old higher price during a drop (common during L2 sequencer incidents or oracle delays).

Flow:

  • Attacker deposits collateral and enters market.
  • Calls Market.borrow(...).
  • Auditor.checkBorrow()accountLiquidity() uses the stale high price, inflating collateral value → borrow passes.
  • Attacker exits with borrowed assets.
    After: once the oracle updates, the position becomes underwater; liquidation may not fully cover → bad debt.
  1. Unfair liquidation with stale-low collateral price → steal from users

Condition and likelihood: oracle is stale at an old lower price during a pump.

Flow

  • A borrower who is healthy at the real price appears unhealthy at the stale-low price.
  • Liquidator calls Market.liquidate(...).
  • Auditor.checkLiquidation() / Auditor.calculateSeize() use the stale-low collateral price, enabling liquidation and determining seize amounts on wrong inputs.

Impact: borrower loses collateral even though they were healthy at real market prices; liquidator profits.

Recommendation

  • Replace IPriceFeed.latestAnswer() with a Chainlink-style interface exposing latestRoundData() and enforce freshness:
require(updatedAt >= block.timestamp - MAX_STALENESS)
  • Make staleness thresholds per-feed configurable (governance-set) since heartbeats differ across assets/chains.

@cruzdanilo @pmolina @lucaslain

Pls take a look at this, as i have other issues of bugs i would be submitting for this contract.

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