Vulnerability class · VC-02
Oracle manipulation: how a flash loan turns a price feed into an exploit
Why spot prices from AMM reserves are not prices, what TWAP actually protects against, and the Chainlink integration checks that most teams skip.
The core mistake: treating a quote as a price
An AMM pool does not publish a price. It publishes a ratio of its current reserves, which is the price at which it will trade with you right now, given its current inventory. Those are different things, and the difference is the entire attack.
A price is a claim about the market. A reserve ratio is a claim about one pool's inventory — and inventory is something anyone with capital can change, atomically, inside the same transaction that then reads it.
// VULNERABLE — this is a quote, not a price
function getPrice() public view returns (uint256) {
(uint112 r0, uint112 r1, ) = pair.getReserves();
return uint256(r1) * 1e18 / uint256(r0);
}
Flash loans removed the capital requirement that used to make this theoretical. An attacker does not need to own $50M to move a pool by 60% — they need to borrow it for the duration of one transaction and repay it in the same transaction. The cost of the attack collapses to gas plus the flash-loan fee plus the slippage they eat and partially recover on the way out.
The attack, step by step
Every variant of this exploit has the same five-step shape. Recognising it is most of the review:
- Borrow. Flash loan a large amount of one side of the pool.
- Move. Swap into the pool, pushing the reserve ratio far from the true market price.
- Read. Call the victim protocol, which reads the manipulated ratio and mis-values something — collateral, shares, a payout, a mint amount.
- Extract. Borrow against inflated collateral, redeem shares at an inflated price, or mint tokens against a deflated backing.
- Restore and repay. Swap back, repay the flash loan, keep the difference.
// The exploit, compressed to its essence
function attack() external {
lender.flashLoan(address(this), USDC, 50_000_000e6, "");
}
function onFlashLoan(...) external {
pool.swap(50_000_000e6, 0, address(this)); // 2. move the ratio
uint256 inflated = victim.collateralValue(address(this));
victim.borrow(inflated * 80 / 100); // 4. borrow against a lie
pool.swap(0, received, address(this)); // 5. restore
USDC.transfer(address(lender), amount + fee);
}
Note what is not in this attack: no reentrancy, no access control bypass, no arithmetic overflow. Every function did exactly what it was written to do. The protocol was defeated by believing a number.
What a TWAP actually protects against
A time-weighted average price makes the manipulation cost proportional to duration. To move a 30-minute TWAP meaningfully you must hold the pool away from market price for a meaningful fraction of 30 minutes — which means eating arbitrage losses the whole time, and crucially, it cannot be done inside a single transaction. Flash loans stop working.
But TWAPs are not magic, and reviewing one means checking specifics:
- Window length. A 5-minute TWAP on an illiquid pool is manipulable across two or three blocks for a determined attacker. Thirty minutes is a common floor; longer for thin liquidity.
- Pool liquidity. The cost of manipulation scales with depth. A TWAP over a $200k pool protects nothing regardless of window. Check the actual pool you are reading, not the token's aggregate liquidity.
- Staleness in the other direction. A long TWAP lags. During a genuine crash, a 60-minute TWAP will value collateral far above market for the better part of an hour — which is its own solvency problem for a lending market.
- Observation cardinality. On Uniswap V3, if cardinality was never increased, the oracle cannot serve the window you are requesting and the call reverts or returns a much shorter effective window.
Chainlink integrations: the checks teams skip
Using a professional feed is the right default. Using it incorrectly is one of the most common findings we write.
// VULNERABLE — the return values nobody checks
function getPrice() public view returns (uint256) {
(, int256 answer, , , ) = feed.latestRoundData();
return uint256(answer); // stale? negative? wrong decimals?
}
// FIXED
function getPrice() public view returns (uint256) {
(uint80 roundId, int256 answer, , uint256 updatedAt, uint80 answeredInRound)
= feed.latestRoundData();
require(answer > 0, "invalid answer");
require(updatedAt != 0, "incomplete round");
require(answeredInRound >= roundId, "stale round");
require(block.timestamp - updatedAt <= HEARTBEAT, "stale price");
return uint256(answer) * 1e18 / 10 ** feed.decimals();
}
Beyond the return values themselves:
- Heartbeat per feed. Feeds have different update frequencies — a single global
MAX_AGEconstant across feeds is either too tight (spurious reverts) or too loose (accepts stale data). - Circuit breakers. Some feeds have min/max answer bounds. If the real price moves outside them, the feed reports the bound, not the price. This is what turned a depeg into an insolvency event for more than one protocol.
- L2 sequencer uptime. On Arbitrum, Optimism and similar, if the sequencer was down, prices went stale while the chain could not process liquidations. Read the sequencer uptime feed and enforce a grace period after recovery.
- Deviation, not just age. Feeds update on a deviation threshold or heartbeat. A price can be perfectly fresh and still be up to the threshold away from market.
Designing an oracle dependency you can defend
The strongest pattern we see in practice is layered rather than singular:
- Primary: a professional feed with full validation as above.
- Cross-check: an independent source — a long TWAP, or a second feed provider — with a maximum permitted deviation between them.
- Fail closed: if the sources disagree beyond the bound, or the primary is stale, pause the affected operation rather than falling back to the weaker source. A fallback that activates precisely when something is wrong is an attack trigger, not a safety measure.
- Bound the blast radius: per-block borrow caps, per-transaction limits, and a maximum price movement accepted between updates. These do not prevent manipulation; they cap what it can extract.
uint256 primary = _chainlinkPrice(); // fully validated
uint256 secondary = _twapPrice(30 minutes); // manipulation-resistant
require(_deviationBps(primary, secondary) <= MAX_DEVIATION_BPS, "oracle disagreement");
return primary;
How we review an oracle dependency
- Enumerate every price read in the codebase and trace it to its ultimate source. Include indirect reads through integrated protocols.
- For each source, estimate the cost of moving it by 10% for one block, and for thirty minutes. If either number is smaller than what an attacker gains, that is the finding.
- Check every Chainlink return value, per-feed heartbeats, circuit-breaker bounds and L2 sequencer handling.
- Check TWAP window length against the actual liquidity of the specific pool, and verify observation cardinality was increased.
- Check decimals normalisation at every boundary — an 8-decimal feed into 18-decimal maths is a whole finding class of its own.
- Ask what happens when the oracle fails, and confirm the answer is not “fall back to something weaker”.
- Write an invariant: collateral value must never permit a borrow that leaves the position immediately liquidatable at the cross-checked price.
Oracle manipulation is not an exotic attack any more. It is the default thing a sophisticated attacker tries first, because it defeats correct code. If your protocol values anything, the question is not whether your oracle can be manipulated — it is how much that costs, and whether the answer is more than what they would gain.
All code in this article is illustrative — written to demonstrate a pattern, not taken from any client engagement. If you want this class of issue hunted in your own codebase, that is what a DeFi Protocol Security Review is for.