3.8.5 Oracle & Price Manipulation

Most DeFi protocols cannot function without external data. Lending protocols need asset prices to decide collateralization. Stablecoin protocols need price feeds to enforce pegs. Derivatives protocols need oracle inputs to settle contracts. Insurance protocols need real-world event feeds. The contract holds the value; the oracle holds the truth.

The vulnerability follows directly from that structure. A contract that uses untrustworthy or manipulable price data acts on lies. An attacker who can manipulate the oracle, even temporarily, can extract value as if the lies were truth — and because smart contracts execute atomically within transactions, even sub-second price manipulations can be exploited.

This section covers the specific vulnerability patterns: where price reads go wrong, how attackers manipulate them, and what defenses block each attack. The deeper treatment of oracle architecture — designing a multi-source oracle system, choosing TWAP windows, handling oracle outages — lives in Section 3.11.1. This section is about identifying and fixing the specific bug patterns that have produced production losses.

The losses are not abstract. The bZx attacks ($1M, 2020) used spot-price manipulation. Harvest Finance ($24M, 2020), Cheese Bank ($3.3M, 2020), Cream Finance ($130M total, 2021), Mango Markets ($114M, 2022), and many others used variations of the same oracle-manipulation pattern. The pattern works because the bug is easy to introduce: a single function reads pair.getReserves() and divides to compute a price, and that's enough.

Spot-Price Oracle Manipulation

The defining oracle vulnerability of DeFi. A contract reads the current price from an AMM (Uniswap, SushiSwap, etc.) by computing reserves ratios. The price reflects the current state of one pool — including any imbalance an attacker just created with a flash loan.

Vulnerable Example

// SPDX-License-Identifier: MIT
pragma solidity ^0.8.20;

interface IUniswapV2Pair {
    function getReserves() external view returns (uint112, uint112, uint32);
    function token0() external view returns (address);
    function token1() external view returns (address);
}

contract NaiveLending {
    IUniswapV2Pair public immutable pricePair;  // USDC/WETH pool
    address public immutable weth;
    address public immutable usdc;
    mapping(address => uint256) public collateralWeth;
    mapping(address => uint256) public borrowedUsdc;

    constructor(address _pair, address _weth, address _usdc) {
        pricePair = IUniswapV2Pair(_pair);
        weth = _weth;
        usdc = _usdc;
    }

    // BUG: spot price from a single pool
    function getWethPrice() public view returns (uint256 usdcPerWeth) {
        (uint112 r0, uint112 r1, ) = pricePair.getReserves();
        if (pricePair.token0() == weth) {
            return (uint256(r1) * 1e18) / uint256(r0);
        } else {
            return (uint256(r0) * 1e18) / uint256(r1);
        }
    }

    function borrow(uint256 usdcAmount) external {
        // Collateral check uses spot price
        uint256 wethPrice = getWethPrice();
        uint256 collateralValueUsdc = (collateralWeth[msg.sender] * wethPrice) / 1e18;
        require(collateralValueUsdc >= usdcAmount * 2, "insufficient collateral");
        borrowedUsdc[msg.sender] += usdcAmount;
        IERC20(usdc).transfer(msg.sender, usdcAmount);
    }
}

The attack against this contract:

  1. Attacker takes a flash loan of, say, 10,000 WETH
  2. Attacker swaps the WETH into the USDC/WETH pool, dramatically increasing WETH reserves
  3. With WETH reserves now inflated, getWethPrice() returns a much lower USDC-per-WETH value (more WETH = each WETH worth less in this pool)
  4. Wait — that's wrong. Let me reconsider:

The attacker actually wants WETH to appear more valuable when they're depositing it as collateral, or less valuable when liquidating someone else. Let's trace the real attack:

  1. Attacker deposits 1 WETH as collateral
  2. Attacker takes a 10,000 USDC flash loan
  3. Attacker swaps 10,000 USDC for WETH in the pool — this drains WETH out, making remaining WETH scarce relative to USDC
  4. Now getWethPrice() reports a vastly inflated USDC-per-WETH price (because the USDC reserve grew and WETH reserve shrank)
  5. Attacker calls borrow() — their 1 WETH of collateral is now valued at the inflated price, allowing them to borrow far more than 1 WETH is actually worth
  6. Attacker takes the borrowed USDC, repays the flash loan, keeps the profit

