At a glance
- The bug
- A contract asks a market "what is this worth right now?" and trusts the answer, when the answer can be moved on purpose within the same transaction
- The lever
- A flash loan borrows millions with no collateral, as long as it is repaid before the transaction ends, so the attacker's own capital is not the limit
- The target
- Spot price from a single pool: a large trade moves it, the victim reads the moved price, the attacker trades back
- The fix
- Do not read an instantaneous price you can move. Use a time-weighted average, or an independent oracle with multiple sources
- The habit
- For any price a contract acts on, ask: could someone move this within one transaction, and would my contract believe them?
The price a contract cannot trust
A decentralised exchange holds two tokens in a pool and prices them by their ratio. That is elegant and it is also the weakness: the price is whatever the current balances say it is, and a large enough trade changes the balances, so a large enough trade changes the price. If another contract reads that price at the wrong instant and lends against it, or mints against it, or liquidates against it, the reader can be fed a number the attacker chose.
This is the single most costly bug class in DeFi. It is not a coding slip like a missing modifier; it is a design mistake about which numbers can be trusted, and it has drained hundreds of millions across dozens of protocols.
Why a flash loan removes the "you'd need to be rich" defence
The instinct is that moving a large pool's price would take enormous capital, so only a whale could do it. A flash loan removes that objection entirely. It lends you an arbitrary amount with no collateral, on one condition: you repay it, plus a fee, before the transaction finishes. If you cannot, the whole transaction reverts as if it never happened, so the lender is never at risk. The attacker borrows millions, uses them for the length of one transaction, and repays from the profit, all atomically.
The vulnerable read
The tell is a contract that derives a price from a pool's live balances, right now, and acts on it in the same call.
// Price = how much USDC the pool holds per token. Moves with every trade.
function spotPrice() public view returns (uint256) {
uint256 tokenReserve = token.balanceOf(address(pool));
uint256 usdcReserve = usdc.balanceOf(address(pool));
return (usdcReserve * 1e18) / tokenReserve;
}
function borrowAgainst(uint256 collateralTokens) external {
uint256 value = collateralTokens * spotPrice() / 1e18;
_lend(msg.sender, value); // lends against a price the caller can move
}
The attacker flash-borrows, swaps a huge amount into the pool to spike spotPrice(), calls borrowAgainst to borrow far more than their collateral is really worth, swaps back to unwind the price, and repays the flash loan. The protocol is left holding collateral worth a fraction of what it lent.
The fix: do not read a price you can move in one block
The defence is to price against something that cannot be shoved within a single transaction. There are two standard answers, and serious protocols use both.
| Approach | Why it resists manipulation |
|---|---|
| Time-weighted average price (TWAP) | Averages the price over many blocks, so a one-block spike barely moves it. Manipulating it means holding the price off-market for a sustained time, which is expensive and arbitraged away. |
| An independent oracle (e.g. a decentralised price feed) | Aggregates many off-chain and on-chain sources and reports a signed value, so a single pool's balances are not the input at all. |
// Read a value that is not the instantaneous balance of one pool.
function priceUSD() public view returns (uint256) {
// A decentralised feed returns a recent, aggregated, signed price.
(, int256 answer, , uint256 updatedAt, ) = priceFeed.latestRoundData();
require(answer > 0, "bad price");
require(block.timestamp - updatedAt < 1 hours, "stale price");
return uint256(answer);
}
An oracle is only as good as the checks around it. Even a good feed can be stale, paused, or report a value from a market that has itself gapped. Always check that the timestamp is recent, that the value is positive and inside a sane band, and think about what happens on the day the feed stops updating. "Trust the oracle" without those guards is its own vulnerability.
The short version. A single pool's spot price is a number an attacker can move within one transaction, and a flash loan means they do not need their own capital to do it. Any contract that lends, mints or liquidates against that number can be fed a lie. Price against a time-weighted average or an independent aggregated oracle instead, and guard the reading for staleness and sanity. The question to carry into every review: could someone move this price in one transaction, and would my contract believe them?
References & further reading
- Uniswap, Oracles and TWAP. How time-weighted average prices are built and why they resist single-block manipulation.
- Chainlink, Using Data Feeds. Reading an aggregated price feed, including the staleness and round checks.
- Ethereum community, Oracles. Why on-chain contracts cannot see off-chain prices on their own, and the trust that introduces.
- Ethereum community, Smart contract security. The broader security context.