Price manipulation attacks: spot prices, TWAPs, and how to fuzz your defenses
Price Manipulation Attacks: Spot Prices, TWAPs, and How to Fuzz Your Defenses
By nican0r — Security Researcher at Recon
Price feeds are the backbone of DeFi. Lending protocols need them for collateral valuation. DEXes use them for routing. Vaults depend on them for share pricing. And attackers know this — manipulate a price feed, and you can drain almost anything downstream.
I've seen dozens of protocols assume their oracle setup is safe because they're "using Chainlink" or "using a TWAP." Neither is a magic shield. The devil's in the details, and the only way to know your defenses actually hold is to test them. Let's break down each attack vector, look at real examples, and build the invariant properties that catch manipulation before it hits mainnet.
Attack vector 1: spot price manipulation via flash loans
This is the most common oracle attack. It's straightforward: borrow a huge amount via flash loan and dump it into a pool to move the spot price. Then trigger a protocol action that reads the manipulated price, profit, and repay the flash loan.
The mechanism
1. Flash borrow 100M USDC
2. Swap into Pool X → spot price of TOKEN/USDC spikes
3. Call VulnerableProtocol.borrow() which reads Pool X's spot price
4. Protocol thinks your TOKEN collateral is worth 10x more
5. Borrow far more than your collateral is actually worth
6. Swap back, repay flash loan, keep the excess borrows
Real example: Harvest Finance (2020)
Harvest Finance lost ~$34M because their vault used Curve pool spot prices for USDC valuation. The attacker repeatedly flash-loaned USDC, moved the Curve pool price, deposited into Harvest at the inflated price, then reversed the swap. Each cycle extracted value.
Vulnerable code pattern
contract VulnerableLender {
IUniswapV2Pair public pair;
function getTokenPrice() public view returns (uint256) {
// NEVER DO THIS -- reads manipulable spot price
(uint112 reserve0, uint112 reserve1, ) = pair.getReserves();
return (uint256(reserve1) * 1e18) / uint256(reserve0);
}
function borrow(uint256 collateralAmount) external {
uint256 price = getTokenPrice();
uint256 collateralValue = collateralAmount * price / 1e18;
uint256 maxBorrow = collateralValue * 75 / 100; // 75% LTV
// Attacker gets inflated maxBorrow
_mint(msg.sender, maxBorrow);
}
}
The invariant
// Property: price used for lending decisions should not deviate
// significantly from a reference oracle within a single block
function invariant_priceNotManipulable() public returns (bool) {
uint256 protocolPrice = lender.getTokenPrice();
uint256 referencePrice = chainlinkOracle.latestAnswer();
// Allow 5% deviation -- anything more signals manipulation
uint256 deviation = protocolPrice > referencePrice
? protocolPrice - referencePrice
: referencePrice - protocolPrice;
return deviation <= (referencePrice * 5 / 100);
}
// Property: no single transaction should enable borrowing
// more than collateral value at fair price
function invariant_borrowNeverExceedsCollateral() public returns (bool) {
for (uint256 i = 0; i < actors.length; i++) {
uint256 borrowed = lender.borrowBalance(actors[i]);
uint256 collateralAtFairPrice = lender.collateralOf(actors[i])
* fairOracle.getPrice() / 1e18;
if (borrowed > collateralAtFairPrice) return false;
}
return true;
}
Attack vector 2: TWAP manipulation
TWAPs (Time-Weighted Average Prices) are supposed to resist flash loan attacks because they average the price over time. And they do, for single-block manipulation. But they're not immune to sustained manipulation over multiple blocks.
The mechanism
Uniswap V2/V3 TWAPs accumulate price*time observations. If an attacker can move the spot price and hold it there across multiple blocks, the TWAP shifts. The cost depends on:
- Pool liquidity (deeper pool = more expensive to move)
- TWAP window length (longer window = more blocks needed)
- Block time (shorter blocks = more observations needed)
Block N: Spot price = $100, TWAP = $100
Block N+1: Attacker pushes spot to $200, holds position
Block N+2: Still at $200
...
Block N+K: TWAP has now shifted toward $200
When TWAPs break
Short TWAP windows (5-15 minutes) on low-liquidity pools are the weak spot. An attacker with enough capital can rent liquidity, push the price for a few blocks, and manipulate the TWAP enough to extract value.
The Euler Finance exploit in 2023 didn't use TWAP manipulation directly. But the Mango Markets exploit (2022, ~$114M) showed that sustained price manipulation across blocks is absolutely practical on low-liquidity markets.
Testing TWAP resistance
// Property: TWAP-based valuations shouldn't allow more borrowing
// than what a trusted reference oracle would permit
function invariant_twapBoundedByReference() public returns (bool) {
uint256 twapPrice = oracle.getTWAP(token, WINDOW);
uint256 chainlinkPrice = chainlinkFeed.latestAnswer();
// TWAP should stay within 10% of Chainlink over any window
uint256 maxDeviation = chainlinkPrice / 10;
uint256 diff = twapPrice > chainlinkPrice
? twapPrice - chainlinkPrice
: chainlinkPrice - twapPrice;
return diff <= maxDeviation;
}
// Property: TWAP shouldn't change more than X% per block
function invariant_twapChangeRate() public returns (bool) {
uint256 currentTwap = oracle.getTWAP(token, WINDOW);
uint256 previousTwap = lastRecordedTwap;
if (previousTwap == 0) {
lastRecordedTwap = currentTwap;
return true;
}
uint256 maxChangePerBlock = previousTwap / 50; // 2% max per block
uint256 change = currentTwap > previousTwap
? currentTwap - previousTwap
: previousTwap - currentTwap;
lastRecordedTwap = currentTwap;
return change <= maxChangePerBlock;
}
Attack vector 3: oracle sandwich attacks
This is a DeFi-specific variant. The attacker spots a pending oracle update transaction in the mempool, sandwiches it with their own transactions.
The flow
1. Attacker sees Chainlink price update TX in mempool
(e.g., ETH price going from $3000 → $3100)
2. Front-run: Deposit collateral, borrow at old price ($3000)
3. Oracle update executes: price moves to $3100
4. Back-run: Collateral now worth more at $3100,
borrow additional funds or close position at profit
This works because on-chain oracles have discrete update points. Between updates, there's a known stale price. If the protocol doesn't account for this gap, traders can extract value at every update.
Properties for oracle freshness
// Property: protocol should reject actions when oracle data is stale
function invariant_oracleNotStale() public returns (bool) {
(, , , uint256 updatedAt, ) = priceFeed.latestRoundData();
uint256 staleness = block.timestamp - updatedAt;
// If oracle is stale, no borrows should succeed
if (staleness > MAX_STALENESS) {
// Check that protocol correctly paused
return lender.isPaused();
}
return true;
}
// Property: position value change from oracle update
// should not create instant arbitrage
function invariant_noOracleUpdateArbitrage() public returns (bool) {
for (uint256 i = 0; i < actors.length; i++) {
uint256 deposited = lender.totalDeposited(actors[i]);
uint256 withdrawn = lender.totalWithdrawn(actors[i]);
uint256 borrowed = lender.totalBorrowed(actors[i]);
uint256 repaid = lender.totalRepaid(actors[i]);
// Net extraction should never exceed reasonable bounds
int256 netPnl = int256(withdrawn + borrowed)
- int256(deposited + repaid);
if (netPnl > int256(deposited / 10)) return false; // 10% max
}
return true;
}
Attack vector 4: multi-oracle discrepancy
Protocols that use multiple price sources (say, Chainlink for ETH/USD and a Uniswap TWAP for TOKEN/ETH) can get hit when these oracles disagree. The attacker manipulates the one that's cheaper to move while the other stays stable.
Testing multi-oracle systems
// Property: when protocol uses multiple oracles,
// derived prices must be consistent
function invariant_oracleConsistency() public returns (bool) {
uint256 priceFromChainlink = chainlinkAdapter.getPrice(token);
uint256 priceFromTWAP = twapAdapter.getPrice(token);
// If oracles diverge beyond threshold, protocol should use
// the more conservative (lower for collateral, higher for debt)
uint256 protocolPrice = protocol.getEffectivePrice(token);
if (protocolPrice > priceFromChainlink &&
protocolPrice > priceFromTWAP) {
// Protocol is using a price higher than BOTH oracles
// This is exploitable
return false;
}
return true;
}
Chainlink vs TWAP vs hybrid: what works
Let me be direct about the tradeoffs.
Chainlink feeds:
- Resistant to flash loan manipulation (off-chain aggregation)
- Can go stale during congestion or extreme volatility
- Limited token coverage (not every pair has a feed)
- Heartbeat and deviation thresholds mean price updates aren't continuous
Uniswap V3 TWAPs:
- Available for any pool that exists
- Resistant to single-block manipulation
- Vulnerable to multi-block attacks on low-liquidity pools
- Free to read (built into the pool)
Hybrid approach (what works best):
- Use Chainlink as primary, TWAP as fallback
- Require both to agree within a tolerance band
- If they disagree, use the more conservative price
- Add circuit breakers for extreme deviations
contract HybridOracle {
uint256 constant MAX_DEVIATION = 500; // 5%
function getPrice(address token) external view returns (uint256) {
uint256 clPrice = getChainlinkPrice(token);
uint256 twapPrice = getTWAPPrice(token);
uint256 diff = clPrice > twapPrice
? clPrice - twapPrice
: twapPrice - clPrice;
uint256 deviation = (diff * 10000) / clPrice;
if (deviation > MAX_DEVIATION) {
// Circuit breaker -- use lower price for safety
return clPrice < twapPrice ? clPrice : twapPrice;
}
// Normal case -- use Chainlink as primary
return clPrice;
}
}
Fuzzing your oracle defenses
Here's how to structure a fuzzing campaign specifically for price manipulation resistance:
-
Create an attacker actor that can flash loan, swap into pools, and interact with your protocol, all in one transaction.
-
Give the fuzzer control of oracle prices. Mock your oracle and let the fuzzer set arbitrary prices.
-
Write properties about outcomes, not about prices themselves. "No user should extract more value than they deposited" is better than "price should be correct."
-
Include multi-block sequences. Use stateful fuzzing to test TWAP manipulation across multiple blocks.
// Fuzzing harness for oracle manipulation testing
contract OracleFuzzHarness is Test {
MockChainlinkFeed mockFeed;
// Let fuzzer set any price
function setOraclePrice(uint256 price) external {
price = bound(price, 1, type(uint128).max);
mockFeed.setPrice(int256(price));
}
// Let fuzzer advance time (simulates stale oracle)
function advanceTime(uint256 seconds_) external {
seconds_ = bound(seconds_, 1, 7 days);
vm.warp(block.timestamp + seconds_);
}
// Core invariant: system solvency
function invariant_systemSolvent() public {
uint256 totalDeposits = lender.totalDeposits();
uint256 totalBorrows = lender.totalBorrows();
uint256 actualAssets = token.balanceOf(address(lender));
assertGe(
actualAssets + totalBorrows,
totalDeposits,
"System is insolvent"
);
}
}
What I've seen teams get wrong
After reviewing dozens of oracle implementations, here are the patterns that keep failing:
-
Using spot prices anywhere. It doesn't matter if it's "just for the UI." If it touches a state-changing function, it's exploitable. Check every
getReserves()call. -
Short TWAP windows on thin pools. A 5-minute TWAP on a pool with $500K liquidity? That's basically a spot price with extra steps.
-
No staleness checks. Chainlink feeds can go hours without updates. If your protocol doesn't check
updatedAt, you're using yesterday's price in a crash. -
Missing circuit breakers. Price drops 90% in one block? That's probably manipulation, not a real market event. Your protocol should pause, not liquidate everything.
-
Trusting a single source. One oracle, one point of failure. Always cross-reference.
The bottom line
Price manipulation isn't going away. Flash loans make it free to attempt, and every new DeFi primitive creates new oracle dependencies to attack. The only reliable defense is testing your assumptions. Write properties about what your protocol should guarantee regardless of what the oracle says, and fuzz those properties until they either hold or break.
Want to test your oracle defenses against manipulation? Try Recon Pro to run stateful fuzzing campaigns with attacker actors. Or request an audit to get expert-written oracle resistance properties for your specific protocol.
Further reading
Related Posts
Oracle integration testing: fuzzing price feeds and manipulation resistance
Your protocol trusts an oracle. But does it handle stale prices, sudden deviations, and manipulation...
Postmortem: The Lending Protocol Reentrancy That Fuzzing Missed — And Invariants Didn't
The dev team ran Echidna for 24 hours: zero findings. The same vulnerability was found by invariant ...