Price feeds are the most critical security surface in DeFi. Not private keys. Not access control. Prices. When a protocol mints tokens, extends loans, or triggers liquidations, it needs to know what assets are worth — and an adversary who controls that answer controls the protocol's treasury.
Mango Markets lost $117 million in October 2022 to a single oracle manipulation. Cream Finance lost $130 million the same month through a flash loan that distorted a Uniswap V2 spot price. BonqDAO lost $120 million in February 2023 because one low-liquidity pool determined collateral value. Across 2023 and 2024, price oracle attacks accounted for more than 60% of DeFi exploit volume by dollar value.
This post covers six concrete vulnerability classes, each with vulnerable Solidity code, a realistic attack scenario, and a secure fix.
1. Spot Price from Low-Liquidity AMM Pool
The Vulnerability
The simplest oracle pattern is also the most dangerous: read getReserves() from a Uniswap V2 pair and compute reserve1 / reserve0.
// VULNERABLE: uses spot price from an arbitrary pool as collateral oracle
function getCollateralPrice(address pair) public view returns (uint256 price) {
(uint112 reserve0, uint112 reserve1,) = IUniswapV2Pair(pair).getReserves();
// Instantaneous reserve ratio — fully manipulable in one transaction
price = (uint256(reserve1) * 1e18) / uint256(reserve0);
}
function borrow(address collateralToken, uint256 collateralAmount, uint256 borrowAmount) external {
uint256 price = getCollateralPrice(tokenPair[collateralToken]);
uint256 collateralValue = (collateralAmount * price) / 1e18;
require(collateralValue >= borrowAmount * 150 / 100, "Undercollateralized");
// ... transfer borrowAmount to msg.sender
}
If the pool holds only $20,000 in liquidity, a $10,000 swap moves the price roughly 50% due to the constant-product formula (x * y = k). The attacker posts collateral worth $10,000 at real prices and borrows $100,000 against the manipulated valuation — all in a single atomic flash loan transaction.
The Fix
Use Uniswap V3's on-chain TWAP, a Chainlink feed, or enforce a minimum liquidity depth before trusting any price:
// SECURE: Uniswap V3 TWAP with minimum liquidity guard
import "@uniswap/v3-core/contracts/interfaces/IUniswapV3Pool.sol";
import "@uniswap/v3-core/contracts/libraries/OracleLibrary.sol";
uint32 constant TWAP_PERIOD = 1800; // 30 minutes
uint256 constant MIN_POOL_LIQUIDITY = 500_000e6; // $500k minimum in base token
function getCollateralPrice(address pool) public view returns (uint256) {
// Reject thin pools before even reading the price
uint128 liquidity = IUniswapV3Pool(pool).liquidity();
require(liquidity >= MIN_POOL_LIQUIDITY, "Pool too illiquid");
(int24 arithmeticMeanTick,) = OracleLibrary.consult(pool, TWAP_PERIOD);
uint256 quoteAmount = OracleLibrary.getQuoteAtTick(
arithmeticMeanTick,
1e18,
IUniswapV3Pool(pool).token0(),
IUniswapV3Pool(pool).token1()
);
return quoteAmount;
}
Chainlink is even simpler and does not require any AMM liquidity at all:
// SECURE: Chainlink with staleness check
AggregatorV3Interface internal priceFeed;
function getCollateralPrice() public view returns (uint256) {
(, int256 price,, uint256 updatedAt,) = priceFeed.latestRoundData();
require(block.timestamp - updatedAt <= 3600, "Stale price");
require(price > 0, "Invalid price");
return uint256(price);
}
2. TWAP Bypass via Sustained Price Manipulation
The Vulnerability
A Time-Weighted Average Price is not a silver bullet. TWAP only protects when it is economically infeasible for an attacker to sustain a distorted price for the full observation window.
If a TWAP window is 5 minutes and an attacker can rent $2 million via flash loan rollovers across multiple blocks, they can hold the price elevated for the entire window at acceptable cost. Protocols using short TWAP windows against illiquid pools are still exploitable.
// VULNERABLE: 5-minute TWAP on a pool with $100k liquidity
uint32 constant TWAP_PERIOD = 300; // 5 minutes — far too short
function getTWAPPrice(address pool) public view returns (uint256) {
(int24 arithmeticMeanTick,) = OracleLibrary.consult(pool, TWAP_PERIOD);
return OracleLibrary.getQuoteAtTick(
arithmeticMeanTick,
1e18,
IUniswapV3Pool(pool).token0(),
IUniswapV3Pool(pool).token1()
);
}
With a $100k pool and a 5-minute TWAP, sustaining a 2x price manipulation costs only the price-impact slippage on entry and exit — often under $3,000 — against a protocol holding $500,000 in borrowable assets.
The Fix
Use a TWAP window of at least 30 minutes (60 for high-value collateral), and combine with a circuit breaker that rejects prices deviating beyond a threshold from a secondary source:
// SECURE: 30-minute TWAP with secondary oracle cross-check
uint32 constant TWAP_PERIOD = 1800; // 30 minutes
uint256 constant MAX_DEVIATION_BPS = 500; // 5% max deviation from Chainlink
function getVerifiedPrice(address pool) public view returns (uint256 twapPrice) {
(int24 meanTick,) = OracleLibrary.consult(pool, TWAP_PERIOD);
twapPrice = OracleLibrary.getQuoteAtTick(meanTick, 1e18, token0, token1);
// Cross-check against Chainlink
(, int256 clPrice,, uint256 updatedAt,) = chainlinkFeed.latestRoundData();
require(block.timestamp - updatedAt <= 3600, "Chainlink stale");
uint256 chainlinkPrice = uint256(clPrice) * 1e10; // normalize decimals
uint256 deviation = twapPrice > chainlinkPrice
? ((twapPrice - chainlinkPrice) * 10000) / chainlinkPrice
: ((chainlinkPrice - twapPrice) * 10000) / twapPrice;
require(deviation <= MAX_DEVIATION_BPS, "Price deviation too high");
}
3. NFT Floor Price Oracle Manipulation
The Vulnerability
NFT-collateralized lending protocols accept NFTs as loan collateral priced at the collection's floor. Many read floor price from an aggregator tracking the cheapest listing or the most recent sale.
Two distinct attacks exist. In the listing attack, an attacker engineers a self-dealing "sale" between two controlled wallets at an inflated price; the aggregator records it as the new floor. In the buy-the-floor attack, an attacker purchases cheap NFTs to remove genuine low-price listings, temporarily raising the apparent floor long enough to borrow against inflated collateral.
// VULNERABLE: uses aggregator floor price with no volume or history checks
function getNFTCollateralValue(address collection, uint256 tokenId)
public view returns (uint256)
{
uint256 floorPrice = nftFloorOracle.getFloor(collection); // single data point
return floorPrice; // no volume check, no TWAP, no minimum sale count
}
A $50,000 buy-the-floor operation on a collection with thin listings could temporarily double the apparent floor, enabling a $200,000+ loan against real collateral worth $80,000.
The Fix
Require a TWAP of verified sales over a meaningful window and enforce a minimum traded volume threshold:
// SECURE: TWAP-based NFT floor with volume and freshness requirements
uint256 constant MIN_SALES_IN_WINDOW = 5;
uint256 constant FLOOR_TWAP_WINDOW = 86400; // 24-hour sales TWAP
uint256 constant MAX_FLOOR_STALENESS = 3600;
function getNFTCollateralValue(address collection) public view returns (uint256) {
(
uint256 twapFloor,
uint256 salesCount,
uint256 lastSaleTimestamp
) = nftOracle.getFloorTWAP(collection, FLOOR_TWAP_WINDOW);
require(salesCount >= MIN_SALES_IN_WINDOW, "Insufficient sales volume");
require(block.timestamp - lastSaleTimestamp <= MAX_FLOOR_STALENESS, "No recent sales");
// Apply a conservative LTV discount on top of the floor
return (twapFloor * 50) / 100; // 50% LTV cap for NFT collateral
}
4. Sandwich Attack on Protocol Swap
The Vulnerability
Protocols often perform internal swaps during core operations — minting, rebalancing, or liquidating. If those swaps do not specify a minimum output amount and a deadline, MEV bots sandwich them for guaranteed profit at the protocol's expense.
// VULNERABLE: no slippage protection on an internal swap
function liquidate(address borrower) external {
uint256 collateral = positions[borrower].collateral;
// Swap seized collateral to repayment token — no minAmountOut, no deadline
uint256 repaid = ISwapRouter(router).exactInputSingle(
ISwapRouter.ExactInputSingleParams({
tokenIn: collateralToken,
tokenOut: debtToken,
fee: 3000,
recipient: address(this),
deadline: block.timestamp + 3600, // deadline set but...
amountIn: collateral,
amountOutMinimum: 0, // VULNERABLE: accepts any output amount
sqrtPriceLimitX96: 0
})
);
}
An MEV bot sees the liquidation in the mempool, front-runs by swapping debtToken for collateralToken (moving the price against the protocol), lets the protocol swap at the worse rate, then back-runs by selling at the recovered price. The bot captures the slippage; the protocol receives fewer tokens than fair value.
The Fix
Calculate an off-chain or on-chain TWAP-derived minimum output and pass it as amountOutMinimum. Use a tight deadline:
// SECURE: slippage-protected internal swap
uint256 constant MAX_SLIPPAGE_BPS = 50; // 0.5% maximum slippage
function liquidate(address borrower) external {
uint256 collateral = positions[borrower].collateral;
// Derive expected output from TWAP oracle
uint256 expectedOut = getTWAPQuote(collateralToken, debtToken, collateral);
uint256 minOut = (expectedOut * (10000 - MAX_SLIPPAGE_BPS)) / 10000;
uint256 repaid = ISwapRouter(router).exactInputSingle(
ISwapRouter.ExactInputSingleParams({
tokenIn: collateralToken,
tokenOut: debtToken,
fee: 3000,
recipient: address(this),
deadline: block.timestamp + 60, // tight, 60-second window
amountIn: collateral,
amountOutMinimum: minOut, // SECURE: rejects manipulated prices
sqrtPriceLimitX96: 0
})
);
}
5. Price Manipulation via Donation Attack
The Vulnerability
Uniswap V2-style pools compute price from reserve0 and reserve1 stored in the pair contract. These reserve values are updated by sync() or automatically at the end of each swap. However, nothing prevents anyone from transferring tokens directly to the pair address outside of the normal swap flow.
A donation attack exploits protocols that read price from reserve0 / reserve1 without calling sync() first, trusting reserves that are out of sync with actual balances.
// VULNERABLE: reads reserve ratio directly, trusting it reflects real balances
function getMintAmount(address pair, uint256 ethIn) public view returns (uint256 tokensOut) {
(uint112 reserve0, uint112 reserve1,) = IUniswapV2Pair(pair).getReserves();
// Ratio can be skewed by a direct token donation to the pair contract
tokensOut = (ethIn * uint256(reserve1)) / uint256(reserve0);
}
The attacker donates a large amount of token1 directly to the pair address (not via swap()). The pool's actual balance is now higher than reserve1. A protocol reading the old reserve gets a stale price; one on the other side of the skew reads an inflated price before sync() corrects it.
This is particularly effective in Uniswap V2 forks that add custom logic between balance checks and reserve reads, or that delay calling sync().
The Fix
Compare actual balances against stored reserves and reject or reconcile discrepancies:
// SECURE: detect donation-skewed reserves before trusting price
function getMintAmount(address pair, uint256 ethIn) public view returns (uint256 tokensOut) {
(uint112 reserve0, uint112 reserve1,) = IUniswapV2Pair(pair).getReserves();
// Actual balances — if these diverge significantly, the pool was donated to
uint256 actualBalance0 = IERC20(IUniswapV2Pair(pair).token0()).balanceOf(pair);
uint256 actualBalance1 = IERC20(IUniswapV2Pair(pair).token1()).balanceOf(pair);
// Reject if actual balances are more than 1% above reserves (donation signal)
require(actualBalance0 <= uint256(reserve0) * 101 / 100, "Reserve0 skewed");
require(actualBalance1 <= uint256(reserve1) * 101 / 100, "Reserve1 skewed");
tokensOut = (ethIn * uint256(reserve1)) / uint256(reserve0);
}
For write-path functions, calling IUniswapV2Pair(pair).sync() before reading reserves forces them to match current balances.
6. Stale Cached Price with No Heartbeat Check
The Vulnerability
Chainlink feeds update on a heartbeat interval and a deviation threshold. During extreme market events, feeds can be paused or lag their heartbeat. A protocol that caches Chainlink's last answer without verifying freshness will use a stale price indefinitely.
// VULNERABLE: caches Chainlink price with no freshness check
contract LendingPool {
uint256 public cachedPrice;
uint256 public cachedAt;
function refreshPrice() external {
(, int256 price,,,) = priceFeed.latestRoundData();
cachedPrice = uint256(price);
cachedAt = block.timestamp; // stored, but never checked on use
}
function getPrice() public view returns (uint256) {
return cachedPrice; // used with no check on cachedAt age
}
function borrow(uint256 amount) external {
uint256 price = getPrice(); // could be hours or days old
// ... collateral valuation uses stale price
}
}
If refreshPrice() is not called for 6 hours during a volatile market, a collateral asset could drop 40% while the protocol still values it at the stale price — enabling undercollateralized borrowing or blocking liquidations when most needed.
The Fix
Check updatedAt on every price consumption, not just on refresh. Add a circuit breaker for prolonged staleness:
// SECURE: staleness check on every price read + circuit breaker
contract LendingPool {
uint256 public constant MAX_STALENESS = 3600; // 1 hour
uint256 public constant CIRCUIT_BREAKER_STALENESS = 7200; // 2 hours
AggregatorV3Interface public immutable priceFeed;
bool public circuitBreakerActive;
function getPrice() public returns (uint256) {
(
uint80 roundId,
int256 price,
,
uint256 updatedAt,
uint80 answeredInRound
) = priceFeed.latestRoundData();
require(price > 0, "Invalid price");
require(answeredInRound >= roundId, "Incomplete round");
uint256 age = block.timestamp - updatedAt;
if (age > CIRCUIT_BREAKER_STALENESS) {
circuitBreakerActive = true;
revert("Price feed halted: circuit breaker active");
}
require(age <= MAX_STALENESS, "Price feed stale");
circuitBreakerActive = false;
return uint256(price);
}
}
The answeredInRound >= roundId check catches Chainlink rounds that were started but never completed.
What ContractScan Detects
ContractScan performs static analysis and semantic reasoning over Solidity source code to identify each of these vulnerability classes before deployment.
| Vulnerability | Detection Method | Severity |
|---|---|---|
| Spot price from low-liquidity pool | Traces getReserves() / getAmountsOut() calls used as price oracles without TWAP wrapping |
Critical |
| Short TWAP window bypass | Flags TWAP observation periods below 1800 seconds in lending/collateral contexts | High |
| NFT floor price without volume guard | Detects NFT oracle reads lacking minimum sales count or TWAP aggregation | High |
| Sandwich attack on unprotected swap | Identifies amountOutMinimum: 0 or missing slippage parameter in internal swaps |
High |
| Donation attack via reserve skew | Flags direct balance reads from pair contracts without reserve/balance divergence check | Medium |
| Stale Chainlink price (no heartbeat) | Detects latestRoundData() usage without updatedAt staleness assertion |
Critical |
ContractScan also traces data flow from oracle reads through to protocol state mutations, identifying manipulable price influence across function calls and contract boundaries.
Scan your contracts at contractscan.io before deployment.
Related Posts
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.