← Back to Blog

AMM and DEX Security: Price Manipulation, Sandwich Attacks, and Liquidity Vulnerabilities

2026-04-18 amm uniswap dex solidity security price manipulation sandwich attack 2026

In April 2023, Eminence Finance lost $15 million in a single block. The attacker flash-loaned a large sum, moved the spot price of EMN tokens through a Uniswap-style pool, used that manipulated price as the protocol oracle, minted EMN at artificially low cost, then sold into the real market. The flash loan repaid itself. The protocol was empty.

AMMs are the plumbing of DeFi. UniswapV2 popularized the constant-product formula (x * y = k) where two token reserves determine price without an order book. UniswapV3 added concentrated liquidity positions and tick-based pricing. Both architectures are battle-tested — but the contracts that integrate with them often are not. The exploits today rarely attack Uniswap itself; they attack the protocols that trust Uniswap's output.

This guide covers six vulnerability classes every DEX integration must address, with code examples for each.


Vulnerability 1: Spot Price Oracle Manipulation

The bug

UniswapV2 exposes pool reserves directly via getReserves(). The ratio reserve1 / reserve0 gives the current spot price. Many integrations — especially early DeFi forks — use this ratio as a price oracle:

// VULNERABLE: spot price used as oracle
function getTokenPrice(address pair) public view returns (uint256) {
    (uint112 reserve0, uint112 reserve1,) = IUniswapV2Pair(pair).getReserves();
    // Price derived purely from current reserves — manipulable in one block
    return (uint256(reserve1) * 1e18) / uint256(reserve0);
}

A flash loan collapses this in one transaction:

  1. Borrow 50,000 ETH via Aave flash loan (zero collateral required)
  2. Swap ETH into the target pool, pushing reserve1 up and reserve0 down
  3. Target protocol reads the now-distorted reserve1 / reserve0 as the price
  4. Attacker borrows, mints, or liquidates against the fake price
  5. Swap back, repay the flash loan, keep the profit

The entire sequence is atomic. The reserves return to near-normal after step 5. No on-chain evidence remains except the block history.

The fix: TWAP

A Time-Weighted Average Price averages the price over many blocks. Moving it meaningfully requires sustained capital across multiple blocks, making flash loan manipulation economically impossible.

UniswapV2 accumulates price0CumulativeLast and price1CumulativeLast in every block a swap touches the pool. The canonical UniswapV2OracleSimple reads two snapshots separated by a time window:

// From Uniswap's oracle example — secure TWAP implementation
contract UniswapV2OracleSimple {
    using FixedPoint for *;

    uint public constant PERIOD = 24 hours;

    IUniswapV2Pair immutable pair;
    address public immutable token0;
    address public immutable token1;

    uint    public price0CumulativeLast;
    uint    public price1CumulativeLast;
    uint32  public blockTimestampLast;

    FixedPoint.uq112x112 public price0Average;
    FixedPoint.uq112x112 public price1Average;

    function update() external {
        (uint price0Cumulative, uint price1Cumulative, uint32 blockTimestamp) =
            UniswapV2OracleLibrary.currentCumulativePrices(address(pair));

        uint32 timeElapsed = blockTimestamp - blockTimestampLast;
        require(timeElapsed >= PERIOD, "UniswapOracle: PERIOD_NOT_ELAPSED");

        price0Average = FixedPoint.uq112x112(
            uint224((price0Cumulative - price0CumulativeLast) / timeElapsed)
        );
        price1Average = FixedPoint.uq112x112(
            uint224((price1Cumulative - price1CumulativeLast) / timeElapsed)
        );

        price0CumulativeLast = price0Cumulative;
        price1CumulativeLast = price1Cumulative;
        blockTimestampLast = blockTimestamp;
    }

    // SECURE: returns 24h TWAP, not manipulable via flash loan
    function consult(address token, uint amountIn) external view returns (uint amountOut) {
        if (token == token0) {
            amountOut = price0Average.mul(amountIn).decode144();
        } else {
            require(token == token1, "UniswapOracle: INVALID_TOKEN");
            amountOut = price1Average.mul(amountIn).decode144();
        }
    }
}

Rule: Never call getReserves() for a price you trust. Call consult() on a deployed TWAP oracle with a window of at least one hour — preferably 24 hours for low-liquidity pairs.


Vulnerability 2: Sandwich Attack

The bug

Every swap in a public mempool is visible before it executes. A sandwich attack is a two-transaction exploit that wraps a victim's trade:

  1. Attacker sees victim's pending swapExactTokensForTokens call
  2. Attacker front-runs: buys the output token before the victim, pushing the price up
  3. Victim's transaction executes — now at a worse price due to attacker's buy
  4. Attacker back-runs: sells the token immediately after, profiting from the spread

The mechanism hinges on one contract parameter: amountOutMin. If it is set too loosely (or, worst case, to zero), the victim accepts any output price and the attacker can extract unlimited value.

