Back to Blog

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

  1. Flash loan a large amount of Token A
  2. Swap Token A for Token B on a DEX (crashing A’s spot price)
  3. Target protocol reads the manipulated spot price
  4. Exploit the protocol based on wrong price (borrow more, liquidate unfairly, etc.)
  5. Reverse the swap
  6. Repay flash loan with profit

Real Example: Harvest Finance ($34M)

The attacker repeatedly:

  1. Flash loaned USDC/USDT
  2. Swapped to manipulate Curve pool prices
  3. Deposited into Harvest at the manipulated (favorable) price
  4. Reversed swap, withdrew at the new (also favorable) price
  5. 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

  1. Flash loan governance tokens
  2. Create malicious proposal (drain treasury, change parameters, etc.)
  3. Vote with borrowed tokens
  4. Execute proposal immediately
  5. Repay flash loan

Real Example: Beanstalk ($182M)

The attacker:

  1. Flash loaned billions in stablecoins
  2. Converted to BEAN tokens and Silo deposits
  3. Called emergencyCommit() on a pre-planted malicious proposal
  4. Drained the entire treasury
  5. 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)

  1. Deposit a tiny amount (1 wei) as the first depositor
  2. “Donate” a large amount directly to the vault (not through deposit)
  3. The share price is now massively inflated
  4. Next depositor’s large deposit receives almost no shares
  5. 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

  1. Flash loan assets from a liquidity pool
  2. The pool now has different reserves/ratios
  3. Target protocol reads the manipulated pool state
  4. Exploit based on wrong assumptions
  5. 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

  1. Flash loans enable unlimited capital for single transactions—design accordingly
  2. Never use spot prices for critical calculations—always TWAP or Chainlink
  3. Governance must have time delays—instant execution is instant exploitation
  4. Vault share calculations need virtual offsets—prevent first-depositor attacks
  5. Multi-block settlement prevents single-transaction manipulation
  6. 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.