2026-03-26·14 min read

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:

  1. Create an attacker actor that can flash loan, swap into pools, and interact with your protocol, all in one transaction.

  2. Give the fuzzer control of oracle prices. Mock your oracle and let the fuzzer set arbitrary prices.

  3. Write properties about outcomes, not about prices themselves. "No user should extract more value than they deposited" is better than "price should be correct."

  4. 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:

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

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

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

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

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

Related Glossary Terms

Fuzz your price manipulation defenses