The vulnerability is reading a price from a venue that the attacker can manipulate within the same transaction. AMM spot prices are a function of reserves, and reserves change with every swap. The "price" returned is correct for that exact moment in that exact pool — but it doesn't reflect a market-wide price, and the attacker has just made that pool stop reflecting the market price.

The canonical fix is to read from an off-chain oracle that aggregates prices from many sources and is not directly manipulable by any single trade. Chainlink is the dominant solution:

// SPDX-License-Identifier: MIT
pragma solidity ^0.8.20;

import "@chainlink/contracts/src/v0.8/shared/interfaces/AggregatorV3Interface.sol";

contract SafeLending {
    AggregatorV3Interface public immutable wethUsdFeed;
    address public immutable weth;
    address public immutable usdc;
    mapping(address => uint256) public collateralWeth;
    mapping(address => uint256) public borrowedUsdc;

    uint256 public constant MAX_FEED_AGE = 1 hours;

    error StalePrice(uint256 age);
    error InvalidPrice(int256 price);

    constructor(address _feed, address _weth, address _usdc) {
        wethUsdFeed = AggregatorV3Interface(_feed);
        weth = _weth;
        usdc = _usdc;
    }

    function getWethPrice() public view returns (uint256 usdcPerWeth) {
        (
            ,
            int256 answer,
            ,
            uint256 updatedAt,
            
        ) = wethUsdFeed.latestRoundData();

        if (answer <= 0) revert InvalidPrice(answer);
        if (block.timestamp - updatedAt > MAX_FEED_AGE) {
            revert StalePrice(block.timestamp - updatedAt);
        }

        // Chainlink WETH/USD feed has 8 decimals; we need to scale to USDC (6 decimals)
        // returned: (answer / 1e8) USD per WETH
        // scaled:   (answer * 1e6 / 1e8) USDC per WETH = answer / 100
        return uint256(answer) / 100;
    }

    function borrow(uint256 usdcAmount) external {
        uint256 wethPrice = getWethPrice();
        uint256 collateralValueUsdc = (collateralWeth[msg.sender] * wethPrice) / 1e18;
        require(collateralValueUsdc >= usdcAmount * 2, "insufficient collateral");
        borrowedUsdc[msg.sender] += usdcAmount;
        IERC20(usdc).transfer(msg.sender, usdcAmount);
    }
}

Three defenses applied:

  1. External oracle (Chainlink) instead of an AMM. Chainlink's price comes from aggregation across multiple exchanges and is not movable by a single trade.

  2. Staleness check. Chainlink feeds have a heartbeat (typically 1 hour for major assets, longer for less-liquid ones). If the feed hasn't been updated within that window, the price may be stale — the contract should reject reads rather than trust the old value. Picking the threshold requires knowing the specific feed's heartbeat; check Chainlink's documentation for each feed.

  3. Sanity check on the value. A returned price of zero or a negative number indicates the feed is unhealthy. Reverting rather than computing with bad data prevents downstream corruption.

The 2020 Compound liquidation incident (small in dollar terms compared to the bZx flash-loan attacks, but instructive) was an oracle-staleness failure — the protocol used a price source that returned an outdated value, causing legitimate positions to be erroneously liquidated. Staleness checks would have prevented that liquidation.

Fixed Example: Uniswap V3 TWAP

When an external oracle isn't available (long-tail assets, gas constraints), Uniswap V3's TWAP (time-weighted average price) is a manipulation-resistant on-chain alternative. The TWAP averages prices over a configurable window; manipulating it requires sustaining the price imbalance for the entire window, which costs far more than manipulating a spot price.

// SPDX-License-Identifier: MIT
pragma solidity ^0.8.20;

import "@uniswap/v3-core/contracts/interfaces/IUniswapV3Pool.sol";
import "@uniswap/v3-core/contracts/libraries/TickMath.sol";

