TL;DR: The 2020 bZx flash loan incidents are a perfect autopsy of DeFi’s early assumptions. The first hit abused slippage to distort Uniswap prices that bZx implicitly trusted; the second was a straight oracle manipulation that let the attacker borrow against inflated collateral. In both cases the logic “spot price is fine, right now” was the footgun. This post breaks down the mechanics and turns them into a developer checklist you can actually ship.
What you’ll learn
- How flash loans really work, transaction-by-transaction
- The two bZx attacks, with step-by-step traces
- Why single-block spot pricing is tinder around a match
- Battle-tested mitigation patterns: oracles, circuit breakers, caps, and tests
- A minimal Solidity oracle wrapper with staleness checks and TWAP fallback
- A Foundry setup to simulate price manipulation and assert invariants
Flash loans in one breath
A flash loan is an uncollateralised borrow you must repay before the transaction ends. If any step can’t repay, the whole tx reverts. That property makes flash loans great capital multipliers for arbitrage and, if you design carelessly, for turning your protocol’s assumptions into attacker profit.
I like to mentally model them as: borrow() → perform complex sequence → repay+fee() → success
. There’s no “loan duration” beyond the single transaction. That’s important when we talk about oracle freshness and single-block market impact.
bZx, 2020: two different failures
Incident A: Slippage-fueled chaos, not a pure oracle bug (Feb 14–15, 2020)
High level:
- Attacker flash-borrows 10k ETH.
- Uses part to borrow WBTC elsewhere and part to open a leveraged WBTC/ETH position on bZx (Fulcrum).
- bZx sources a big WBTC buy through Kyber, which routes to Uniswap where liquidity is thin. That single trade spikes WBTC price on Uniswap.
- Attacker dumps previously borrowed WBTC back into the now-inflated Uniswap pool, cashing out more ETH.
- Repays the flash loan and keeps the spread. bZx eats the loss created by buying at a terrible price.
The subtlety: Uniswap’s spot price inside one block is cheap to move if the pool is shallow. bZx’s execution path effectively “accepted whatever the AMM quoted right now” with no circuit breaker or sanity check.
Incident B: Classic oracle manipulation with synthetic USD (Feb 18, 2020)
A few days later, the attacker:
- Flash-borrows ETH, accumulates sUSD.
- Pumps sUSD’s price via the same integrated route bZx used for pricing.
- Deposits the pumped sUSD as collateral on bZx and borrows ETH against it.
- Repays loan, pockets the delta. The price feed said sUSD was worth more than it really was.
Same root cause family, different angle: trusting a manipulable, immediate on-chain spot without guards.
Reality check: flash loans didn’t “break.” They simply provided cheap, temporary capital to exploit economic assumptions.
Why it worked: the assumptions that burned bZx
- Spot oracles are safe: They aren’t. If your price source can be meaningfully moved within a single transaction, an attacker can move it just before you read it.
- Liquidity is deep enough: It often isn’t. One path with thin reserves becomes your attack surface.
- Atomic composability is only good: It cuts both ways. The attacker chains borrow → manipulate → borrow-again → repay, all in one atomic bundle.
- No caps, no brakes: Unlimited per-tx size, no rate limits, and no pause conditions give attackers infinite canvas.
Design patterns that actually help
1) Don’t trust a single spot price
- Prefer decentralised oracle networks for primary prices (e.g. Chainlink Data Feeds) and treat on-chain DEX prices as a secondary or sanity check.
- When you must use AMM data, use time-weighted averages (TWAP) over meaningful windows, not the instantaneous price. Short windows are easier to bully.
- Combine feeds: primary oracle + Uniswap v3 TWAP as a cross-check. If they diverge past a threshold, halt or lower LTVs.
2) Add circuit breakers and stale checks
- Reject prices older than a heartbeat, or those that moved beyond a max basis-point change per block.
- If an asset’s liquidity is thin or volatility spikes, dynamically scale down LTV and cap borrow size.
- Implement a kill switch: pause sensitive functions if price conditions look off.
3) Rate limits and caps
- Per-block and per-transaction caps on borrows, mints, redemptions and liquidations.
- Notional exposure caps per asset. Don’t let a single trade force your protocol to cross a predefined risk envelope.
4) Slippage-aware execution
- If your protocol routes trades to DEXes, always set max slippage. Revert when the quote deviates too far from the oracle.
- Prefer aggregators that support split routing and include their own guardrails.
5) Architecture safety belts
- CEI pattern,
ReentrancyGuard
, pull payments, no unbounded external calls. - Least privilege with
Ownable
/AccessControl
. Separate roles for pausing, risk param updates and treasury. - L2s: include sequencer uptime checks and delayed price acceptance when sequencers recover.
A minimal oracle wrapper you can actually drop in
Below is a practical pattern I use: a primary Chainlink feed with staleness checks, plus an optional Uniswap v3 TWAP sanity check and a circuit breaker. The goal is to make it painful to pass a manipulated price through your risk engine.
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.24;
import {AggregatorV3Interface} from "@chainlink/contracts/src/v0.8/interfaces/AggregatorV3Interface.sol";
import {IERC20} from "@openzeppelin/contracts/token/ERC20/IERC20.sol";
import {ReentrancyGuard} from "@openzeppelin/contracts/security/ReentrancyGuard.sol";
interface IUniV3TwapOracle {
// Return price with 8 decimals, reverts on bad window/observation
function consult(address tokenIn, uint32 secondsAgo) external view returns (int256 priceX8);
}
contract RobustOracle is ReentrancyGuard {
AggregatorV3Interface public immutable chainlink;
IUniV3TwapOracle public immutable uniTwap;
uint256 public immutable staleAfter; // seconds
uint256 public immutable maxBpsDeviation; // e.g. 500 = 5%
uint32 public immutable twapWindow; // seconds
bool public immutable useTwapGuard;
error StaleOracle();
error DeviatesTooMuch();
constructor(
address _cl,
address _twap,
uint256 _staleAfter,
uint256 _maxBpsDeviation,
uint32 _twapWindow,
bool _useTwapGuard
) {
chainlink = AggregatorV3Interface(_cl);
uniTwap = IUniV3TwapOracle(_twap);
staleAfter = _staleAfter;
maxBpsDeviation = _maxBpsDeviation;
twapWindow = _twapWindow;
useTwapGuard = _useTwapGuard;
}
function latestPriceX8() public view returns (int256 px) {
( , int256 answer, , uint256 updatedAt, ) = chainlink.latestRoundData();
if (block.timestamp - updatedAt > staleAfter) revert StaleOracle();
px = answer; // assume 8 decimals feed, normalise per asset in production
if (useTwapGuard) {
int256 twapPx = uniTwap.consult(address(0), twapWindow); // token routing baked in oracle
uint256 a = uint256(px > 0 ? px : -px);
uint256 b = uint256(twapPx > 0 ? twapPx : -twapPx);
uint256 devBps = a > b ? (a - b) * 10_000 / a : (b - a) * 10_000 / b;
if (devBps > maxBpsDeviation) revert DeviatesTooMuch();
}
}
}
Notes
- In production, normalise decimals per asset and route the Uniswap consult through a fixed pool path. Don’t accept arbitrary pairs from callers.
- For L2s, add a sequencer-up check and reject prices until grace time passes after sequencer resumes.
Making trades slippage-aware inside your protocol
If your logic triggers an on-chain swap, guard it.
function _swapWithLimit(address router, bytes memory route, uint256 amountIn, uint256 minOut) internal returns (uint256 out) {
uint256 quote = _oracleQuote(amountIn); // from RobustOracle or similar
// Require minOut to be within a tight band of the oracle quote
uint256 minBand = (quote * 98) / 100; // 2% band example; tune per asset
require(minOut >= minBand, "minOut too low vs oracle");
// then perform the aggregator swap and return amountOut
// revert if amountOut < minOut
}
This simple check blocks the “buy at any price” behaviour that sunk bZx.
Simulating the bZx pattern with Foundry
You don’t need mainnet millions to test this. You can script a cheap local reproduction and assert your protocol refuses the attack path.
1) Scaffolding
forge init bzx-lab && cd bzx-lab
forge install foundry-rs/forge-std@v1.9.6 openzeppelin/openzeppelin-contracts@v5.1.0 --no-commit
2) A price-manipulation helper
Write a helper contract that swaps against your local AMM mock to force a spot to move, then immediately calls the lending function you’re testing. Your invariant: protocol equity cannot decrease from a single-block spot dislocation.
contract PricePusher {
IRouter public router; IMargin public margin;
constructor(IRouter r, IMargin m){router=r;margin=m;}
function nukeThenBorrow(uint256 amountIn, uint256 minOut) external {
router.swapExactTokensForTokens(amountIn, minOut, /* path */);
margin.openLeveraged(/* uses oracle & swap under the hood */);
}
}
3) Invariant test sketch
contract Invariants is Test {
RobustOracle oracle; IMargin margin; PricePusher pusher;
uint256 public equityBaseline;
function setUp() public {
// deploy oracle, margin, router mocks, seed pools
equityBaseline = margin.totalAssets() - margin.totalLiabilities();
pusher = new PricePusher(router, margin);
}
function invariant_equity_never_drops_on_single_block_push() public {
uint256 equity = margin.totalAssets() - margin.totalLiabilities();
assertGe(equity, equityBaseline);
}
}
Then run forge test --ffi --invariant runs=256
and iterate until the invariant holds under a variety of push sizes.
Production checklist
- Use a primary oracle with staleness and heartbeat checks
- Cross-check with an on-chain TWAP and reject large divergences
- Cap per-tx and per-block exposure for borrows/mints/liquidations
- Slippage limits on any swap your protocol performs
- Dynamic LTVs and per-asset risk knobs
- Kill-switch: pausable critical functions
- CEI + ReentrancyGuard, least privilege, upgrade hygiene
- Invariant tests that model multi-leg attacks in one tx
- Static analysis in CI (Slither) + fuzzing (Foundry/Echidna)
- Runbook for incident response and a funded risk/insurance module
Lessons I keep coming back to
- Flash loans aren’t evil. They just remove capital as a friction. If your design assumes “no one can afford to do X quickly,” that assumption is already dead.
- On-chain spot is a market quote, not truth. Treat it with suspicion, smooth it over time, and compare against independent sources.
- Make it expensive to lie to you. The attacker should have to move multiple venues, multiple blocks, and still fail a sanity check.
- Simulate the ugly path. If your tests don’t mimic the exact sequence an attacker would chain together, you’re testing vibes, not risk.