Flash loan exploits: how they work and how invariant testing catches them
Flash loan exploits: how they work and how invariant testing catches them
Flash loans changed DeFi forever — and not just for the builders. Since their introduction, they've been the weapon of choice behind some of the biggest exploits in crypto history. The ability to borrow millions without collateral, use those funds to manipulate protocol state, and repay everything in a single atomic transaction has cost protocols hundreds of millions of dollars.
But here's the thing: flash loan attacks don't exploit flash loans themselves. They exploit weaknesses in the protocols that interact with borrowed funds. And those weaknesses are exactly what invariant testing is built to find.
How flash loans actually work
A flash loan lets you borrow any amount of tokens from a lending pool, as long as you repay the full amount (plus a small fee) within the same transaction. If repayment doesn't happen, the entire transaction reverts — the blockchain acts like the loan never existed.
The flow looks like this:
- Your contract calls the lending pool's flash loan function
- The pool sends tokens to your contract and calls your callback
- Inside the callback, you do whatever you want with the funds
- Before the transaction ends, you repay the pool
- If you don't repay, everything reverts
This mechanism is powerful because it removes the capital barrier. An attacker doesn't need $200M sitting in a wallet. They just need a smart contract and a vulnerability to target.
Real exploits that shook DeFi
Euler Finance — $197M (March 2023)
The Euler hack exploited a flaw in how the protocol handled donations to reserves. The attacker flash-borrowed DAI, deposited it, used the donateToReserves function to create an artificial bad debt position, then liquidated themselves at a profit. The core issue wasn't the flash loan — it was that donateToReserves didn't properly account for how reducing a user's collateral affected their health factor.
bZx — $8M (February 2020)
One of the earliest flash loan attacks. The attacker borrowed ETH via a flash loan, used part as collateral on Compound, shorted ETH on bZx's Fulcrum platform (which pushed the price on Uniswap due to low liquidity), then profited from the price difference. The vulnerability was bZx's reliance on a single DEX for price discovery.
Cream Finance — $130M (October 2021)
Cream was hit through a multi-step flash loan attack involving price manipulation of yUSD vault tokens. The attacker inflated the price of their collateral by manipulating the underlying vault's share price, then borrowed against the inflated value. The root cause: the protocol used a spot price that could be manipulated within a single transaction.
The pattern behind every flash loan exploit
If you look at these attacks, there's a common thread. Every single one exploits the fact that protocol state can be temporarily pushed into an inconsistent position within an atomic transaction. The three most common vectors are:
- Price oracle manipulation: Spot prices from AMMs can be moved with enough capital
- Reentrancy during callbacks: Flash loan callbacks execute arbitrary code mid-transaction
- Accounting inconsistencies: Deposits, donations, or transfers that temporarily break internal invariants
Where invariant testing fits in
Traditional unit tests won't catch these issues because you'd need to specifically imagine the exact multi-step attack sequence. Flash loan exploits often involve 5-10 steps across multiple protocols — no human enumerates all those paths.
Invariant testing takes the opposite approach. You define properties that must hold true regardless of what happens, and a fuzzer throws millions of random transaction sequences at your contracts. If any sequence breaks a property, you've found a bug.
<a href="/request-audit" class="cta-button">Get expert fuzzing for your protocol</a>
Catching flash loan vulnerabilities with properties
Let's look at concrete examples. First, here's a simplified vulnerable flash loan callback:
// VULNERABLE: No reentrancy guard, state updated after external call
contract VulnerableLender {
mapping(address => uint256) public balances;
function flashLoan(uint256 amount, address callback) external {
uint256 balanceBefore = token.balanceOf(address(this));
token.transfer(callback, amount);
IFlashBorrower(callback).onFlashLoan(amount); // arbitrary external call
// State check happens after external call — reentrancy window
require(
token.balanceOf(address(this)) >= balanceBefore,
"Flash loan not repaid"
);
}
function deposit(uint256 amount) external {
token.transferFrom(msg.sender, address(this), amount);
balances[msg.sender] += amount;
}
function withdraw(uint256 amount) external {
require(balances[msg.sender] >= amount);
balances[msg.sender] -= amount;
token.transfer(msg.sender, amount); // can be called during flashLoan callback
}
}
The issue here is that during the onFlashLoan callback, an attacker can call deposit and withdraw to manipulate their balance while the flash loan's repayment check hasn't executed yet.
Property 1: Reentrancy detection via solvency invariant
Using the Chimera framework, you can write a property that catches this class of bugs:
// Chimera invariant property — checks solvency after every action
abstract contract FlashLoanProperties is BeforeAfter {
// PROPERTY: Protocol token balance must always cover all user deposits
function invariant_solvency_during_flash_loans() public view returns (bool) {
uint256 totalTrackedDeposits = lender.totalDeposits();
uint256 actualBalance = token.balanceOf(address(lender));
// If actual balance drops below tracked deposits,
// something extracted value it shouldn't have
return actualBalance >= totalTrackedDeposits;
}
// PROPERTY: No single tx sequence should change total deposits
// without corresponding token transfers
function invariant_deposit_accounting_consistent() public view returns (bool) {
uint256 sumOfBalances = 0;
for (uint256 i = 0; i < actors.length; i++) {
sumOfBalances += lender.balances(actors[i]);
}
return sumOfBalances == lender.totalDeposits();
}
}
The fuzzer will generate sequences that include flash loan calls interleaved with deposits and withdrawals. When a reentrancy bug lets someone inflate their balance, invariant_solvency_during_flash_loans breaks because tracked deposits exceed the actual token balance.
Property 2: Oracle manipulation detection
For price oracle attacks, you need a property that detects when prices move beyond reasonable bounds within a single block:
abstract contract OracleProperties is BeforeAfter {
uint256 constant MAX_PRICE_DEVIATION_BPS = 500; // 5% max per block
// PROPERTY: Oracle price can't deviate more than threshold in one block
function invariant_oracle_price_stability() public view returns (bool) {
uint256 currentPrice = oracle.getPrice(address(token));
uint256 lastKnownPrice = _before.oraclePrice;
if (lastKnownPrice == 0) return true; // skip first call
uint256 deviation;
if (currentPrice > lastKnownPrice) {
deviation = ((currentPrice - lastKnownPrice) * 10000) / lastKnownPrice;
} else {
deviation = ((lastKnownPrice - currentPrice) * 10000) / lastKnownPrice;
}
return deviation <= MAX_PRICE_DEVIATION_BPS;
}
// PROPERTY: Collateral value used for borrowing must reflect
// time-weighted price, not spot price
function invariant_no_spot_price_borrowing() public view returns (bool) {
uint256 spotPrice = amm.getSpotPrice(address(token));
uint256 twapPrice = oracle.getTWAP(address(token), 30 minutes);
// If spot deviates significantly from TWAP, any new borrows
// using spot price indicate a manipulation window
uint256 gap = spotPrice > twapPrice
? spotPrice - twapPrice
: twapPrice - spotPrice;
if (gap * 10000 / twapPrice > 1000) { // >10% gap
// No new borrows should have been opened at manipulated price
return _after.totalBorrows == _before.totalBorrows;
}
return true;
}
}
This catches the exact pattern that hit Cream Finance. When the fuzzer manipulates an AMM pool's reserves (which it'll naturally try during random sequences), the TWAP divergence check flags any borrows that relied on the manipulated spot price.
Why this works better than manual review
An auditor reviewing the Euler codebase might look at donateToReserves and think "this reduces the user's assets, but there's a health check later." The subtlety is in the interaction between donation, liquidation math, and flash-loaned capital — a chain of 6+ steps that's genuinely hard to trace mentally.
A fuzzer doesn't need to imagine the attack. It just needs good properties. If "the protocol is always solvent" and "no user can extract more than they deposited plus earned yield" are both defined as invariants, the fuzzer will find any sequence that violates them — including sequences that involve flash loans, reentrancy, and price manipulation.
Practical takeaways
-
Don't trust spot prices for anything financial. Use TWAPs, Chainlink feeds, or multi-source medians. Write a property that flags spot-vs-TWAP divergence.
-
Define solvency properties early. "Actual token balance >= total tracked deposits" catches an enormous class of bugs, including flash loan reentrancy.
-
Include flash loan handlers in your fuzz campaigns. If your protocol interacts with Aave, dYdX, or Balancer flash loans, add handler functions that simulate flash-loan-funded sequences.
-
Test accounting invariants after every call. Sum of individual balances must equal the tracked total. This catches double-counting and reentrancy inflation.
-
Don't rely on reentrancy guards alone. They help, but cross-function and cross-contract reentrancy can bypass them. Properties catch the consequence regardless of the vector.
Flash loan attacks aren't going away — the capital-free nature of these loans means every protocol is a potential target. But with the right properties, you don't need to predict the exact attack. You just need to define what "correct" looks like, and let the fuzzer find everything that isn't.
For a deeper dive into how fuzzing works under the hood, check out What is smart contract fuzzing?. And if you're writing your first invariant tests, How to write your first invariant test walks through the full setup.
Related Posts
ZK circuit security: constraint bugs, witness leaks, and audit patterns
ZK circuits aren't programs — they're constraint systems. The bugs look different: under-constrained...
Access control bugs in Solidity: real hacks and property-based defense
A missing modifier. A delegatecall to an untrusted address. Access control bugs have caused some of ...