Back

SC07: Flash Loan Attacks - Prevention

Prevention Strategy Overview

You cannot reliably stop attackers from taking flash loans, and trying to is the wrong target. The durable defence is to make your protocol indifferent to how much capital any actor holds for one transaction:

  1. Design every mechanism assuming an attacker has effectively unlimited capital for a single transaction.
  2. Never trust a value that can be moved within one block—spot prices, live balances, instantaneous share ratios.
  3. Use time-resistant sources: TWAP / robust oracles for prices, past-block snapshots for votes.
  4. Add reentrancy guards, CEI ordering, deviation bounds, and circuit breakers.
  5. Assume attempts are free and constant—so any gap will be found.

Core Principles

1. Never Trust Spot Price — Use TWAP / Robust Oracles

The single most important defence. A spot price read from one pool's current reserves is trivially skewed by a flash swap. A time-weighted average price (TWAP) forces an attacker to hold the manipulation across many blocks—which a flash loan cannot do—and a robust oracle aggregates multiple independent sources. (See SC02 for full oracle design.)

// VULNERABLE: instantaneous spot price
function getPrice() external view returns (uint) {
    return pool.reserveQuote() * 1e18 / pool.reserveBase();  // one flash swap moves this
}

// HARDENED: time-weighted average, immune to single-tx skew
function getTwapPrice(uint32 window) external view returns (uint) {
    // sample the pool's cumulative price at now and (now - window),
    // divide the delta by the window -> average price over `window` seconds.
    // A flash loan cannot move an average sustained across many blocks.
    return _consultTwap(window);   // e.g. 30-minute window
}

// BEST: cross-check an aggregated feed against the TWAP and reject on divergence.

2. Governance: Snapshot Balances from a Past Block + Timelocks

Voting power must be read from a checkpoint at a block in the past (the proposal's snapshot block), not from the live balance. A flash borrower holds tokens only in the current transaction, so a past-block snapshot gives them zero weight. A timelock on execution further guarantees a proposal cannot be voted and executed in the same transaction.

// VULNERABLE: live balance can be flash-inflated
uint weight = govToken.balanceOf(msg.sender);

// HARDENED: weight from a snapshot taken when the proposal was created
uint weight = govToken.getPastVotes(msg.sender, proposal.snapshotBlock);
//            ^ ERC20Votes-style checkpoint from a PAST block -> flash balance = 0

// AND separate voting from execution with a delay:
//   propose() -> snapshot taken
//   vote()    -> weighted by past-block balances only
//   queue()   -> enters timelock (e.g. 48h)
//   execute() -> only after the delay, in a LATER transaction

3. Reentrancy Guards and Checks-Effects-Interactions

Flash capital multiplies what a reentrancy bug can steal. Update internal state before any external call, and add a guard so a function cannot be re-entered. (See SC05.)

// HARDENED withdrawal: effects before interactions + nonReentrant
mapping(address => uint) balances;
bool private locked;

modifier nonReentrant() {
    require(!locked, "reentrant");
    locked = true;
    _;
    locked = false;
}

function withdraw(uint amount) external nonReentrant {
    require(balances[msg.sender] >= amount, "insufficient");
    balances[msg.sender] -= amount;                 // EFFECT first
    (bool ok, ) = msg.sender.call{value: amount}(""); // INTERACTION last
    require(ok, "transfer failed");
}

4. Avoid Logic That Large Atomic Swaps Can Game

Any formula that scales with a manipulable balance or ratio must be stress-tested at extreme inputs. Common hardening: initialise pools with a minimum liquidity / dead shares to defeat first-depositor inflation, round in the protocol's favour, and never derive value from an instantaneous, attacker-supplied balance.

// Defuse first-depositor share inflation by seeding dead shares at init:
uint constant MINIMUM_LIQUIDITY = 1e3;
function initialize() internal {
    _mint(address(0xdead), MINIMUM_LIQUIDITY);   // shares that can never be redeemed
}

// Round shares DOWN on mint and assets DOWN on withdraw so dust never favours caller.
// Derive share price from tracked internal accounting, not from token.balanceOf(this).

5. Deviation Bounds and Sanity Checks

Reject prices and state transitions that move more than a plausible amount in a single update. An impossible swing is almost always manipulation.

// Reject a new price that deviates too far from the trusted reference:
function requireSanePrice(uint newPrice, uint referencePrice) internal pure {
    uint maxDeviationBps = 200; // 2%
    uint diff = newPrice > referencePrice ? newPrice - referencePrice
                                          : referencePrice - newPrice;
    require(diff * 10_000 / referencePrice <= maxDeviationBps, "price deviation too large");
}

6. Circuit Breakers and Per-Block Action Limits

Cap the value that can move through sensitive paths, and allow a guardian (or automated trigger) to pause on anomaly. Rate-limiting per block blunts single-transaction drains even if another control fails.

// Pausable + per-block outflow cap
bool public paused;
uint public constant MAX_OUTFLOW_PER_BLOCK = 1_000_000e18;
mapping(uint => uint) outflowInBlock;

modifier whenNotPaused() { require(!paused, "paused"); _; }

function _accountOutflow(uint amount) internal {
    outflowInBlock[block.number] += amount;
    require(outflowInBlock[block.number] <= MAX_OUTFLOW_PER_BLOCK, "block outflow cap");
}

7. Break Single-Transaction Composability Where It Matters

For the most sensitive actions, require that deposit and a value-bearing action do not both settle in the same transaction/block. A minimum holding period or a commit–reveal step means an attacker cannot borrow, act, and repay atomically.

// Enforce a minimum holding period before a deposit can be used to withdraw/vote:
mapping(address => uint) firstDepositBlock;

function deposit(uint amount) external {
    if (firstDepositBlock[msg.sender] == 0) firstDepositBlock[msg.sender] = block.number;
    // ... credit deposit ...
}

function actOnDeposit() external {
    require(block.number > firstDepositBlock[msg.sender], "same-block action blocked");
    // ... value-bearing action ...
}

8. Testing and Monitoring

// Foundry-style sketch: prove a mechanism survives attacker-scale capital.
function test_cannotDrainWithFlashCapital() public {
    uint huge = 100_000_000e18;
    deal(address(token), address(attacker), huge);   // simulate flash capital
    vm.expectRevert();                               // manipulation must NOT profit
    attacker.attack(huge);
    assertEq(vault.totalAssets(), initialAssets);    // invariant: reserves intact
}

Defence-to-Weakness Mapping

Amplified WeaknessPrimary DefenceRelated Category
Spot-price oracle skewTWAP / aggregated robust oracle + deviation boundsSC02
Balance-based votingPast-block snapshot balances + execution timelockGovernance
Share / accounting errorDead shares, round-in-protocol-favour, internal accountingSC03
Reentrancy drainCEI ordering + nonReentrant guardSC05
Liquidation gamingManipulation-resistant price for health checksSC02
Single-tx atomic drainCircuit breakers, per-block caps, holding periodsSC07

Key Takeaways

  1. Don't fight the loan, fix the trust — make the protocol indifferent to any actor's momentary capital.
  2. TWAP and robust oracles — never price off a single-block spot value.
  3. Snapshot votes from the past + timelock — a flash-borrowed balance must count for zero.
  4. Guards, bounds, and breakers — CEI, reentrancy guards, deviation checks, and per-block caps catch what slips through.
  5. Test at attacker scale — assume unlimited one-tx capital in every test, because attackers will.

Next Steps