Flash Loan Attacks
Flash loans are uncollateralized loans that must be borrowed and repaid within a single transaction. If repayment fails, the entire transaction reverts as if it never happened. This atomic guarantee makes them safe to offer — but also gives attackers access to billions of dollars of capital for a few hundred dollars in gas fees. Flash loans did not create new vulnerabilities, but they made attacking existing ones economically viable for anyone.
Flash loans are neutral technology — used for legitimate arbitrage, liquidations, and collateral swaps daily. But they are also the primary amplification tool for oracle manipulation and governance attacks. Euler Finance lost $197M in a flash-loan-enabled attack. Understanding flash loans is essential for auditing any DeFi protocol.
How Flash Loans Work
// The atomicity guarantee:
// If repayment fails, entire transaction reverts — steps 1-4 never happened
// The lender protocol takes ZERO credit risk
// STEP 1: Borrower calls flashLoan(amount, callbackData)
// STEP 2: Protocol sends amount tokens to borrower's contract
// STEP 3: Protocol calls borrower.onFlashLoan(amount, fee, callbackData)
// STEP 4: Borrower does ANYTHING with the tokens in the callback
// STEP 5: Borrower returns amount + fee to the protocol
// STEP 6: Protocol verifies repayment — if insufficient, REVERT everything
// Flash Loan Sources and Fees:
// Aave V3: 0.09% fee (flashLoan / flashLoanSimple)
// Uniswap V3: 0.05% fee (flash)
// Balancer: 0.00% fee (flashLoan)
// MakerDAO: 0.00% fee (dai flashMint)ERC3156: Standard Flash Loan Interface
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.20;
import "@openzeppelin/contracts/interfaces/IERC3156FlashBorrower.sol";
import "@openzeppelin/contracts/token/ERC20/IERC20.sol";
contract FlashLoanAttacker is IERC3156FlashBorrower {
address public immutable lender;
address public owner;
bytes32 private constant CALLBACK_SUCCESS =
keccak256("ERC3156FlashBorrower.onFlashLoan");
constructor(address _lender) {
lender = _lender;
owner = msg.sender;
}
// Entry point: initiate the flash loan
function initiateAttack(address token, uint256 amount) external {
require(msg.sender == owner, "Not owner");
bytes memory data = abi.encode(token, amount);
IERC3156FlashLender(lender).flashLoan(this, token, amount, data);
}
// Called by the lender — this contract holds amount tokens here
function onFlashLoan(
address initiator,
address token,
uint256 amount,
uint256 fee,
bytes calldata data
) external override returns (bytes32) {
require(msg.sender == lender, "Not lender");
require(initiator == address(this), "Not initiator");
// ALL ATTACK LOGIC GOES HERE
// This contract holds amount tokens at this point
_executeAttack(token, amount);
// Approve repayment: amount + fee must be returned
IERC20(token).approve(lender, amount + fee);
return CALLBACK_SUCCESS;
}
function _executeAttack(address token, uint256 amount) internal {
// Attacker-specific exploit logic: manipulate oracle, drain protocol, etc.
}
}Flash Loan Attack Patterns
// Vulnerable: governance counts votes at time of execution
// not at time of vote — flash loan borrows enough tokens to pass any proposal
// Attack flow:
// 1. Deploy malicious proposal (e.g., drain treasury to attacker)
// 2. Flash loan 51% of governance tokens from lending market
// 3. Vote YES on malicious proposal with borrowed tokens
// 4. Execute proposal (if no timelock)
// 5. Repay flash loan + fee
// Result: treasury drained, repaid loan, kept profit
// DEFENSE: Snapshot voting power at proposal creation block
// A flash loan is within ONE block — snapshot from PREVIOUS block prevents this
function getVotingPower(address voter, uint256 snapshotBlock)
external view returns (uint256) {
// Read voting power at a PAST block — immune to current-block flash loans
return governanceToken.getPastVotes(voter, snapshotBlock);
}// Simplified explanation of the Euler Finance exploit (March 2023)
// THE BUG: donateToReserves() had no health check after execution
// This allowed intentionally creating an insolvent position
// STEP 1: Flash loan 30M DAI from Aave
// STEP 2: Deposit 20M DAI into Euler — receive eDAI tokens (collateral)
// STEP 3: Borrow 200M eDAI against deposit (Euler allows leverage internally)
// STEP 4: Repay 10M borrowed eDAI (reduces some debt, but still over-leveraged)
// STEP 5: Call donateToReserves(100M eDAI) — KEY BUG: no health check
// donating collateral makes the position insolvent intentionally
// STEP 6: Second attacker address liquidates the first at a discount
// Self-liquidation: attacker controls both sides, pockets the liquidation bonus
// STEP 7: Exit position, repay flash loan, keep profit
// ROOT CAUSE: Missing health check after donateToReserves()
// FIX: Require position to be healthy after every operation that modifies it
function donateToReserves(uint256 amount) external {
_decreaseCollateral(msg.sender, amount);
_increaseReserves(amount);
// ❌ BUG: No health check — position can be insolvent after this
// ✅ FIX: Add health check
require(_isHealthy(msg.sender), "Position unhealthy after donation");
}Defenses Against Flash Loan Attacks
contract FlashLoanResistantProtocol {
uint256 public constant MIN_LOCK_BLOCKS = 1;
mapping(address => uint256) public depositBlock;
// DEFENSE 1: Block same-transaction deposit+borrow
// Flash loans deposit and borrow in the same block
function deposit(uint256 amount) external {
depositBlock[msg.sender] = block.number;
// ... update balances
}
function borrow(uint256 amount) external {
// ✅ Cannot deposit and borrow in the same block
require(
block.number > depositBlock[msg.sender] + MIN_LOCK_BLOCKS,
"Deposit too recent — flash loan prevention"
);
// ... borrow logic
}
// DEFENSE 2: TWAP pricing — immune to same-block manipulation
function getCollateralValue(uint256 amount) external view returns (uint256) {
uint256 twapPrice = _get30MinTWAP(); // Cannot be moved by flash loan
return (amount * twapPrice) / 1e18;
}
// DEFENSE 3: Health check AFTER every operation
modifier checkHealthAfter() {
_; // Execute function first
require(_isPositionHealthy(msg.sender), "Position unhealthy");
}
function _get30MinTWAP() internal view returns (uint256) { return 2000e18; }
function _isPositionHealthy(address) internal view returns (bool) { return true; }
function _decreaseCollateral(address, uint256) internal {}
function _increaseReserves(uint256) internal {}
function _isHealthy(address) internal view returns (bool) { return true; }
}Foundry Fork Test: Flash Loan Simulation
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.20;
import "forge-std/Test.sol";
import "@openzeppelin/contracts/token/ERC20/IERC20.sol";
contract FlashLoanAttackTest is Test {
address constant AAVE_POOL = 0x87870Bca3F3fD6335C3F4ce8392D69350B4fA4E2;
address constant USDC = 0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48;
FlashLoanAttacker public attacker;
address public victim;
function setUp() public {
// Fork mainnet at a specific block for reproducibility
vm.createSelectFork(vm.envString("MAINNET_RPC_URL"), 19500000);
victim = address(new VulnerableLendingProtocol());
attacker = new FlashLoanAttacker(AAVE_POOL);
// Seed victim with 1M USDC (simulates protocol TVL)
deal(USDC, victim, 1_000_000 * 1e6);
}
/// @notice Demonstrates flash loan attack draining vulnerable protocol
function test_flashLoanAttackDrainsProtocol() public {
uint256 victimBefore = IERC20(USDC).balanceOf(victim);
// Borrow 50M USDC, execute attack callback, repay
attacker.initiateAttack(USDC, 50_000_000 * 1e6);
uint256 victimAfter = IERC20(USDC).balanceOf(victim);
assertLt(victimAfter, victimBefore, "Victim should lose funds");
assertGt(
IERC20(USDC).balanceOf(address(attacker)),
0,
"Attacker should gain funds"
);
}
/// @notice Demonstrates that the fixed protocol resists the attack
function test_fixedProtocol_resistsFlashLoan() public {
address fixedVictim = address(new FlashLoanResistantProtocol());
deal(USDC, fixedVictim, 1_000_000 * 1e6);
// Attack should revert
vm.expectRevert();
attacker.initiateAttack(USDC, 50_000_000 * 1e6);
// Funds intact
assertEq(IERC20(USDC).balanceOf(fixedVictim), 1_000_000 * 1e6);
}
}Real-World Flash Loan Exploits
| Protocol | Year | Loss | Flash Loan Source | Attack Type |
|---|---|---|---|---|
| Euler Finance | 2023 | $197M | Aave | Unguarded donateToReserves + self-liquidation loop |
| bZx Fulcrum | 2020 | $350k | dYdX | Flash loan moved Uniswap price, exploited bZx collateral check |
| Harvest Finance | 2020 | $34M | Uniswap | Flash loan manipulated Curve USDC/USDT price, exploited Harvest valuation |
| Pancake Bunny | 2021 | $45M | PancakeSwap | Flash loan BNB, pumped BUNNY price, minted BUNNY at stale price, dumped |
| Cream Finance | 2021 | $130M | Aave + MakerDAO | Recursive flash loans to manipulate crETH2 price oracle |
Key Takeaways
- Flash loans amplify existing vulnerabilities — they do not create new ones. Every flash loan attack exploits an underlying bug: bad oracle, missing health check, or flawed governance snapshot. Fix the underlying vulnerability, not the flash loan.
- TWAP oracles and historical voting snapshots are flash-loan resistant. Flash loans operate within one block. Any mechanism looking at past blocks cannot be manipulated by a single-block flash loan.
- Always run health checks after every state-changing operation. Euler's critical bug was that donateToReserves() allowed creating unhealthy positions with no validation.
- Same-block deposit+borrow patterns enable flash loan attacks. A minimum one-block delay between deposit and borrow prevents flash-loan-funded collateral attacks.
- Write Foundry fork tests against real mainnet state. vm.createSelectFork lets you test against real Aave and Uniswap contracts at any block. Use this to simulate realistic attack scenarios.
Aave v3 Flash Loan Implementation Pattern
// Aave v3 uses a different callback signature than ERC3156
interface IPool {
function flashLoan(
address receiverAddress,
address[] calldata assets,
uint256[] calldata amounts,
uint256[] calldata interestRateModes,
address onBehalfOf,
bytes calldata params,
uint16 referralCode
) external;
}
contract AaveFlashLoanReceiver {
address constant AAVE_POOL = 0x87870Bca3F3fD6335C3F4ce8392D69350B4fA4E2;
function initiateAttack(address token, uint256 amount) external {
address[] memory assets = new address[](1);
uint256[] memory amounts = new uint256[](1);
uint256[] memory modes = new uint256[](1);
assets[0] = token;
amounts[0] = amount;
modes[0] = 0; // 0 = no debt (must repay in same tx)
IPool(AAVE_POOL).flashLoan(
address(this),
assets, amounts, modes,
address(this),
"", // params — passed to executeOperation
0 // referral code
);
}
// Aave calls this after sending tokens to this contract
function executeOperation(
address[] calldata assets,
uint256[] calldata amounts,
uint256[] calldata premiums, // 0.09% fee
address initiator,
bytes calldata params
) external returns (bool) {
require(msg.sender == AAVE_POOL, "Not Aave pool");
require(initiator == address(this), "Not initiator");
// ← Execute attack logic here ←
// At this point: this contract holds amounts[0] of assets[0]
// Approve repayment: amount + fee
for (uint256 i = 0; i < assets.length; ) {
uint256 repay = amounts[i] + premiums[i];
IERC20(assets[i]).approve(AAVE_POOL, repay);
unchecked { ++i; }
}
return true; // Signal success to Aave
}
}Flash Loan Economic Analysis
Understanding when flash loan attacks are economically viable helps prioritize which protocol vulnerabilities to fix first. Not all bugs are equally exploitable with flash loans — profitability depends on the value at risk, the flash loan fee, and gas costs.
| Attack Type | Flash Loan Amplification | Break-Even Threshold | Real Example |
|---|---|---|---|
| Oracle manipulation | Very high — any price discrepancy exploitable | Low — any poorly-priced collateral works | Mango Markets ($115M) |
| Governance attack | High — any exploitable proposal | Medium — needs brief lack of timelock | Beanstalk ($182M) |
| Reentrancy amplification | Medium — amplifies existing reentrancy | High — base reentrancy must exist | Cream Finance |
| Arbitrage | High — captures price differences | Very low — just needs price discrepancy | Legitimate MEV |
Flash loans are often blamed for hacks, but they are merely an amplification tool. The Mango Markets attacker could have done the same attack with $100M of their own capital — the flash loan just made it cheaper and accessible. The root cause is always the underlying vulnerability: the oracle, the governance snapshot, the missing health check. Protocols should not try to detect or prevent flash loans — they should fix the vulnerabilities that flash loans exploit.
Uniswap V3 Flash Swaps
Uniswap V3 implements flash loans as "flash swaps" — you can borrow any token from a pool and repay at the end of the same callback, either with the same token plus a fee (0.05%–1%) or with the other pool token at the current spot price. This makes Uniswap V3 a popular flash loan source alongside Aave, often used by arbitrageurs and attackers alike.
interface IUniswapV3FlashCallback {
/// @param fee0 Fee owed for token0 borrowed
/// @param fee1 Fee owed for token1 borrowed
/// @param data Arbitrary data passed from flash() call
function uniswapV3FlashCallback(
uint256 fee0,
uint256 fee1,
bytes calldata data
) external;
}
contract UniswapV3FlashBorrower is IUniswapV3FlashCallback {
IUniswapV3Pool public immutable pool;
IERC20 public immutable token0;
IERC20 public immutable token1;
constructor(address _pool) {
pool = IUniswapV3Pool(_pool);
token0 = IERC20(pool.token0());
token1 = IERC20(pool.token1());
}
function initiateFlash(uint256 amount0, uint256 amount1) external {
bytes memory data = abi.encode(msg.sender);
pool.flash(address(this), amount0, amount1, data);
}
function uniswapV3FlashCallback(
uint256 fee0,
uint256 fee1,
bytes calldata data
) external override {
require(msg.sender == address(pool), "Only pool");
// --- Execute arbitrage or legitimate operation here ---
// Repay: token0 + fee0, token1 + fee1
uint256 bal0 = token0.balanceOf(address(this));
uint256 bal1 = token1.balanceOf(address(this));
if (bal0 > 0) token0.transfer(address(pool), bal0);
if (bal1 > 0) token1.transfer(address(pool), bal1);
}
}Collateral Swap — A Legitimate Flash Loan Use Case
Not all flash loan use cases are malicious. One common legitimate pattern is "collateral swapping" — atomically changing your collateral type in a lending protocol without closing your position. This requires a flash loan to temporarily cover the withdrawal.
// Scenario: You have 10 ETH collateral in Aave backing 5,000 USDC debt.
// You want to swap to wBTC collateral without closing the position.
//
// Step 1: Flash borrow 5,000 USDC from Aave (to repay existing debt)
// Step 2: Repay your 5,000 USDC Aave debt → ETH collateral is now free
// Step 3: Withdraw your 10 ETH collateral
// Step 4: Swap 10 ETH → wBTC on DEX
// Step 5: Deposit wBTC as new collateral in Aave
// Step 6: Borrow 5,000 USDC again (backed by wBTC)
// Step 7: Repay the flash loan 5,000 USDC + fee
// Result: Your debt stays the same, but collateral is now wBTC
function executeOperation(
address[] calldata assets,
uint256[] calldata amounts,
uint256[] calldata premiums,
address initiator,
bytes calldata params
) external returns (bool) {
// Decode: which collateral to exit, which to enter, swap parameters
(
address exitCollateral,
address enterCollateral,
bytes memory swapData
) = abi.decode(params, (address, address, bytes));
// Repay existing debt using flash-borrowed USDC
ILendingPool(AAVE).repay(assets[0], amounts[0], 2, initiator);
// Withdraw freed collateral
ILendingPool(AAVE).withdraw(exitCollateral, type(uint256).max, address(this));
// Swap to new collateral (ETH → wBTC)
_swapOnDEX(exitCollateral, enterCollateral, swapData);
// Deposit new collateral and re-borrow to repay flash loan
IERC20(enterCollateral).approve(AAVE, type(uint256).max);
ILendingPool(AAVE).deposit(enterCollateral, IERC20(enterCollateral).balanceOf(address(this)), initiator, 0);
ILendingPool(AAVE).borrow(assets[0], amounts[0] + premiums[0], 2, 0, initiator);
// Approve Aave to pull repayment
IERC20(assets[0]).approve(AAVE, amounts[0] + premiums[0]);
return true;
}Flash Loan Attack Detection Checklist
| Check | What to Look For | Detection Method |
|---|---|---|
| Oracle reads | Spot price read before and after large swap in same tx | Trace analysis, Tenderly simulation |
| Balance snapshots | Protocol checks balanceOf() rather than internal accounting | Static analysis, Slither detector |
| Governance voting | Snapshot taken at current block, not historical | Compound-style getPriorVotes() review |
| Same-block borrows | Token borrowed and returned in same block without fee | On-chain event monitoring |
| Large single-tx flows | Millions of dollars moved atomically through multiple protocols | MEV bots, Flashbots transparency |
| Reentrancy vectors | Callback interfaces (executeOperation, onFlashLoan) that call back into protocol | Call graph analysis, reentrancy guards |
Key Takeaways
- Flash loans provide uncollateralized capital for a single transaction — any vulnerability exploitable with enough capital is potentially exploitable with a flash loan.
- The root cause is never the flash loan itself — it is always the underlying vulnerability (oracle, governance snapshot, missing health check).
- Protocols should never rely on
balanceOf()for price discovery — always use internal accounting or time-weighted oracles. - Governance snapshots must use historical block numbers (
getPriorVotes()) to prevent flash-loan voting power manipulation. - The Euler Finance exploit ($197M) was returned after negotiation — most flash loan exploits are not so fortunate.
- Flash loans have legitimate uses: arbitrage, collateral swapping, liquidations, self-liquidation — recognize the pattern without assuming malice.