// VULNERABLE: zero slippage protection
router.swapExactTokensForTokens(
    amountIn,
    0,                  // amountOutMin = 0 — accepts any price, no protection
    path,
    recipient,
    block.timestamp
);

The fix: enforce slippage tolerance

Calculate a reasonable minimum output off-chain and pass it in. The standard approach is a 0.5%–1% slippage tolerance:

// SECURE: calculate amountOutMin off-chain and enforce it on-chain
function swapWithSlippageProtection(
    address router,
    uint256 amountIn,
    address[] calldata path,
    uint256 slippageBps  // e.g. 50 = 0.5%
) external returns (uint256[] memory amounts) {
    // Get expected output from router (informational only — do not use as oracle)
    uint256[] memory expectedAmounts = IUniswapV2Router02(router).getAmountsOut(amountIn, path);
    uint256 expectedOut = expectedAmounts[expectedAmounts.length - 1];

    // Apply slippage tolerance
    uint256 amountOutMin = expectedOut * (10000 - slippageBps) / 10000;

    IERC20(path[0]).transferFrom(msg.sender, address(this), amountIn);
    IERC20(path[0]).approve(router, amountIn);

    amounts = IUniswapV2Router02(router).swapExactTokensForTokens(
        amountIn,
        amountOutMin,   // revert if output falls below this
        path,
        msg.sender,
        block.timestamp + 300
    );
}

Note that getAmountsOut() is used here only to estimate the expected output for slippage math — not as a trusted oracle for protocol logic. The distinction matters and is covered in Vulnerability 6.


Vulnerability 3: Reentrancy via Token Callbacks

The bug

Uniswap V1 had a critical reentrancy vulnerability in its tokenToEthSwapInput function. The function sent ETH to the caller before updating internal state (reserves), allowing a malicious contract to re-enter and drain the pool repeatedly. This exact pattern — send value, then update state — is the classic reentrancy precondition.

ERC-777 tokens reintroduce this vector into Uniswap V2-style pools. ERC-777 tokens call tokensToSend and tokensReceived hooks on the sender and receiver during transfers. If either the pool's input token or output token is ERC-777, the swap() function's external call fires before state is settled:

// Simplified Uniswap V2 swap() — illustrating the callback timing
function swap(uint amount0Out, uint amount1Out, address to, bytes calldata data) external {
    // Optimistic transfer — sends tokens BEFORE updating reserves
    if (amount0Out > 0) _safeTransfer(token0, to, amount0Out);
    if (amount1Out > 0) _safeTransfer(token1, to, amount1Out);

    // ERC-777 tokensReceived() fires here — pool reserves still show old values
    if (data.length > 0) IUniswapV2Callee(to).uniswapV2Call(msg.sender, amount0Out, amount1Out, data);

    // Reserves updated only after the external call completes
    uint balance0 = IERC20(token0).balanceOf(address(this));
    uint balance1 = IERC20(token1).balanceOf(address(this));
    // ...
    _update(balance0, balance1, _reserve0, _reserve1);  // too late if reentered
}

A malicious tokensReceived hook can re-enter swap() while the old reserves are still in storage, allowing a second swap at a price that has not yet reflected the first.

The fix: the lock modifier

Uniswap V2 addresses this with a reentrancy guard on every external function:

// Uniswap V2 reentrancy guard
uint private unlocked = 1;
modifier lock() {
    require(unlocked == 1, "UniswapV2: LOCKED");
    unlocked = 0;
    _;
    unlocked = 1;
}

// Applied to swap, mint, burn, and skim
function swap(...) external lock { ... }
function mint(address to) external lock returns (uint liquidity) { ... }
function burn(address to) external lock returns (uint amount0, uint amount1) { ... }

If you fork or build a Uniswap-style pool: apply the lock modifier to every function that transfers tokens or updates reserves. Treat ERC-777 tokens as a red flag — audit every external call path they introduce.


Vulnerability 4: K-Value Manipulation

The bug

The invariant k = reserve0 * reserve1 must hold (or increase with fees) after every swap. Manipulation of k can distort prices and break protocol assumptions built around it.

One pattern: a liquidity provider adds a large amount of liquidity, moves the pool price toward a target, then immediately removes liquidity in the same block. The k value increases on add and decreases on remove, but integrating protocols reading getReserves() between those two operations see a manipulated state:

// VULNERABLE integration: reads reserves mid-block without protection
function getPriceFromReserves(address pair) external view returns (uint256) {
    (uint112 r0, uint112 r1,) = IUniswapV2Pair(pair).getReserves();
    // If called between addLiquidity and removeLiquidity in the same block,
    // this reflects the attacker's chosen reserve ratio
    return (uint256(r1) * 1e18) / uint256(r0);
}

Protocol-level mitigation belongs in the pool contract: Uniswap V2 checks that k does not decrease in swap(). But integrations that rely on reserves as a price source remain exposed at the integration level.

// SECURE integration: use TWAP — immune to same-block reserve manipulation
function getPriceSecure(address oracle, address token, uint256 amountIn) 
    external view returns (uint256) 
{
    // consult() returns time-averaged price — same-block add/remove has no effect
    return IUniswapV2Oracle(oracle).consult(token, amountIn);
}

