name: oracle-manipulation description: Hunt single-block oracle manipulation — spot-price AMM oracles, manipulable TWAP, dependent calculations, missing staleness checks. metadata: subdomain: smart-contracts when_to_use: "oracle manipulation twap price bypass" mitre_attack: - T1565.001 - T1190
Oracle Manipulation Playbook
DeFi protocols that read a price from an on-chain source are vulnerable when the source can be moved within a single transaction or block. Classic vectors:
- Spot-price AMM oracle —
reserve1 / reserve0of a Uniswap V2 pool. Anyone with enough capital (or a flash loan) can push the price for one block. - Manipulable TWAP — short window TWAP, or TWAP over a low-liquidity pool.
- Single Chainlink feed without staleness check — feed returns 0 / stale → uses 0 in math.
- Custom oracle reading from manipulable storage — e.g., a "fair-price" oracle that reads
totalSupply()of an LP token alongside reserves. - L2 sequencer offline — L2 oracles need a "is sequencer up?" check or attacker can exploit when sequencer downs and feeds freeze.
Audit steps
1. Locate price-reading code
# Common patterns
grep -rn 'getReserves\|getAmountsOut\|getPriceFromSqrtPriceX96\|latestAnswer\|latestRoundData' src/
# Custom oracle reads
grep -rn 'IPriceOracle\|getPrice\|consult' src/
2. Trace each price use
For each call:
- Is the price read from a Uniswap V2 / Sushi / Camelot pool's reserves? → spot price = manipulable
- Is the price read from a Uniswap V3 pool's
slot0.sqrtPriceX96? → manipulable - Is it a Uniswap V3 TWAP via
OracleLibrary.consult? → check the secondsAgo window (>= 1800s = 30 min is the safe minimum) - Is it a Chainlink
latestRoundData()call? → check: isupdatedAtvalidated? IsansweredInRound >= roundId? Isanswer > 0? Are L2 sequencer feeds checked?
3. Validate staleness handling
// MISSING — vulnerable
(, int256 price, , , ) = priceFeed.latestRoundData();
// GOOD — explicit staleness + sequencer
(uint80 roundId, int256 price, , uint256 updatedAt, uint80 answeredInRound) = priceFeed.latestRoundData();
require(price > 0, "ORACLE_NEGATIVE");
require(updatedAt > block.timestamp - MAX_DELAY, "ORACLE_STALE");
require(answeredInRound >= roundId, "ORACLE_OLD_ROUND");
// On L2: also check sequencer uptime feed (L2 SequencerUptimeFeed)
4. Trace the price's use
The bug isn't oracle-reading — it's oracle-trusting. Find where the price drives a state change:
- Liquidation thresholds
- Collateral valuation
- Borrow limits
- Swap output amounts (slippage check)
- LP token pricing for vaults
PoC via Foundry
Flash-loan price push (Uniswap V2 reserves)
// Pseudo:
contract Test_oracle is Test {
function test_manipulate() public {
// 1. Flash-loan WETH from Aave / Balancer / Uniswap V3
// 2. Swap WETH → token in target pool, draining one side
// 3. Reserves now skewed → spot price way off
// 4. Call vulnerable protocol's price-dependent function
// (e.g., borrow USDC against overvalued collateral)
// 5. Reverse the swap, repay flash loan
// 6. Profit = whatever was extracted in step 4
assertGt(USDC.balanceOf(attacker), 0, "should profit");
}
}
Decepticon helper:
foundry_oracle_test(target="LendingPool", price_feed="UniV2Pair",
token0="WETH", token1="TARGETTOKEN", target_path="src/LendingPool.sol")
Chainlink stale check missing
function test_stale_price() public {
// mock the feed to return updatedAt that's 24h old
vm.mockCall(
address(priceFeed),
abi.encodeWithSignature("latestRoundData()"),
abi.encode(uint80(1), int256(STALE_PRICE), uint256(0), block.timestamp - 86400, uint80(1))
);
// call function — should revert if staleness checked, should proceed if not
target.priceDependentFunction();
// If we reach here w/o revert → bug
}
Severity calibration
| Manipulation surface | Typical impact | Severity |
|---|---|---|
| Spot-price oracle drives liquidation | Drain LPs via fake liquidations | Critical |
| Spot-price drives borrow limit | Borrow more than collateral worth | Critical |
| TWAP < 30 min | Still manipulable on low-liquidity pairs | High |
| TWAP 30 min+ on high-liquidity ETH-USDC | Hard to exploit profitably | Medium-Low |
| Missing staleness check, but feed updates often | Edge-case impact during outages | Medium |
| L2 sequencer not checked | Exploitable during sequencer downtime | High (timing-dependent) |
CVSS
- Spot-oracle + flash-loan available + drains protocol:
CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:C/C:H/I:H/A:H= 10.0 (Crit) - 30-min TWAP on illiquid pair: 7-8 (High)
- Missing staleness + Chainlink usually fresh: 5-6 (Medium)
Defender remediation
// Use Chainlink properly, not spot-price
function getPrice() internal view returns (uint256) {
(uint80 roundId, int256 answer, , uint256 updatedAt, uint80 answeredInRound)
= priceFeed.latestRoundData();
require(answer > 0, "neg price");
require(updatedAt > block.timestamp - 1 hours, "stale");
require(answeredInRound >= roundId, "old round");
// L2 only:
(, int256 sequencerStatus, , uint256 sequencerStart, ) = sequencerFeed.latestRoundData();
require(sequencerStatus == 0, "sequencer down");
require(block.timestamp - sequencerStart > 1 hours, "grace period");
return uint256(answer);
}
// Where Chainlink unavailable: use 30+ min Uniswap V3 TWAP w/ deep-liquidity pool
function getTwap(uint32 secondsAgo) internal view returns (uint160 sqrtPriceX96) {
(int24 arithmeticMeanTick, ) = OracleLibrary.consult(pool, secondsAgo);
return TickMath.getSqrtRatioAtTick(arithmeticMeanTick);
}
Known exemplars
- bZx Feb 2020: $645k via Synthetix sUSD spot-price oracle
- Harvest Finance Oct 2020: $24M via Curve pool spot price
- Cream Oct 2021: $130M via Yearn share-price manipulation
- Mango Markets Oct 2022: $114M via MNGO spot price on AMM
- Avraham Eisenberg's pattern, exactly: deposit small collateral, manipulate oracle, borrow against inflated value
- Inverse Finance Apr 2022: $15M via INV/ETH spot price
- Beanstalk Apr 2022: $182M via governance + flash-loan oracle (different but related)