Oracle price manipulation
If your contract reads a price from a source an attacker can move within one transaction, they will move it. Spot prices from an AMM pool are the classic mistake: a flash loan gives an attacker enough capital to skew the pool, act on the false price, and restore it, all before anyone can react.
Vulnerable code
function getPrice() public view returns (uint256) {
(uint112 r0, uint112 r1, ) = pair.getReserves();
return (uint256(r1) * 1e18) / uint256(r0); // spot price
}
function borrow(uint256 collateral) external {
uint256 value = collateral * getPrice() / 1e18;
_lend(msg.sender, value * 80 / 100);
}
Why it breaks
getReserves() reports the pool's balance right now. An attacker takes a flash loan, swaps a large amount into the pool to push the reported price up, calls borrow against collateral now valued at several times its real worth, takes the loan, reverses the swap, and repays the flash loan. Everything settles in one transaction, so there is no window for arbitrage to correct the price first.
Watch the attack run
The same bug as a stepped sequence. Play it, or walk through with the arrow keys.
The fix
AggregatorV3Interface internal immutable feed;
function getPrice() public view returns (uint256) {
(
uint80 roundId,
int256 answer,
,
uint256 updatedAt,
uint80 answeredInRound
) = feed.latestRoundData();
require(answer > 0, "bad price");
require(answeredInRound >= roundId, "stale round");
require(block.timestamp - updatedAt < 3600, "price too old");
return uint256(answer);
}
The invariant
The fix above is what an audit gives you. This is what monitoring gives you: the thing that should always be true once the contract is live, stated precisely enough to check every block.
Reported price stays within a bounded deviation of a time-weighted reference over N blocks.
invariant abs(spot - twap(30 min)) / twap(30 min) <= 0.02 invariant block.timestamp - updatedAt < heartbeat
How to test for it
A finding you cannot reproduce is an opinion. Write the test before you write the fix, watch it fail, then make it pass.
function testFlashLoanCannotMovePrice() public {
uint256 p1 = lending.getPrice();
// simulate a large swap through the pool
deal(address(weth), attacker, 10_000 ether);
vm.prank(attacker);
router.swapExactTokensForTokens(10_000 ether, 0, path, attacker, block.timestamp);
assertApproxEqRel(lending.getPrice(), p1, 0.01e18); // within 1%
}
Where it has caused real losses
Oracle manipulation has caused some of the largest DeFi losses on record. Repeatedly, the pattern is identical: a lending market prices collateral from a thin pool, an attacker borrows capital to move that pool, and the protocol lends against a price that existed for one block.
What we check during review
What we look for during review: does any pricing path touch getReserves, balanceOf, or a single-block observation? Is there a staleness check on the oracle response, and a sanity bound on how far the price may move between updates? Is there a fallback when the primary feed reverts, and can that fallback be manipulated more cheaply than the primary? Uniswap V3 TWAPs are stronger than spot but still manipulable on low-liquidity pairs if the observation window is short.
Want us to check your contract for this?
A free monitoring assessment covers one contract up to 200 lines, manually reviewed, findings back within 72 hours. This class is on the checklist for every review we run.
Related vulnerabilities
Found this useful?
We publish these because the alternative, asking you to trust us, is worth less. Send a contract and we’ll apply the same thinking to your code.
- Reply written by the auditor who read your code
- No sales sequence, no drip campaign, no retargeting
- We’ll tell you if you don’t need a paid audit yet
- Report published only with your written permission