Additionally, any custom pool fork must verify the invariant explicitly:

// K-invariant check in a custom swap implementation
uint256 kBefore = uint256(reserve0) * uint256(reserve1);
// ... execute swap logic ...
uint256 kAfter = uint256(balance0Adjusted) * uint256(balance1Adjusted);
require(kAfter >= kBefore, "K_INVARIANT_VIOLATED");

Vulnerability 5: Deadline Not Set

The bug

Every Uniswap router swap accepts a deadline parameter. Transactions that sit in the mempool — waiting due to low gas price or network congestion — can be executed much later when market conditions have changed. Setting the deadline to type(uint256).max effectively removes this protection:

// VULNERABLE: transaction can be held indefinitely and executed at any time
router.swapExactTokensForTokens(
    amountIn,
    amountOutMin,
    path,
    recipient,
    type(uint256).max  // no expiry — miners or searchers can hold this tx
);

A searcher or MEV bot that holds the transaction can execute it whenever the market moves in a direction that maximizes its value (via sandwich or simply when prices are unfavorable for the original sender). The sender may receive the minimum acceptable output but at a time they did not intend.

The fix: use a short deadline

// SECURE: deadline expires 5 minutes from submission
router.swapExactTokensForTokens(
    amountIn,
    amountOutMin,
    path,
    recipient,
    block.timestamp + 300  // 5-minute window
);

For contract-initiated swaps, compute the deadline off-chain and pass it in, or use a short block-relative offset. Never hardcode type(uint256).max or block.timestamp + 365 days. For time-sensitive operations like rebalancing or arbitrage execution, a 1–3 minute deadline is appropriate.


Vulnerability 6: Using getAmountsOut() as a Price Oracle

The bug

getAmountsOut() simulates how much output a given input would produce at current reserves. It reads spot reserves — which makes it just as manipulable as reading reserve0 / reserve1 directly. Many integrations — aggregators, yield optimizers, and custom routers — use it to make protocol-level pricing decisions:

// VULNERABLE: using getAmountsOut() as a trusted oracle for protocol logic
function shouldRebalance(address tokenA, address tokenB) external view returns (bool) {
    address[] memory path = new address[](2);
    path[0] = tokenA;
    path[1] = tokenB;

    // This is a spot price read — manipulable via flash loan in the same block
    uint256[] memory amounts = IUniswapV2Router02(router).getAmountsOut(1e18, path);
    uint256 currentPrice = amounts[1];

    return currentPrice > targetPrice;
}

An attacker flash-loans into the pool before this call fires, manipulates currentPrice, and triggers a rebalance at the wrong time.

The fix: consult a TWAP oracle

// SECURE: protocol decisions use TWAP — not spot price
contract SecureRebalancer {
    IUniswapV2Oracle public immutable oracle;

    constructor(address _oracle) {
        oracle = IUniswapV2Oracle(_oracle);
    }

    function shouldRebalance(address tokenA) external view returns (bool) {
        // consult() returns the 24h TWAP — not the current spot price
        uint256 twapPrice = oracle.consult(tokenA, 1e18);
        return twapPrice > targetPrice;
    }
}

The distinction between getAmountsOut() and consult() is the most common mistake in DEX integrations. getAmountsOut() is for user-facing quote UIs. consult() on a properly deployed TWAP oracle is for on-chain protocol logic.


What ContractScan Detects

Vulnerability Detection Method
Spot price from getReserves() used as oracle Static analysis: flags reserve0/reserve1 ratio in pricing functions
amountOutMin = 0 in swap calls AST pattern match on router call arguments
Missing reentrancy guard on swap/mint/burn Control flow analysis: identifies external calls before state updates
K-invariant not checked in custom pools Data flow analysis: verifies post-swap invariant assertion
deadline = type(uint256).max or far-future constant Literal value detection in router call deadline arguments
getAmountsOut() used in protocol decision logic Call graph analysis: traces output of getAmountsOut into conditional branches
ERC-777 token in pool without reentrancy guard Token interface detection combined with reentrancy path analysis

Secure AMM Integration Checklist


Audit Your DEX Integration

The vulnerabilities above are routinely exploitable and frequently missed in manual reviews because they span the boundary between your contract and the AMM you integrate with. Static analysis catches the deterministic patterns — zero slippage, missing locks, max deadlines. Economic attack vectors require simulation.

Audit your DEX integration at https://contract-scanner.raccoonworld.xyz



This post is for educational purposes. It does not constitute financial, legal, or investment advice. Always conduct a professional audit before deploying smart contracts to production.

Important Notes

This post is for informational and educational purposes only. It does not constitute financial, legal, or investment advice. The security analysis provided is based on available data and automated tools, which may not capture all potential vulnerabilities. Always conduct a professional audit before deploying smart contracts.

Scan your contract for this vulnerability
Free QuickScan — Unlimited quick scans. No signup required.. No signup required.
Scan a Contract →