Flash loans changed DeFi forever. In 2020, bZx lost $350,000 in minutes when an attacker used flash-borrowed funds to manipulate oracle prices. Since then, flash loan attacks have extracted billions from protocols. Understanding these attack patterns is no longer optional—it’s survival.
What Makes Flash Loans Dangerous
Flash loans let anyone borrow unlimited capital with zero collateral, provided the loan is repaid within a single transaction. This sounds like magic, and for attackers, it is.
Before flash loans, manipulating a protocol required significant capital. You needed millions to move markets, manipulate oracles, or exploit price discrepancies. Flash loans democratized attacks—now anyone with a clever idea can borrow $100 million for a few seconds.
The key insight: any logic that can be exploited within a single transaction is vulnerable to flash loan attacks. This includes:
- Price oracle manipulation
- Governance vote manipulation
- Liquidity-based calculations
- Share price manipulation in vaults
Pattern 1: Oracle Manipulation
The most common flash loan attack. The attacker manipulates a price oracle, then exploits a protocol that relies on that manipulated price.
The Attack Flow
- Flash loan a large amount of Token A
- Swap Token A for Token B on a DEX (crashing A’s spot price)
- Target protocol reads the manipulated spot price
- Exploit the protocol based on wrong price (borrow more, liquidate unfairly, etc.)
- Reverse the swap
- Repay flash loan with profit
Real Example: Harvest Finance ($34M)
The attacker repeatedly:
- Flash loaned USDC/USDT
- Swapped to manipulate Curve pool prices
- Deposited into Harvest at the manipulated (favorable) price
- Reversed swap, withdrew at the new (also favorable) price
- Repeated 17 times in 7 minutes
Defense Strategies
Use Time-Weighted Average Prices (TWAPs)
TWAPs calculate the average price over multiple blocks, making single-transaction manipulation impossible:
function getTWAP(address pair, uint32 period) internal view returns (uint256) {
(uint256 price0Cumulative, uint256 price1Cumulative, uint32 blockTimestamp) =
UniswapV2OracleLibrary.currentCumulativePrices(pair);
// Requires historical observation from `period` seconds ago
uint32 timeElapsed = blockTimestamp - observation.timestamp;
require(timeElapsed >= period, "Period not elapsed");
return (price0Cumulative - observation.price0Cumulative) / timeElapsed;
} Use Chainlink with Proper Validation
Chainlink aggregates prices from multiple sources off-chain, making manipulation economically impractical:
function getPrice(address feed) internal view returns (uint256) {
(, int256 price,, uint256 updatedAt,) =
AggregatorV3Interface(feed).latestRoundData();
require(price > 0, "Invalid price");
require(block.timestamp - updatedAt < MAX_STALENESS, "Stale price");
return uint256(price);
} Validate Price Bounds
Even with good oracles, add sanity checks:
function validatePrice(uint256 price, uint256 lastPrice) internal pure {
uint256 deviation = price > lastPrice
? (price - lastPrice) * 100 / lastPrice
: (lastPrice - price) * 100 / lastPrice;
require(deviation <= MAX_PRICE_DEVIATION, "Price deviation too high");
} Pattern 2: Governance Attacks
Flash loans can acquire massive voting power for a single transaction. If governance actions can execute immediately, attackers can pass malicious proposals and execute them before anyone reacts.
The Attack Flow
- Flash loan governance tokens
- Create malicious proposal (drain treasury, change parameters, etc.)
- Vote with borrowed tokens
- Execute proposal immediately
- Repay flash loan
Real Example: Beanstalk ($182M)
The attacker:
- Flash loaned billions in stablecoins
- Converted to BEAN tokens and Silo deposits
- Called
emergencyCommit()on a pre-planted malicious proposal - Drained the entire treasury
- Repaid loans with profit
Defense Strategies
Time-Delayed Execution
Require a delay between proposal passing and execution:
function queue(uint256 proposalId) external {
require(state(proposalId) == ProposalState.Succeeded, "Not succeeded");
Proposal storage proposal = proposals[proposalId];
proposal.eta = block.timestamp + TIMELOCK_DELAY;
emit ProposalQueued(proposalId, proposal.eta);
}
function execute(uint256 proposalId) external {
require(state(proposalId) == ProposalState.Queued, "Not queued");
require(block.timestamp >= proposals[proposalId].eta, "Timelock not expired");
// Execute...
} Snapshot Voting Power
Measure voting power at a past block, not the current one:
function propose(...) external returns (uint256) {
// Snapshot voting power from previous block
uint256 snapshotBlock = block.number - 1;
require(
getVotes(msg.sender, snapshotBlock) >= proposalThreshold,
"Below threshold"
);
// Store snapshot block for this proposal
proposals[proposalId].snapshotBlock = snapshotBlock;
}
function castVote(uint256 proposalId, bool support) external {
// Use historical voting power, not current
uint256 votes = getVotes(msg.sender, proposals[proposalId].snapshotBlock);
// ...
} Pattern 3: Vault Share Manipulation
Vaults that calculate share prices based on current balances are vulnerable to donation attacks and first-depositor manipulation.
The Attack Flow (First Depositor)
- Deposit a tiny amount (1 wei) as the first depositor
- “Donate” a large amount directly to the vault (not through deposit)
- The share price is now massively inflated
- Next depositor’s large deposit receives almost no shares
- Withdraw, taking most of their deposit
Real Example: Multiple Protocols
This pattern has hit numerous yield aggregators and vaults. A $1M deposit might receive 1 share if an attacker inflates the share price first.
Defense Strategies
Virtual Shares/Assets
Add virtual liquidity that makes manipulation uneconomical:
function convertToShares(uint256 assets) public view returns (uint256) {
uint256 totalAssets_ = totalAssets() + 1; // Virtual asset
uint256 totalSupply_ = totalSupply() + 10**decimalsOffset(); // Virtual shares
return assets.mulDiv(totalSupply_, totalAssets_, Math.Rounding.Down);
}
function convertToAssets(uint256 shares) public view returns (uint256) {
uint256 totalAssets_ = totalAssets() + 1;
uint256 totalSupply_ = totalSupply() + 10**decimalsOffset();
return shares.mulDiv(totalAssets_, totalSupply_, Math.Rounding.Down);
} Minimum Initial Deposit
Require a meaningful first deposit:
function deposit(uint256 assets, address receiver) public returns (uint256 shares) {
if (totalSupply() == 0) {
require(assets >= MINIMUM_INITIAL_DEPOSIT, "Initial deposit too small");
// Optionally burn some initial shares to dead address
}
// ...
} Pattern 4: Liquidity Manipulation
Protocols that make decisions based on liquidity pool states are vulnerable when that liquidity can be temporarily drained or inflated.
The Attack Flow
- Flash loan assets from a liquidity pool
- The pool now has different reserves/ratios
- Target protocol reads the manipulated pool state
- Exploit based on wrong assumptions
- Return liquidity, repay loan
Defense Strategies
Avoid Reading Pool State Directly
Don’t use getReserves() or balance queries for price calculations:
// DANGEROUS
function getPrice() internal view returns (uint256) {
(uint112 reserve0, uint112 reserve1,) = pair.getReserves();
return uint256(reserve1) * 1e18 / reserve0;
}
// SAFER - Use accumulated prices
function getPrice() internal view returns (uint256) {
return getTWAP(pair, TWAP_PERIOD);
} Require Multi-Block Settlement
For large operations, require action to span multiple blocks:
mapping(address => uint256) public pendingWithdrawals;
mapping(address => uint256) public withdrawalBlock;
function initiateWithdrawal(uint256 shares) external {
pendingWithdrawals[msg.sender] = shares;
withdrawalBlock[msg.sender] = block.number;
}
function completeWithdrawal() external {
require(
block.number >= withdrawalBlock[msg.sender] + MIN_BLOCKS,
"Too soon"
);
uint256 shares = pendingWithdrawals[msg.sender];
pendingWithdrawals[msg.sender] = 0;
// Process withdrawal...
} Building Flash Loan Resistant Protocols
Principle 1: Nothing Critical in One Transaction
Any state that affects significant value should require multiple transactions or blocks to change:
- Price feeds: Use TWAPs or multi-source aggregation
- Governance: Time-delayed execution
- Large withdrawals: Multi-block settlement
- Parameter changes: Timelocks
Principle 2: Assume Unlimited Adversarial Capital
When threat modeling, assume attackers have infinite capital for a single transaction. If your invariants can be broken with enough capital in one block, they will be.
Principle 3: Validate Against Historical State
Compare current values against recent historical values. Dramatic single-block changes are suspicious:
uint256 public lastRecordedPrice;
uint256 public lastRecordedBlock;
function validatePrice(uint256 currentPrice) internal {
if (block.number > lastRecordedBlock) {
if (lastRecordedBlock != 0) {
uint256 deviation = calculateDeviation(currentPrice, lastRecordedPrice);
require(deviation <= MAX_SINGLE_BLOCK_DEVIATION, "Suspicious price move");
}
lastRecordedPrice = currentPrice;
lastRecordedBlock = block.number;
}
} Principle 4: Circuit Breakers
When something unusual happens, pause and investigate rather than continue:
function executeOperation() external {
uint256 price = getPrice();
if (isPriceAnomalous(price)) {
emit CircuitBreakerTriggered(price);
paused = true;
return; // Don't execute with suspicious price
}
// Normal execution...
} Testing for Flash Loan Vulnerabilities
Standard unit tests won’t catch flash loan attacks. You need adversarial testing:
Foundry Fork Tests
Test against mainnet state with real flash loan providers:
function testFlashLoanAttack() public {
// Fork mainnet
vm.createSelectFork(MAINNET_RPC);
// Get flash loan
uint256 loanAmount = 100_000_000 * 1e18;
flashLender.flashLoan(address(this), token, loanAmount, "");
}
function onFlashLoan(
address initiator,
address token,
uint256 amount,
uint256 fee,
bytes calldata
) external returns (bytes32) {
// Attempt attack
// ...
// Repay
IERC20(token).approve(msg.sender, amount + fee);
return keccak256("ERC3156FlashBorrower.onFlashLoan");
} Invariant Testing
Define properties that must always hold, even under adversarial conditions:
function invariant_totalAssetsMatchesBalance() public {
assertGe(
token.balanceOf(address(vault)),
vault.totalAssets()
);
}
function invariant_sharesFullyBacked() public {
uint256 totalValue = vault.totalAssets();
uint256 totalShares = vault.totalSupply();
// Each share should be worth at least minShareValue
if (totalShares > 0) {
assertGe(totalValue * 1e18 / totalShares, MIN_SHARE_VALUE);
}
} Key Takeaways
- Flash loans enable unlimited capital for single transactions—design accordingly
- Never use spot prices for critical calculations—always TWAP or Chainlink
- Governance must have time delays—instant execution is instant exploitation
- Vault share calculations need virtual offsets—prevent first-depositor attacks
- Multi-block settlement prevents single-transaction manipulation
- Circuit breakers save protocols—pause on anomalies, investigate later
Flash loan attacks will continue evolving as DeFi grows more complex. The protocols that survive will be those that assumed adversarial capital from day one and designed defenses into their core architecture.
Concerned about flash loan vulnerabilities in your protocol? Request a security assessment to identify and fix these risks before launch.
Ready to Secure Your Project?
Whether you're preparing for launch or strengthening an existing protocol, we're here to help. Get in touch for a confidential conversation about your security needs.