Flash Loans
Flash loans are uncollateralized loans that must be repaid within the same transaction. They are not a vulnerability by themselves; they are a tool that turns any economic vulnerability worth more than the flash-loan fee into a fully-funded attack.
Most large DeFi exploits since 2020 have used flash loans as the funding mechanism. The bugs being exploited would have been bugs regardless; flash loans merely lowered the capital barrier from "have a million dollars in your wallet" to "have 100,000 in gas + flash-loan fees."
How Flash Loans Work
A flash-loan provider (Aave, Balancer, Uniswap V3 via flash, dYdX, Morpho) lends a large amount of assets to the borrower. The borrower's contract receives the funds, executes arbitrary logic, and must return the funds plus a small fee before the transaction ends. If the funds aren't returned, the entire transaction reverts.
// Borrower-side pattern (Aave V3):
function flashLoanCallback(
address asset, uint256 amount, uint256 premium,
address initiator, bytes calldata params
) external returns (bool) {
// ...arbitrary logic using `amount` of `asset`...
IERC20(asset).approve(address(pool), amount + premium);
return true;
}
Flash loans are useful for legitimate purposes — debt swaps, leverage adjustments, arbitrage, liquidations — and abused for malicious ones. The on-chain code doesn't (and can't) distinguish.
Attack Patterns Enabled
Oracle Manipulation Attacks
The most common pattern (§4.15.4 covers oracle defenses). Flash-loan a large amount; swap it in a low-liquidity pool to manipulate the spot price; perform a value-extracting action against another protocol that reads that price; swap back; repay.
Governance Attacks
A protocol with token-weighted voting can be attacked by flash-borrowing the governance token, voting on a malicious proposal, and returning the tokens. The 2022 Beanstalk attack drained $182M via this pattern.
Defense: voting power should be based on getPastVotes at a block snapshot before the proposal exists (Compound Governor's standard pattern), not on instantaneous balance.
Liquidation Arbitrage
A user with a position near liquidation can use a flash loan to repay their own debt, withdraw collateral, swap, and repay the loan — capturing the liquidation discount themselves rather than yielding it to a third-party liquidator. This is generally fine and arguably desirable; it's adversarial only if the protocol's liquidation incentive was supposed to fund some other mechanism.
Re-entrancy Funding
A re-entrancy bug worth $X requires the attacker to have $X to drain in their initial deposit. With flash loans, the threshold drops to "any amount."
State-Manipulation Composition
A protocol whose state depends on aggregated TVL, supply, or share counts can be temporarily manipulated by a flash loan. Examples:
- A vault that uses share-price for some external computation, where share-price depends on TVL.
- A protocol that distributes rewards proportional to balance, where temporarily inflating balance captures rewards.
- An emergency-pause mechanism that triggers at a TVL threshold; an attacker pushes TVL across the threshold transiently.
Defenses
1. Don't Trust Manipulable State Within a Transaction
If a value's reading and its use are in the same transaction, that value can have been manipulated by the same transaction. Defenses:
- TWAP or historical state instead of instantaneous values.
- State snapshots from a prior block (
block.number - 1, governance "snapshot blocks"). - Multi-step operations with intermediate commits rather than atomic within-transaction operations.
2. Check Invariants at the End of External Operations
A common pattern: at the start of a function, the state is fine; the function makes external calls; at the end, check that the state is still fine. If a flash loan-funded attack moved the state to a bad place, the final check catches it.
function userAction(...) external {
uint256 invariantBefore = _computeInvariant();
// ...do stuff that may involve external calls...
uint256 invariantAfter = _computeInvariant();
require(invariantAfter >= invariantBefore || /* allowed change */, "broken invariant");
}
This is the basis of many modern protocols' safety: the user's action can do whatever, as long as the protocol's invariants hold at the end.
3. Same-Block Restrictions
Some protocols restrict actions within the same block to prevent flash-loan composition:
mapping(address => uint256) lastInteractionBlock;
function action(...) external {
require(lastInteractionBlock[msg.sender] != block.number, "same block");
lastInteractionBlock[msg.sender] = block.number;
// ...
}
This prevents the attacker from depositing, manipulating state, and withdrawing all in one transaction — they have to commit to one block of risk. The trade-off: legitimate users can also only interact once per block, which can be annoying.
4. Withdrawal Delays
Some vaults add a withdrawal delay (commit-to-withdraw, wait, then actually withdraw). This prevents flash-loan-funded "deposit, manipulate, withdraw" within a single transaction. It costs UX for legitimate users.
5. Governance Snapshot Voting
Voting based on token balance at a past block, not current balance. Compound Governor, OpenZeppelin Governor, and most modern governance frameworks use this by default.
6. Re-entrancy Guards Everywhere
The defensive ceiling: even if a flash loan funds a deep callback chain, re-entrancy guards prevent the attacker from re-entering the protocol's critical state functions. Combined with CEI, this eliminates the entire re-entrancy bug class.
Common Audit Findings
Borrower-Side: Unauthenticated Callback
The flash-loan callback function is often public or external and must be callable by the lending pool. A common bug: the callback doesn't verify the caller is the legitimate lending pool:
function flashLoanCallback(address asset, uint256 amount, ...) external returns (bool) {
// ❌ no check that msg.sender == address(pool)
// ❌ no check that initiator == address(this)
// attacker calls this directly with attacker-controlled `asset` and `amount`
// exploiting whatever logic follows
}
Fix:
function flashLoanCallback(address asset, uint256 amount, ...) external returns (bool) {
require(msg.sender == address(pool), "not pool");
require(initiator == address(this), "wrong initiator");
// ...
}
This is a recurring finding. Any callback function should authenticate both the caller and the initiator.
Borrower-Side: Insufficient Repayment
The callback must repay the loan + fee. If the amount approved is wrong:
IERC20(asset).approve(address(pool), amount); // ❌ missing premium
The pool tries to pull amount + premium, the transfer fails, the whole transaction reverts. This is "safe" in that the loan can't proceed, but it's a footgun that masks other bugs.
Lender-Side: Re-entrancy Through Token
If the lending pool's flashLoan function transfers tokens to the borrower before completing its own accounting, and the token has a transfer hook (ERC-777, ERC-1155), the borrower can re-enter the pool. Most production flash-loan contracts handle this correctly; custom implementations sometimes don't.
Lender-Side: Fee Bypass
Fee calculation in custom flash-loan implementations sometimes has rounding-down errors that let the borrower repay slightly less than expected. Cumulatively, this drains the pool's fee reserves.
Composition: "Flash Loan Inside Another Flash Loan"
A borrower can compose flash loans (borrow from Aave, then immediately borrow from Balancer using the Aave funds as collateral somewhere). This is legitimate and used in arbitrage. Defenses against composition (re-entrancy guards on the lender) sometimes block this; sometimes they don't. Either is fine if intentional.
Auditor Checklist
For a protocol that consumes flash loans:
-
Callback authenticates
msg.sender == lending pool. -
Callback authenticates
initiator == address(this). - Repayment includes the premium correctly.
-
Approval to the pool is exactly
amount + premium, not unbounded.
For a protocol that provides flash loans (acts as lender):
- Pool re-entrancy guarded (no re-entry from token transfer hooks).
- Fee math is exact; no rounding-down bypass.
- Pool's solvency check happens after the borrower returns; before doesn't catch the attack.
- Reentrancy through composition (nested flash loans) considered.
For a protocol that's exposed to flash loan-funded attacks (everyone):
- No value-bearing logic uses spot-AMM prices.
- Governance voting uses past-block snapshots.
- Critical functions check invariants at the end, not just before.
- Vault deposits/withdraws are protected against atomic deposit-manipulate-withdraw flows.
- Re-entrancy guards on all state-mutating functions.
Flash loans are not the problem. Designs that assume "attackers can't have much capital" are the problem. A well-designed protocol is flash-loan-resistant by construction; a poorly-designed one will get drained as soon as anyone bothers to fund the attack.