Command Palette

Search for a command to run...

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

Locked

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.

The practical implication: never read spot price for risk-critical decisions (liquidation, max borrow amount, collateral valuation). Use TWAP or a trusted external oracle (Chainlink). The best defense combines both: Chainlink as primary, TWAP as secondary, deviation check to catch oracle staleness.

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. 1

    Setup: Deposit attacker collateral

    Attacker owns 100 WETH in the lending protocol. At current spot price of 2000 USDC/WETH, this is worth 200k USDC. Max borrowing (assuming 50% LTV) = 100k USDC.

  2. 2

    Frame 1: flashloan attack begins

    Attacker borrows 5000 WETH from Aave in a single tx (fee = 4.5 USDC). Now holds 5100 WETH total.

  3. 3

    Frame 2: Swap to manipulate WETH/USDC

    Attacker dumps 5000 WETH into the Uniswap V2 pool's WETH reserve, buying USDC. Pool equation: reserve0 * reserve1 = k. Shoving 5000 WETH into reserve0 drops WETH's spot price catastrophically (say to 1000 USDC/WETH). But more importantly, USDC becomes very cheap — the pool now has far fewer USDC reserves.

  4. 4

    Frame 3: Borrow against inflated collateral

    Attacker calls lending protocol again, deposits the same 100 WETH (now reads spot price as 1000 USDC/WETH, so collateral value = 100k USDC), and borrows 50k USDC fresh. Total outstanding USDC debt: 150k. Attacker now holds the original 100k + this new 50k.

  5. 5

    Frame 4: Unwind and profit

    Attacker sells the 50k USDC back to Uniswap pool for WETH, restoring the pool to near-original price. Repays the 5000 WETH flashloan + fee. Keeps 50k USDC profit. The lending protocol's reserves are drained.

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 primary + TWAP secondary + deviation guard
uint256 {chainlinkPx} = {chainlink}.latestAnswer();
uint256 {twapPx} = {uniswapV3Oracle}.consult({pool}, 1800); // 30-min TWAP
require(
    _abs({chainlinkPx}, {twapPx}) * 1e4 / {chainlinkPx} < {maxDeviation},
    "price sources disagree"
);
uint256 {priceCollateral} = {chainlinkPx};

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.
← Back to skill tree