A heist with no money down
On 18 February 2020 an attacker opened a single Ethereum transaction, borrowed thousands of ETH that they did not own, used it to shove the price of a token sideways inside a small trading pool, tricked the bZx lending protocol into believing that fake price, walked away with the difference, and paid back the loan — and the whole thing settled in about thirteen seconds, the time it takes one block to be produced. No collateral was posted. No identity was given. The two bZx incidents that week netted roughly $1 million, and they announced a new attack class to the world: the flash-loan attack on a manipulable price oracle.
You have already met reentrancy, arithmetic bugs, and broken access control. Those are flaws inside one contract. This attack is different and more modern: every contract behaves exactly as written. The bug lives in an assumption — that the number a contract reads from another contract is the true market price. In DeFi, where contracts lend, liquidate, and settle against prices, a wrong price is a wrong balance sheet. This guide builds the attack from its two ingredients — the flash loan and the manipulable price feed — then shows the defenses that real protocols ship.
Flash loans: borrowing millions for one transaction
A flash loan is uncollateralized lending made safe by atomicity. A pool (Aave, Balancer, Uniswap, an ERC-3156 lender) hands you any amount it holds, then calls your contract back. You may do anything with the money, but by the end of the same transaction you must return the principal plus a small fee. If you do not, the lender's check fails and the entire transaction reverts — the EVM unwinds every state change as if it never happened, including the loan. The lender therefore risks nothing: either it is repaid, or the loan never existed.
// Simplified ERC-3156-style flash-loan flow (illustrative)
interface IFlashLender {
function flashLoan(address borrower, address token, uint256 amount, bytes calldata data)
external returns (bool);
}
contract Attacker {
function attack() external {
// ask for 1,000,000 USDC with zero collateral
lender.flashLoan(address(this), USDC, 1_000_000e6, "");
}
// the lender calls THIS back, in the middle of the same transaction:
function onFlashLoan(address, address token, uint256 amount, uint256 fee, bytes calldata)
external returns (bytes32)
{
// ... do the whole attack here, with a million dollars in hand ...
// finally: hand back principal + fee, or the WHOLE tx reverts
IERC20(token).approve(msg.sender, amount + fee);
return keccak256("ERC3156FlashBorrower.onFlashLoan");
}
}Flash loans are not evil — they power legitimate arbitrage, collateral swaps, and self-liquidation. But they erase the one thing that used to limit market manipulation: needing capital. Before flash loans, moving a price took a war chest. Now it takes a clever contract. The attacker's only real cost is the fee (often 0.05–0.09%, sometimes zero) plus gas.
Why an on-chain spot price is so easy to bend
The second ingredient is a naive price feed. The simplest, cheapest on-chain oracle is to read the spot price straight out of an automated market maker: in a Uniswap-v2 pool the price of a token is just the ratio of the two reserves. That feels objective — it is real, live, on-chain market data — but it is also exactly the number an attacker controls, because the constant-product formula x · y = k lets anyone move the ratio simply by trading into the pool.
Let us work it numerically. Take a thin pool — one with shallow liquidity, the attacker's favorite target — holding 1,000 TOKEN and 100,000 USDC. The product is k = 100,000,000 and the spot price is 100,000 / 1,000 = 100 USDC per TOKEN. Now the attacker swaps 100,000 USDC into the pool (ignore the 0.3% fee for clarity):
- USDC reserve becomes 100,000 + 100,000 = 200,000. To keep k constant, TOKEN reserve must fall to k / 200,000 = 500.
- The attacker therefore receives 1,000 − 500 = 500 TOKEN out of the pool.
- The new spot price the oracle now reports is 200,000 / 500 = 400 USDC per TOKEN — a clean 4× in one swap.
That huge move is the price impact of a large trade against shallow liquidity. But here is the insight that makes manipulation almost free: the move is reversible. If the attacker swaps those 500 TOKEN straight back, the reserves return to 1,000 TOKEN / 100,000 USDC and they recover their 100,000 USDC, minus only the swap fees. Pushing a constant-product price up and then back down is a round trip that costs only fees and slippage — not the headline amount. The attacker rents an absurd price for the few microseconds their transaction needs it, then gives it back.
Putting it together: the full attack
Now chain the two ingredients. Suppose a lending market values collateral using that thin pool's spot price and lets you borrow up to 75% of your collateral's value. The attacker, holding nothing of their own at risk, runs this inside one flash-loan transaction:
- Borrow 100,000 USDC via a flash loan (no collateral).
- Pump: swap that 100,000 USDC into the TOKEN/USDC pool, receiving 500 TOKEN and shoving the oracle price from 100 to 400.
- Exploit: deposit those 500 TOKEN into the lending market as collateral. It reads the spot price of 400 and credits 500 × 400 = $200,000 of collateral, then lets you borrow 75% = 150,000 USDC of real assets.
- Repay & vanish: return the 100,000 USDC flash loan plus fee out of the 150,000 you just borrowed. Walk away with ~50,000 USDC, abandoning the 500 TOKEN (honest value $50,000) inside the protocol.
Tally the books. The attacker began and ended with no capital of their own and walked off with ~$50,000. The lending protocol is now holding 500 TOKEN — really worth $50,000 — against a $150,000 loan that will never be repaid: $100,000 of bad debt. (The missing $50,000 leaks to the pool's liquidity providers and the arbitrageurs who restore its price.) The protocol's accounting was never wrong about its math — it was wrong about what a TOKEN is worth, and that single bad input cascaded into insolvency.
// The VULNERABLE oracle: a lending market priced off one pool's spot reserves
interface IUniswapV2Pair {
// token0 = TOKEN, token1 = USDC
function getReserves() external view returns (uint112 r0, uint112 r1, uint32);
}
contract VulnerableLend {
IUniswapV2Pair public pool;
// USDC per TOKEN, as 18-decimal fixed point -- the instantaneous spot price
function tokenPrice() public view returns (uint256) {
(uint112 r0, uint112 r1, ) = pool.getReserves();
return uint256(r1) * 1e18 / uint256(r0); // <-- attacker controls this
}
function borrowable(uint256 tokenCollateral) external view returns (uint256) {
uint256 valueUSDC = tokenCollateral * tokenPrice() / 1e18;
return valueUSDC * 75 / 100; // 75% LTV against a slider
}
}This template drained protocol after protocol. bZx (Feb 2020) manipulated the price of sUSD across Kyber/Uniswap to borrow far more ETH than its collateral deserved. Harvest Finance (Oct 2020) flash-manipulated the USDC/USDT balance inside a Curve pool that Harvest used to value its vault shares, looting about $24 million. The pattern is always the same shape: a flash loan supplies the size, a thin venue supplies the lie, and a contract that trusted a spot price supplies the payout.
Defending the feed: TWAPs and decentralized oracles
The fix is never "trust the spot price more carefully." It is to read a price an attacker cannot move within one transaction. Two families of defense do this.
(1) Time-weighted average price (TWAP). Instead of the instantaneous ratio, average the price over a window of time. Uniswap v2 stores a `price0CumulativeLast` accumulator that adds price × seconds on every block; a consumer records it at time t₁ and again at t₂, and the TWAP over the window is just the difference in the accumulator divided by the elapsed seconds. Because a flash-loan spike lasts only the instant of one transaction, it barely nudges an average taken over many minutes. To move a 30-minute TWAP, an attacker must hold the price off across many blocks — paying slippage every block while arbitrageurs and ordinary traders fight to drag it back. Manipulation stops being free and starts being ruinous.
(2) Decentralized oracle networks. Services like Chainlink do not read one pool at all. A set of independent node operators each pull a price aggregated across many deep markets (centralized and decentralized exchanges) off-chain, the network agrees on a median, signs it, and publishes it on-chain. Because that price reflects the whole market's liquidity, a flash loan that empties one thin pool simply does not register. A consumer reads the latest signed value and — critically — checks that it is fresh and sane:
// The FIX: read a decentralized feed, and reject stale or absurd values
interface AggregatorV3Interface {
function latestRoundData() external view returns (
uint80 roundId, int256 answer, uint256 startedAt,
uint256 updatedAt, uint80 answeredInRound);
function decimals() external view returns (uint8);
}
contract SaferLend {
AggregatorV3Interface public feed; // e.g. a TOKEN/USD price feed
uint256 public constant MAX_STALENESS = 3600; // reject if older than the heartbeat
function tokenPrice() public view returns (uint256) {
(uint80 roundId, int256 answer, , uint256 updatedAt, uint80 answeredInRound)
= feed.latestRoundData();
require(answer > 0, "bad price"); // not zero/negative
require(answeredInRound >= roundId, "stale round"); // fully reported
require(block.timestamp - updatedAt <= MAX_STALENESS, "stale"); // recent enough
return uint256(answer) * 1e18 / (10 ** feed.decimals());
}
}Decentralized feeds carry their own trade-offs. You now trust the oracle network's node set and its aggregation; updates arrive only on a heartbeat or when the price moves past a deviation threshold, so the on-chain value is intentionally a little stale; and the feed itself becomes a high-value target. The honest limit is sharper still: an oracle can only relay the price of markets that exist. In the Mango Markets exploit (Oct 2022, ~$117 million), the attacker pumped the thinly-traded MNGO token across the very venues that fed the oracle, so the oracle faithfully reported a real — but manipulated — price, and the protocol let him borrow against the inflated collateral. When an asset only trades on thin venues, no oracle design fully saves you; the cure is to not lend against assets whose price you cannot robustly source.
What an auditor checks
Oracle manipulation is now the single most lucrative bug class in DeFi, so reviewing how a protocol learns prices is the first thing an auditor does. The checklist:
- Trace every price. For each number that drives a borrow, mint, swap, or liquidation, ask: where does it come from, and can one transaction move it? A raw `getReserves()` or `balanceOf()` ratio is a red flag.
- Assume free capital. Threat-model every function as if the caller wields an unlimited flash loan. If holding a huge balance for one block lets them profit, it is exploitable.
- Prefer aggregated or time-averaged prices. Use a decentralized feed with freshness checks, or a TWAP with a long-enough window; better still, cross-check both and revert if they diverge beyond a bound.
- Bound the blast radius. Per-block borrow/withdraw caps, deviation circuit-breakers, and not pricing collateral off the very asset being borrowed all shrink what a single bad print can do.
Notice that none of these is about a coding mistake; every contract in a flash-loan oracle attack runs flawlessly. That is the lesson of this rung's hardest bug: in DeFi, security is an economic property, not just a code property. The next guide steps back to ask how professional auditors find all of these — reentrancy, arithmetic, access control, and oracle bugs — systematically, with tests, static analysis, fuzzing, and threat modeling, before an attacker with a flash loan does it for you.