# 10 — The case of the stale oracle

> Scenario file: `scenarios/stale_oracle.yaml` Severity observed: HIGH Time-to-reproduce: 3 minutes

This is the simplest attack in the book and the one we hit most often. Nine times out of ten, the first run of `economicfuzz attack` against a new lending protocol surfaces some flavour of this, and nine times out of ten the protocol's authors did not realise they had it.

## The setup

A lending protocol uses a Pyth feed to value collateral. The protocol's read path looks like this:

```
let price = pyth_feed.price();
let value = collateral_amount.checked_mul(price)?;
let max_borrow = value.checked_mul(LTV)?;
```

The bug is what is *not* there. There is no check on `pyth_feed.publish_time`. There is no check that the feed's confidence interval is below a threshold. There is no slot-age guard.

When we modelled this against six lending protocols last quarter, four of them had this exact shape.

## The attack

The adversary picks a moment when an oracle update is delayed — a Saturday afternoon during a quiet market, a few seconds after the Pyth publisher restarts, a chain congestion event. During that window, the on-chain price the protocol reads is stale.

If the asset has rallied off-chain in that window, the adversary deposits at the stale (lower) price and immediately borrows against the stale value. They then bridge the borrowed asset out and wait for the oracle to update. When it does, the protocol's view of the collateral catches up to the market, and the deposit is now worth substantially more — but the adversary has already extracted value at the stale rate.

If the asset has fallen, the inverse: the adversary borrows at the stale (higher) collateral value, then walks away when the price corrects, leaving an undercollateralised position that the protocol has to absorb.

## What the fuzzer does

`stale_oracle.yaml` defines three steps. The first manipulates the on-chain feed's `publish_time` to a value 30 seconds older than the current slot. The second deposits collateral and borrows the protocol's maximum LTV against it. The third checks the conservation invariant — total protocol equity should not have decreased.

The first time we ran this against a clean Anchor project that proxied a Pyth feed without a freshness check, the conservation invariant broke by $4,200 on a $100,000 simulated TVL. We have never seen a freshness-aware protocol break this scenario.

## The fix

There is exactly one fix: enforce a maximum staleness on the read.

```rust
let max_staleness = 30; // seconds
let now = Clock::get()?.unix_timestamp;
require!(
    now - pyth_feed.publish_time < max_staleness,
    LendingError::StaleOracle
);
```

The threshold is protocol-specific. Money markets we have audited use 15–60 seconds; perpetuals use 5–10. The lower the threshold, the more often the protocol will refuse a transaction; the higher the threshold, the larger the attack window. There is no universally right answer. There is a universally wrong answer, which is *no check at all*.

The hardening index in chapter 80 cross-references this finding to its fix as `STALE-ORACLE-1`.


---

# Agent Instructions: Querying This Documentation

If you need additional information that is not directly available in this page, you can query the documentation dynamically by asking a question.

Perform an HTTP GET request on the current page URL with the `ask` query parameter:

```
GET https://economicfuzz.gitbook.io/economicfuzz-docs/case-studies/10-case-stale-oracle.md?ask=<question>
```

The question should be specific, self-contained, and written in natural language.
The response will contain a direct answer to the question and relevant excerpts and sources from the documentation.

Use this mechanism when the answer is not explicitly present in the current page, you need clarification or additional context, or you want to retrieve related documentation sections.