contract TwapOracle {
    IUniswapV3Pool public immutable pool;
    address public immutable token0;
    address public immutable token1;
    uint32 public constant TWAP_WINDOW = 30 minutes;

    constructor(address _pool) {
        pool = IUniswapV3Pool(_pool);
        token0 = pool.token0();
        token1 = pool.token1();
    }

    function getTwapPrice() public view returns (uint256 priceX96) {
        uint32[] memory secondsAgos = new uint32[](2);
        secondsAgos[0] = TWAP_WINDOW;
        secondsAgos[1] = 0;

        (int56[] memory tickCumulatives, ) = pool.observe(secondsAgos);

        int56 tickCumulativesDelta = tickCumulatives[1] - tickCumulatives[0];
        int24 timeWeightedAverageTick = int24(tickCumulativesDelta / int56(uint56(TWAP_WINDOW)));

        // Convert tick to price (sqrt(price) * 2^96)
        uint160 sqrtPriceX96 = TickMath.getSqrtRatioAtTick(timeWeightedAverageTick);
        priceX96 = (uint256(sqrtPriceX96) * uint256(sqrtPriceX96)) >> 96;
    }
}

A 30-minute TWAP window means an attacker must sustain a price imbalance for half an hour to materially move the TWAP — generally requiring far more capital than a single flash loan can provide, and exposing the attacker to arbitrage during the window.

The window choice is a trade-off:

  • Shorter windows (e.g., 5 minutes) track price changes faster but are easier to manipulate
  • Longer windows (e.g., 1 hour) are harder to manipulate but lag the real market price
  • Typical production windows are 15-60 minutes depending on the asset's liquidity and the protocol's risk profile

TWAPs are not immune to manipulation. The Inverse Finance exploit (April 2022, $15M) used a sandwich attack against an Inverse-Yearn vault's TWAP — even with a TWAP, a sufficiently liquid attacker can sustain the manipulation. TWAPs raise the cost of attack but don't make it impossible.

Cross-reference: Section 3.11.1 covers TWAP configuration in depth, including how to combine TWAPs with Chainlink for robustness.

Single-Source Dependency

Even a "good" oracle becomes a single point of failure if the protocol depends entirely on it. Chainlink can have outages. Specific feeds can be deprecated. A contract that has no fallback when the primary oracle fails is brittle in ways that have caused real losses.

Vulnerable Pattern

function liquidate(address borrower) external {
    uint256 price = chainlink.latestAnswer();  // what if Chainlink is down?
    // ... liquidation logic
}

If Chainlink's price feed is paused (which has happened, typically for less than an hour at a time), this function reverts and no liquidations can proceed. Bad debt accumulates while the protocol is helpless to act.

The opposite failure: if Chainlink keeps returning the last price during an outage (rather than reverting), the contract proceeds with stale data and may liquidate positions at the wrong price.

Fixed Example: Multi-Source Aggregation

The defense is layered fallback. Primary source is Chainlink; if Chainlink is stale, fall back to a TWAP; if both are unavailable, pause the affected operations.

function getPrice() public view returns (uint256, bool isStale) {
    (uint256 chainlinkPrice, bool chainlinkOk) = _tryChainlink();
    if (chainlinkOk) {
        return (chainlinkPrice, false);
    }

    (uint256 twapPrice, bool twapOk) = _tryTwap();
    if (twapOk) {
        return (twapPrice, false);
    }

    // Both unavailable — return last known good but flag staleness
    return (lastGoodPrice, true);
}

function liquidate(address borrower) external whenNotPaused {
    (uint256 price, bool stale) = getPrice();
    require(!stale, "oracle unavailable");
    // ... liquidation logic
}

The isStale return value lets different operations make different policy decisions. A liquidation might require fresh data; a view function returning a balance estimate might tolerate stale data with a warning.

Sanity Checks Between Sources

When multiple sources are available, sanity checks between them catch failures of any one source:

