Oracle Manipulation — Flashloan AMM Attack
The lending protocol below reads asset prices from a Uniswap pool's spot reserves. A flashloan can swing the price in a single transaction, letting the attacker borrow far more than collateral should allow. The Primer teaches the price-oracle mental model you need to see this class.
~9 min read
If Lab 001 taught you to look at state-mutation ordering, Lab 002 teaches you to look at where a contract gets its numbers. Smart contracts can't call a web API; they can only read on-chain state. That state is adversarial by default.
1. What is an on-chain oracle?
An oracle is any function that returns a number representing real-world state (asset price, interest rate, volatility). On Ethereum, the oracle IS another contract's state. You are reading a number written by code you don't control — and the code that writes it can be triggered by anyone with enough capital.
Three oracle sources are common: centralized price feed (Chainlink, Band Protocol, owned by a service provider, updated off-chain every 10-30 min on-chain), DEX pool reserves (Uniswap, Curve, Balancer; updated by swap() transactions, can be manipulated mid-block), and aggregate oracles (e.g., UniswapV3 TWAP oracle that stores historical reserves).
The core contract security question is: who controls the oracle input? If users control it (by submitting data), it's vulnerable to oracle manipulation. If only trusted parties control it (Chainlink oracles by design, you can't push data yourself), it's safer but not immune (you must handle stale data and fallback sources).
2. Spot price vs TWAP — the single most important distinction
spot price is the instantaneous price extracted from a DEX pool's reserve ratio right now. For Uniswap V2, that's reserve0 / reserve1 for the WETH/USDC pair. It's trivial to read on-chain but trivial to manipulate: any large swap() that moves reserves will move the spot price inside that same transaction.
TWAP (time-weighted average price) is the average price over a recent time window (typically 30 min to 4 hours). Uniswap V3 accumulates price cumulative snapshots on every swap(). Computing a TWAP requires at least two snapshots, so an attacker cannot manipulate both within a single transaction — they'd need to attack across multiple blocks, each one cost-prohibitive.
3. The flashloan attack — weaponizing spot price
A flashloan is a smart contract primitive that lends you arbitrary amounts of a token with one constraint: you must repay it (plus a small fee, typically 0.09%) by the end of the same transaction. No collateral upfront. If you fail to repay, the whole transaction reverts. This is atomic — lender is guaranteed to get paid or the entire attack reverts.
function borrow(collateral: Token, amount: uint256):
1. require(collateral.balanceOf(msg.sender) >= MIN_DEPOSIT) ← collateral check
2. maxBorrow = (collateral.amount * collateral.spotPrice()) / LTV ← spot price!
3. require(amountOut <= maxBorrow)
4. transfer(amountOut, msg.sender)
Spot-price based lending is the canonical target. An attacker with a flashloan can amplify their own collateral's value by temporarily inflating its price.
Attack timeline (WETH/USDC example, Uniswap V2)
- 1
- 2
- 3
- 4
- 5
4. The fix — TWAP, multi-source, deviation checks
There is no single three-liner for oracle manipulation; the fix is defensive composition. Use Chainlink as the primary source, TWAP as the secondary, and explicitly check that they agree within a tolerated band.
Chainlink is maintained by Chainlink Labs' node operators, updated every 10-30 min or when price moves > 0.5%. It's centralized but battle-tested and accepted by the industry. TWAP is decentralized (computed from on-chain reserves) but requires historical snapshots. The deviation check (typically 2-5% for large-cap pairs like WETH/USDC, up to 10-15% for smaller pairs) ensures they stay synchronized; a 20%+ gap signals a stale or censored feed.
Additional defensive measures: check Chainlink's heartbeat and staleness (Chainlink publishes timestamp, verify it's recent). Accept flashloanable collateral only in isolated markets (don't let governance tokens or volatile assets be collateral). For exotic pairs, prefer Uniswap V3's higher-resolution TWAP over V2.
5. What to look for as an auditor
- Does any critical path read reserve0 or reserve1 or equivalent spot reserve ratio from a DEX? That's the bullseye. If the code reads getReserves()() or similar and feeds it directly to a risk decision without TWAP or external oracle, you've found a bug.
- Is there a TWAP fallback or multi-source composition? If a contract reads only Chainlink and Chainlink goes offline (Arbitrum bridge outage, node failure), does the contract gracefully handle it or revert all operations? Graceful degradation + deviation checks are the sign of defensive design.
- What's the maximum MAX_DEVIATION_BPS tolerated between sources? Check require() statements. 2-5% is reasonable for large-cap pairs; >10% is lax and flags possible staleness. A contract that allows 50% deviation between spot and TWAP is essentially reading spot.
- Does the protocol accept flashloanable collateral? Check what tokens can be pledged. If the lending pool accepts a token that's also listed on Uniswap V2 or V3 (and that DEX is the oracle source), you can manipulate collateral price. The bug escalates if that collateral is used in a higher-level strategy (e.g., liquidation of other users).
- Governance tokens used as collateral are a red flag. MNGO in the Mango Markets hack (2022) was both the collateral and subject to price manipulation. Low liquidity on governance tokens makes them trivial to pump via small flashloan-powered swaps.
- Calculate the attacker's max profit vs flashloan cost. A 0.09% fee on 10 million borrowed is 9,000 USDC. If the protocol's arbitrage margin is only 0.5% on a 10M violation, the attacker loses money. Protocols with tight risk margins are harder to exploit; loose margins (e.g., 50% LTV with spot price, no deviation checks) are sitting ducks.