Price Oracle Security
Oracles are the bridge between the blockchain and the real world — and they are the single most common root cause of DeFi exploits. When a protocol asks "what is the price of ETH?", the answer it receives determines how much users can borrow, whether positions get liquidated, and how much reward tokens are minted. Getting this wrong — or letting an attacker influence the answer — can drain every dollar from a protocol in one transaction.
Smart contracts cannot natively access external data. Everything that happens on-chain must be deterministic and verifiable by every node in the network. "The current ETH price" changes every second and is measured differently by different exchanges — it cannot be computed on-chain. Oracles solve this by having trusted parties (or decentralized networks) post price data on-chain, creating a new attack surface: whoever controls the oracle controls the protocol's view of reality.
Oracle Types and Their Manipulation Cost
| Oracle Type | How It Works | Manipulation Cost | Staleness Risk | Best Used For |
|---|---|---|---|---|
| Spot Price (AMM) | Read current reserve ratio from DEX | Very Low (flash loan) | None (always current) | Never as a price oracle |
| Uniswap v2 TWAP | Cumulative price / time window | High (sustained capital) | Medium (delay = window length) | Low-value, low-liquidity assets |
| Uniswap v3 TWAP | Geometric mean TWAP from observations | High (more capital efficient than v2) | Medium | Moderate-value assets with deep liquidity |
| Chainlink | Aggregated from many data providers | Extremely High (requires compromising multiple nodes) | Low (30-minute heartbeat) | High-value, standard assets (ETH, BTC, major tokens) |
| Protocol-Owned | Admin-controlled price feed | Zero (admin key compromise) | Variable | Avoid — extreme centralization risk |
| Multi-Oracle Median | Median of 3+ independent sources | Highest | Low | Maximum security applications |
Chainlink Deep Dive: Correct Implementation
Chainlink is the gold standard for price feeds, but using it incorrectly is extremely common. There are five distinct checks that must be performed on every latestRoundData() call.
// ❌ DANGEROUS: Multiple missing checks
function getPrice_BUGGY() external view returns (uint256) {
(, int256 price,,,) = priceFeed.latestRoundData();
return uint256(price);
// Missing: staleness check (price could be hours old)
// Missing: price > 0 check (negative/zero price)
// Missing: answeredInRound check (incomplete round)
// Missing: min/max circuit breaker check
}
// ✅ CORRECT: All five safety checks present
function getPrice_SAFE() external view returns (uint256) {
(
uint80 roundId,
int256 answer,
uint256 startedAt,
uint256 updatedAt,
uint80 answeredInRound
) = priceFeed.latestRoundData();
// Check 1: Price must be positive
require(answer > 0, "Invalid price: zero or negative");
// Check 2: Round must be complete (answeredInRound >= roundId)
require(answeredInRound >= roundId, "Incomplete round");
// Check 3: Price must not be stale (updatedAt within heartbeat)
require(
block.timestamp - updatedAt <= STALENESS_THRESHOLD, // e.g. 3600 seconds
"Stale price feed"
);
// Check 4: updatedAt must not be zero (uninitialized feed)
require(updatedAt != 0, "Uninitialized feed");
// Check 5: Price within circuit breaker bounds (min/max answer)
// Chainlink stops updating when price hits circuit breaker
// This means the returned price is NOT the actual price
require(uint256(answer) > minAnswer, "Price at circuit breaker min");
require(uint256(answer) < maxAnswer, "Price at circuit breaker max");
// Safe to use the price
return uint256(answer);
}
// How to find minAnswer/maxAnswer for a specific feed:
// IOffchainAggregator(priceFeed.aggregator()).minAnswer()
// IOffchainAggregator(priceFeed.aggregator()).maxAnswer()
Chainlink feeds have hardcoded min and max prices. During the LUNA collapse (May 2022), LUNA's price fell below Chainlink's minimum circuit breaker. The feed stopped reporting the actual price and returned the minimum value instead. Protocols using Venus and Blizz Finance that did not check for circuit breaker violations continued accepting LUNA as collateral at the minimum circuit breaker price (far above zero) — allowing attackers to borrow millions against worthless tokens.
TWAP Oracle: How It Works and When It Fails
// Uniswap v2 stores cumulative price in the pair contract
// Every block: price0CumulativeLast += currentPrice0 * timeElapsed
// To get TWAP over 30 minutes:
function getTWAP() external view returns (uint256 price) {
uint256 timeElapsed = block.timestamp - timestampLast;
require(timeElapsed >= 1800, "TWAP window too short"); // 30 min minimum
uint256 cumulativeNow = pair.price0CumulativeLast();
// Also sync current reserves to get latest cumulative
// TWAP = (cumulativeNow - cumulativeLast) / timeElapsed
price = (cumulativeNow - cumulativeLast) / timeElapsed;
}
// Cost to manipulate 30-minute TWAP on a $1M pool:
// Attacker must hold price at 2x for 30 full minutes
// Capital required: ~$500K locked, opportunity cost = 30 min × interest
// Plus: arbitrageurs will immediately trade against the manipulation
// → Makes economic sense only if target protocol holds > $1M+
A TWAP oracle is only as secure as the underlying pool's depth and age. A TWAP on a pool with $50K TVL can be manipulated for a fraction of a percent per block. Newly deployed token pairs (less than 24 hours old) have no meaningful TWAP history. Never use TWAP from a low-liquidity pool as an oracle for a high-value protocol.
Multi-Oracle Patterns
contract MultiOracleMedian {
address[] public oracles; // Chainlink, TWAP, Band Protocol
function getPrice() external view returns (uint256) {
uint256[] memory prices = new uint256[](oracles.length);
uint256 validCount = 0;
for (uint256 i = 0; i < oracles.length; i++) {
try IOracle(oracles[i]).getPrice() returns (uint256 p) {
if (p > 0) prices[validCount++] = p;
} catch {
// This oracle failed — skip it, don't revert
}
}
require(validCount >= 2, "Need at least 2 valid oracles");
// Sort and return median
return _median(prices, validCount);
}
// Optional: revert if any two oracles diverge by more than 5%
function _checkDeviation(uint256[] memory prices, uint256 count) internal pure {
for (uint256 i = 0; i < count - 1; i++) {
uint256 diff = prices[i] > prices[i+1]
? prices[i] - prices[i+1]
: prices[i+1] - prices[i];
require(diff * 100 / prices[i] < 5, "Oracle deviation too high");
}
}
}
Dead Oracles and Protocol-Owned Feeds
Chainlink node operators must be paid to keep updating a feed. If a feed becomes unprofitable or the asset loses volume, nodes may stop updating it. The feed continues to return the last price — which may be days or weeks stale. Protocols using niche asset feeds (small-cap DeFi tokens, exotic stablecoins) are particularly at risk. Always check when a feed was last updated, and implement a circuit breaker that pauses operations if the feed goes stale.
Real Exploits: Oracle Attacks Hall of Fame
// Mango Markets used an on-chain TWAP from a low-liquidity MNGO/USDC pool
// as the price oracle for MNGO collateral in its lending market
// Avraham Eisenberg's attack (he publicly claimed credit):
// Step 1: Open large MNGO perp long position on Mango
// Step 2: Buy massive amounts of MNGO on the spot market
// → MNGO price 5x'd from ~$0.03 to ~$0.15
// → Mango's oracle (reading that same pool) reported 5x price
// Step 3: Use the inflated MNGO as collateral
// → Borrow ALL available assets from Mango: USDC, SOL, BTC, ETH
// → Withdraw $115M in various tokens
// Step 4: MNGO price returns to normal
// → Mango's treasury is empty, $115M in bad debt
// → Eisenberg voted with stolen governance tokens to keep $47M as "bug bounty"
// Root cause: low-liquidity pool as oracle for high-value collateral
// Fix: use Pyth Network (external oracle) for MNGO price
// bZx used Kyber Network as its oracle for sETH/ETH
// Kyber's price was based on a very thin liquidity pool
// Attack in one transaction using flash loan:
// 1. Flash borrow 10,000 ETH from dYdX
// 2. Deposit 5,500 ETH into Compound as collateral
// 3. Borrow 112 WBTC from Compound
// 4. Short ETH on bZx (using flash loan ETH)
// 5. Dump sETH on Kyber → crashes sETH/ETH price
// 6. bZx reads manipulated Kyber price → short position is profitable
// 7. Collect profit from bZx, repay flash loan
// Net profit: ~1,300 ETH (~$350K)
// This was the exploit that popularized flash loan attacks
Synthetix Oracle Front-Running ($1B Potential, 2019)
Before Synthetix introduced on-chain price delays, their oracle system posted prices on-chain in public transactions. Any trade submitted in the same block as an oracle update could be front-run: a trader who saw the oracle update in the mempool (price is about to rise 5%) could submit a buy transaction with a higher gas price, trade at the old price, and immediately profit from the update.
// Synthetix (2019) front-running scenario
// Attacker watches mempool for oracle update transactions
// Block N mempool:
// [Synthetix oracle tx]: Update ETH price from $200 → $210 (+5%)
// [Attacker tx, higher gas]: Buy 1,000,000 sUSD of sETH at $200
// Block N executes in gas priority order:
// 1. Attacker buys sETH at $200 (old price) → gets 5000 sETH
// 2. Oracle updates ETH price to $210
// Block N+1:
// 3. Attacker sells 5000 sETH at $210 → gets $1,050,000
// 4. Risk-free profit: $50,000 in two blocks
// At $1B in TVL with frequent oracle updates: up to $1B/day could be extracted
// Synthetix's fix: "price delay" — new prices take effect after a waiting period
// This makes front-running unprofitable because the price the attacker bought at
// is now in the past — the sell also faces the delayed price constraint
// Modern fix: Commit-reveal oracle updates
// 1. Oracle posts a COMMITMENT (hash of future price) — price unknown to attackers
// 2. After N blocks, oracle REVEALS the price — already committed
// 3. Trades execute at the now-revealed price
// Front-running is impossible because the committed price was hidden
Protocol-Owned Oracles: The Centralization Risk
// ❌ DANGEROUS: Admin can set any price they want
contract AdminOracle {
mapping(address => uint256) public prices;
address public admin;
function setPrice(address token, uint256 price) external {
require(msg.sender == admin, "Only admin");
prices[token] = price; // Admin can set ETH price to $1,000,000
// Then borrow $999M against $1M of ETH collateral
}
}
// When this is a finding:
// If the admin is a single EOA (no timelock, no multisig) → Critical
// If the admin is a 3/5 multisig with no timelock → High
// If the admin is a 5/9 multisig + 48h timelock → Medium (acknowledged risk)
// Best practice: oracle updates should go through governance with timelock
// This gives the community 48+ hours to react to malicious price changes
Building Oracle-Resilient Protocols
| Design Choice | Recommended | Why |
|---|---|---|
| Primary oracle | Chainlink with all 5 checks | Most manipulation-resistant for major assets |
| Fallback oracle | TWAP from deep liquidity pool | Continues operating if Chainlink is down |
| Staleness threshold | 1.5× the feed's heartbeat | Allows for minor delays without false positives |
| Price deviation check | Revert if primary and fallback differ >5% | Catches manipulation attempts |
| Oracle updater | External keeper network | Do not rely on admin to push prices |
| Emergency pause | Auto-pause if oracle returns 0 or is stale | Stops damage automatically |
| Price admin | Governance + timelock, not single admin | Prevents admin-controlled oracle rug |
For every oracle usage in a protocol: (1) Can the price be manipulated in a single block? (2) Is there a staleness check? (3) Is there a positive price check? (4) Is there a circuit breaker check? (5) What happens if the oracle reverts — does it propagate and brick the protocol? (6) Is the same oracle used for both the collateral price and a debt token price — could manipulating one affect both? (7) Can the oracle admin set any price without a timelock?
Pyth Network: Push vs Pull Oracle Architecture
Most protocols assume a push-based oracle model — node operators update the price feed on-chain, and the protocol reads it. Pyth Network uses a pull-based model where the price data is published off-chain (on Pythnet) and users must submit a signed price update when they want to interact with the protocol. This changes the security surface significantly.
// Pyth: price data is off-chain; user must supply updateData bytes
// This shifts the cost of oracle updates to the user, not the protocol
import { IPyth } from "@pythnetwork/pyth-sdk-solidity/IPyth.sol";
import { PythStructs } from "@pythnetwork/pyth-sdk-solidity/PythStructs.sol";
contract PythOracleConsumer {
IPyth public immutable pyth;
bytes32 public immutable priceId; // e.g. ETH/USD feed ID
uint256 public constant MAX_AGE = 60; // 60 seconds max age
// ✅ User provides fresh price update; protocol validates it on-chain
function deposit(uint256 amount, bytes[] calldata priceUpdateData)
external payable
{
// Pay the Pyth update fee (usually very small, <1 gwei)
uint256 fee = pyth.getUpdateFee(priceUpdateData);
pyth.updatePriceFeeds{value: fee}(priceUpdateData);
// Get price; Pyth checks freshness and confidence interval
PythStructs.Price memory price = pyth.getPriceNoOlderThan(priceId, MAX_AGE);
// price.price: int64, price.expo: int32, price.conf: uint64
// confidence interval must be tight (e.g., < 1% of price)
require(
price.conf * 100 < uint64(price.price) * 1,
"Price confidence too wide"
);
// price.expo is negative (e.g., -8 for 8 decimal USD price)
uint256 normalizedPrice = uint256(int256(price.price))
* 1e18 / 10 ** uint256(-price.expo);
// Continue with deposit logic using normalizedPrice...
// ⚠️ Audit: Is msg.value >= fee enforced? Can user pass empty priceUpdateData?
}
}
When auditing Pyth integrations, check: (1) Is getPriceNoOlderThan() used instead of getPrice()? The bare getPrice() does not enforce freshness. (2) Is the confidence interval (price.conf) validated? Wide confidence intervals signal market uncertainty. (3) Can an attacker call functions that skip the price update step, using a stale cached price? (4) Is the exponent (price.expo) handled correctly? Treating a negative exponent as positive causes catastrophic price miscalculation. (5) Is the Pyth contract address hardcoded or upgradeable by an admin?
Oracle Sandwich Attacks and Price Anchoring
Beyond simple spot price manipulation, sophisticated attackers combine oracle manipulation with other protocol mechanics. The oracle sandwich is a pattern where an attacker manipulates the oracle before a transaction, allows victim transactions to execute at the wrong price, then restores the oracle.
// Oracle sandwich attack anatomy
// Requires: spot price oracle (manipulable in one block)
// Does NOT work against: TWAP, Chainlink (economic infeasibility)
// tx1: Attacker manipulates oracle (buy token, spike price 3x)
// tx2: Victim deposits token at inflated valuation → gets 3x shares
// tx3: Attacker restores oracle (sells token, price returns)
// Result: Victim's shares are backed by 1/3 the assets they should be
// Liquidation oracle sandwich:
// tx1: Attacker manipulates oracle DOWN (sells collateral, price crashes)
// tx2: Victim's position is now "undercollateralized" — liquidatable
// tx3: Attacker liquidates victim at a discount
// tx4: Attacker buys back the collateral at restored price
// Net: Attacker profits from liquidation bonus
// Defense: Require oracle price to be stable for N blocks before liquidation
mapping(address => uint256) lastOracleUpdateBlock;
function liquidate(address user) external {
// Oracle must have been stable for at least 2 blocks
require(
block.number - lastOracleUpdateBlock[collateral] >= 2,
"Oracle too fresh for liquidation"
);
// Prevents same-block manipulation → liquidation attacks
}
Oracle Correlation Risk in Multi-Asset Protocols
// Problem: Using the same oracle for both collateral AND debt token
// If you can manipulate the oracle, you can affect both sides of the equation
// Example: Protocol uses UniswapV2 pool as oracle for TOKENX/ETH
// User deposits TOKENX as collateral, borrows ETH
// Health Factor = (TOKENX price in ETH × amount) / (ETH borrowed)
// If attacker manipulates TOKENX/ETH price UP:
// → Collateral appears more valuable → can borrow MORE ETH
// → Health factor remains "healthy" despite over-borrowing
// LST (Liquid Staking Token) correlation risk:
// stETH oracle: reports stETH/ETH ratio from Curve pool
// ETH price oracle: Chainlink ETH/USD
// If stETH depegs from ETH (like June 2022 Celsius crisis):
// → stETH/ETH oracle crashes to 0.97
// → Mass stETH collateral liquidations triggered
// → Liquidations cause stETH selling → further depeg → cascade
// ✅ Safer: use stETH/USD oracle path = stETH/ETH × ETH/USD
// Both components should be validated independently
function getLSTPrice() internal view returns (uint256) {
uint256 ethUsd = getChainlinkPrice(ETH_USD_FEED);
uint256 lstEthRatio = getChainlinkPrice(STETH_ETH_FEED); // Not a DEX pool
return (ethUsd * lstEthRatio) / 1e18;
}
| Oracle Audit Finding | Severity | Why |
|---|---|---|
| Spot price from DEX pool used as oracle | Critical | Flash loan manipulation in single tx, no capital cost |
Missing answeredInRound >= roundId check | High | Stale data from unfinished rounds accepted as valid |
| Missing circuit breaker bounds check | High | Extreme market events return wrong min/max price |
| Missing staleness check on Chainlink feed | High | Dead oracle accepted indefinitely |
| Admin can update price without timelock | High | Single point of failure, rug vector |
| TWAP window < 30 minutes | Medium | Short windows are cheaper to manipulate sustainably |
| Same oracle for correlated asset pairs | Medium | Manipulation on one side affects both sides |
| Oracle reverts propagate to core functions | Medium | DoS: oracle failure bricks deposits/borrows |
Pyth getPrice() instead of getPriceNoOlderThan() | Medium | No freshness guarantee on the price |
| TWAP on pool with <$500K liquidity | Medium | Economically viable to manipulate with moderate capital |
Key Takeaways
- Spot prices from DEX pools can be manipulated within a single transaction using flash loans — never use them as oracles.
- Chainlink's
latestRoundData()requires five distinct safety checks: price > 0, answeredInRound >= roundId, updatedAt freshness, updatedAt != 0, and circuit breaker bounds. - Chainlink circuit breakers cause the feed to report an incorrect minimum/maximum price during extreme market events — protocols that don't check for this will accept manipulated prices as valid.
- TWAP security is proportional to pool liquidity and window length — a 30-minute TWAP on a $50K pool offers minimal protection.
- Multi-oracle median patterns with deviation checks offer the strongest protection for high-value protocols.
- Dead oracles continue returning stale prices — implement automatic pausing when oracle data is older than 2× the expected heartbeat.