function getPriceWithCrossCheck() public view returns (uint256) {
    uint256 chainlinkPrice = chainlink.latestAnswer();
    uint256 twapPrice = uniswapTwap.getTwapPrice();

    // Compute the deviation between sources
    uint256 deviation = chainlinkPrice > twapPrice
        ? ((chainlinkPrice - twapPrice) * 10000) / twapPrice
        : ((twapPrice - chainlinkPrice) * 10000) / chainlinkPrice;

    require(deviation < 500, "oracle sources disagree by >5%");
    return chainlinkPrice;  // prefer Chainlink, but only if TWAP agrees
}

The check catches: a manipulated TWAP (Chainlink stays put, deviation triggers revert); a failed Chainlink feed returning stale data while the real market has moved (TWAP shows current price, deviation triggers); a depeg event for a pegged asset (both sources move together — deviation stays low, but other defenses like circuit breakers catch the absolute price change).

The 5% threshold above is illustrative; production protocols typically use 1-3% for major assets and wider thresholds for volatile assets.

Read Path vs Write Path Confusion

A subtle class of oracle bug: the contract reads a price that depends on state which the same transaction is about to change. The read returns the post-write value rather than the pre-write value, leading to circular logic.

Vulnerable Example

contract Vault {
    function depositAndComputeShares(uint256 amount) external {
        IERC20(asset).transferFrom(msg.sender, address(this), amount);  // increases vault balance
        uint256 sharePrice = vault.getSharePrice();                     // reads vault balance
        // sharePrice now reflects the deposit that just happened
        uint256 shares = amount * 1e18 / sharePrice;  // wrong! diluted by own deposit
        _mint(msg.sender, shares);
    }
}

The user's deposit increases the vault's balance, which increases sharePrice. The user then computes their shares using the inflated price — getting fewer shares than they should.

This isn't quite "oracle manipulation" in the classic sense, but it has the same root cause: trusting a calculation that depends on state the same transaction has just modified.

Fixed Example

Read the price before modifying state:

function depositAndComputeShares(uint256 amount) external {
    uint256 sharePrice = vault.getSharePrice();  // BEFORE the deposit
    uint256 shares = amount * 1e18 / sharePrice;

    IERC20(asset).transferFrom(msg.sender, address(this), amount);
    _mint(msg.sender, shares);
}

This pattern recurs in ERC-4626 vaults and any protocol where deposits affect a price the contract then reads. The ERC-4626 standard defines previewDeposit(assets) and convertToShares(assets) to provide the correct pre-state calculation. Use those rather than re-reading state after modifications.

Read-Only Reentrancy Variant

A related bug: a contract reads a price from another contract whose state is mid-update. The Curve LP token's get_virtual_price() was the canonical case — during a removal of liquidity, total supply was updated before reserves, so reading get_virtual_price() mid-transaction returned an inflated value. Lending protocols that used get_virtual_price() as collateral pricing issued loans against the inflated value, then the original transaction completed and the price returned to normal, leaving the loans under-collateralized.

This is covered in Section 3.8.2 (Reentrancy Family — Read-Only Reentrancy variant) and the defense is the same: ensure read endpoints are consistent during state updates, either by ordering updates properly or by exposing a lock state that consumers can check.

Oracle Decimals and Scaling

A non-vulnerability per se but a frequent source of bugs that enable exploits or cause loss of funds. Different oracles return prices in different scales.

  • Chainlink price feeds return values with feed-specific decimals (typically 8 for USD pairs, 18 for ETH-denominated pairs)
  • Uniswap V3 returns prices as sqrtPriceX96 requiring conversion
  • Custom oracles return whatever they choose

Vulnerable Example

function getPrice(address token) external view returns (uint256) {
    return chainlink.latestAnswer();  // assumes 18 decimals
}

If the actual Chainlink feed returns 8 decimals, the returned price is off by a factor of 10^10. Subsequent calculations treat this enormous number as the price, causing wildly incorrect collateral valuations.

Fixed Example

Always normalize to a known decimal base in the contract layer:

function getPrice(address token) external view returns (uint256) {
    int256 answer = chainlink.latestAnswer();
    uint8 feedDecimals = chainlink.decimals();
    require(answer > 0, "bad price");

    // Normalize to 18 decimals
    if (feedDecimals < 18) {
        return uint256(answer) * (10 ** (18 - feedDecimals));
    } else if (feedDecimals > 18) {
        return uint256(answer) / (10 ** (feedDecimals - 18));
    } else {
        return uint256(answer);
    }
}

The contract now has a uniform 18-decimal internal representation regardless of the feed's native scale. Add unit tests that verify the normalization for the specific feeds your contract uses.

Foundry Test for Oracle Manipulation Resistance

// SPDX-License-Identifier: MIT
pragma solidity ^0.8.20;

import "forge-std/Test.sol";
import "../src/SafeLending.sol";
import "./MockChainlinkFeed.sol";

contract OracleResistanceTest is Test {
    SafeLending lending;
    MockChainlinkFeed feed;

    function setUp() public {
        feed = new MockChainlinkFeed();
        feed.setPrice(2000e8);  // 2000 USDC per WETH (8 decimals)
        feed.setUpdatedAt(block.timestamp);

        lending = new SafeLending(address(feed), address(0xWETH), address(0xUSDC));
    }

    function test_freshPriceAccepted() public view {
        uint256 price = lending.getWethPrice();
        assertEq(price, 2000e6, "wrong scaled price");  // 2000 USDC in 6 decimals = 2e9
    }

    function test_stalePriceRejected() public {
        feed.setUpdatedAt(block.timestamp - 2 hours);
        vm.expectRevert(abi.encodeWithSelector(SafeLending.StalePrice.selector, 7200));
        lending.getWethPrice();
    }

    function test_invalidPriceRejected() public {
        feed.setPrice(0);
        vm.expectRevert(abi.encodeWithSelector(SafeLending.InvalidPrice.selector, int256(0)));
        lending.getWethPrice();
    }

    function test_negativePriceRejected() public {
        feed.setPrice(-1);
        vm.expectRevert(abi.encodeWithSelector(SafeLending.InvalidPrice.selector, int256(-1)));
        lending.getWethPrice();
    }
}

These tests assert the defensive properties of the oracle integration: the contract behaves correctly when prices are fresh, and reverts safely in each failure mode. Without these tests, oracle integration bugs frequently survive deployment.

A more sophisticated test would mock a price-deviation scenario across two oracles to verify the cross-check logic; that pattern is implementation-specific and varies by which oracles the contract uses.

Quick Reference

FailureWhat goes wrongDefense
Spot price from one AMMAttacker manipulates pool reserves via flash loan within same transactionExternal aggregated oracle (Chainlink) or TWAP with sufficient window
Missing staleness checkOutdated price used long after feed stopped updatingCompare updatedAt to block.timestamp; revert if older than feed heartbeat
Missing value sanity checkZero or negative prices used as if validrequire(answer > 0) and bounded-range checks
Single-source dependencyOracle outage halts protocol or trusts stale dataFallback hierarchy: primary → secondary → paused state
No cross-source checkOne source manipulated, no comparison catches itCompute deviation between sources; revert above threshold
Read-after-write (own transaction)Price reflects the deposit that just happenedRead prices before modifying state; use previewX helpers
Read-only reentrancy on external poolExternal pool mid-update returns inconsistent valuePool exposes lock; readers respect lock state
Decimal mismatchPrice off by 10^N due to scale assumptionRead decimals() and normalize at every integration point

Cross-References

  • Reentrancy variants — Section 3.8.2 covers read-only reentrancy in depth, the specific case that produced the Curve get_virtual_price() exploits
  • Defensive patterns — Section 3.7.5 covers circuit breakers and rate limits that complement oracle defenses
  • Advanced oracle architecture — Section 3.11.1 covers oracle system design: multi-feed aggregation, fallback strategies, push vs pull oracles
  • Flash loans as attack primitive — Section 3.11.4 covers flash loans in depth; this section addresses one of their most common uses
  • Real exploits — Section 3.10.3 (bZx) and 3.10.8 (Euler Finance) are oracle/liquidation-manipulation case studies in depth
  • Auditor's view — Section 4.11 and 4.15 cover oracle vulnerability detection during audit
  • Chainlink documentation — for specific feed addresses, heartbeats, and deviation thresholds, consult Chainlink's official feed registry (https://docs.chain.link/data-feeds/price-feeds/addresses)