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
    No fields configured for issues without a type.

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions