Case Study — bZx Price Manipulation (Feb 15 & Feb 18, 2020)

“There were two bZx attacks, five days apart, and they were not the same attack. The first was a margin-protocol bug — bZx’s own slippage check was bypassed, and the WBTC oracle drift was a side-effect of the attacker tilting Uniswap to make the position pay. The second was the real oracle-manipulation attack — bZx asked Kyber ‘what is sUSD worth?’, and the attacker had spent ~900 ETH making sure Kyber’s answer was nonsense. Auditors should read them in that order: the first taught the industry that flash loans give every actor in a single block a temporary balance sheet bigger than most banks. The second taught the industry that a spot price is not a price.”

“samczsun published ‘Taking undercollateralized loans for fun and for profit’ in September 2019. It described, in painful detail, the exact pattern that drained bZx in February. bZx had read it. They had had the code audited. They thought they had fixed it. The five-day re-attack is the lesson: oracle-manipulation is not a single bug — it is a design posture.” — Auditor’s gloss, retrospective.

Tags: web3-security defi case-study oracle-manipulation flash-loan lending margin spot-price twap historical Phase: 3 — Protocol & Economic Security Anchor lesson: Tuan-09-Oracle-MEV-Economic-Attack §3 (oracle taxonomy), §5 (Uniswap TWAP), §6 (oracle-manipulation case archetype) Related cases: Case-Harvest-Finance-2020 (same template, four-pool curve manipulation) · Case-Mango-Markets-2022 (single-pool collateral pricing, same shape) · Case-Euler-Finance-2023 (flash-loaned donation rather than flash-loaned price) · Case-Cream-Iron-Bank-2021 (flash-loan + price manipulation hybrid) Vault context: this is the canonical “spot-price oracle + flash loan” case. Read it after Tuan-09-Oracle-MEV-Economic-Attack §3 and before any of the post-2020 oracle cases — bZx is the template that the next 40 oracle exploits copied.


1. At a Glance

FieldValue
DatesAttack 1: Feb 15, 2020, 01:38:57 UTC (block 9,484,688). Attack 2: Feb 18, 2020, 03:13:58 UTC (block 9,504,627). Five days apart.
ProtocolbZx — a margin-trading and lending protocol with two consumer-facing products: Fulcrum (tokenized lending/leveraged positions via iTokens) and Torque (fixed-rate borrowing).
Loss (Attack 1)~**620k equity loss to bZx (the bZx margin position itself was left underwater) [verify exact split — figures vary across PeckShield / Quantstamp / palkeo; ETH price ~$280 at the time]
Loss (Attack 2)~**633k; PeckShield ~$665k; depends on ETH-price snapshot]
Loss (combined)~1M in attacker profit; iETH liquidity-pool insurance absorbed the loss for users. No user lost principal — bZx’s insurance fund (and later token-holder governance) covered the shortfall.
Attack class (1)Margin-protocol slippage-check bypass + opportunistic Uniswap-price manipulation via the attacker’s own bZx-routed swap. Flash-loan capital from dYdX. Primarily a bZx logic bug, secondarily an oracle issue.
Attack class (2)Pure spot-oracle manipulation — bZx priced sUSD collateral against Kyber’s reserves, and the attacker bought sUSD across Kyber’s reserves to inflate that price by ~2.5×. Flash-loan capital from bZx itself.
Flash-loan sourceAttack 1: dYdX (10,000 ETH). Attack 2: bZx itself (7,500 ETH — bZx had launched its own flash-loan endpoint that was used to attack bZx).
Attacker fundingFunded via Tornado Cash shortly before each attack. Starting EOA capital was negligible (a few ETH for gas) — the flash loans provided the capital, the bugs provided the profit.
Attacker addressesEOA: 0x148426fDC4C8a51b96b4BEd827907b5FA6491aD0 (“bZx Exploiter 1”). Attack contract: 0x4f4e0f2cb72E718fC0433222768c57e823162152. [verify — addresses for Attack 2 differ; documented in PeckShield Medium write-ups and Etherscan labels]
Tx hashesAttack 1: 0xb5c8bd9430b6cc87a0e2fe110ece6bf527fa4f170a4bc8cd032f768fc5219838. Attack 2: 0x762881b07feb63c436dee38edd4ff1f7a74c33091e534af56c9f7d49b5ecac15.
Audits priorbZx was audited by ZK Labs (and others — exact list across 2019/2020 not fully reconstructed publicly). samczsun had publicly disclosed the underlying pattern in September 2019, five months before the attack. The bZx team believed the issue was patched. [verify audit firm list against bZx blog / GitHub]
PatchAttack 1 fix: closed the slippage-bypass condition (loanDataBytes.length == 0 && sentAmounts[6] == sentAmounts[1]) in takeOrderFromiToken. Attack 2 fix: switched price source from Kyber-spot to Chainlink for sUSD and other manipulable assets. Long-term: bZx moved most price feeds to Chainlink.
OutcomebZx paused the protocol after both attacks, covered the shortfall from the insurance fund + BZRX treasury, no user lost principal. Continued operating through 2020 and 2021 (with further incidents — see §6.3). Today the protocol is largely defunct; the brand and token persist but TVL is negligible.
Industry significanceFounding case of the “DeFi flash-loan-oracle-manipulation” attack class. Every subsequent oracle exploit (Harvest, Cheese Bank, Origin Dollar, Warp, Cream, Mango, etc.) inherits bZx’s structural template. The phrase “flash loan attack” enters mainstream crypto vocabulary because of these two transactions.

Both bugs in one sentence each. Attack 1: bZx’s slippage check on takeOrderFromiToken short-circuited to true when the trade routed through a single oracle path, so a 5× leveraged short executed at a 3× off-market price was treated as healthy. Attack 2: bZx used the Kyber spot price as its sUSD oracle without a manipulation-resistant transform, and a 900-ETH sweep of Kyber’s sUSD reserves inflated that price by ~2.5× — turning ~1.7M of bZx-recognized collateral.


2. Background — What bZx Was

2.1 The product

bZx, founded by Tom Bean and Kyle Kistner in 2017, was one of the earliest non-custodial margin protocols on Ethereum. By early 2020 it had two main consumer-facing products:

  • Fulcrum — tokenized lending and leveraged positions. Lenders deposited assets (ETH, DAI, USDC, WBTC, sUSD, …) and received iTokens representing their share of the lending pool with accumulated interest. Borrowers could open positions tokens like sETHwBTCx5 (a 5× short ETH against wBTC, denominated as a tradeable ERC-20).
  • Torque — fixed-rate, fixed-collateral borrowing for retail users who wanted a “Compound-but-with-a-rate-you-can-quote”.

TVL at the time of the first attack was roughly 40M [verify against DeFi Pulse snapshot for Feb 14, 2020]. Small by post-2021 standards, but bZx was a top-15 DeFi protocol in early 2020 and a flagship of the “DeFi Summer” buildup.

2.2 How a leveraged short worked on Fulcrum

To open a 5× WBTC short with 1,300 ETH of collateral:

  1. User deposits 1,300 ETH into the position-token contract (sETHwBTCx5).
  2. The contract borrows an additional 5,200 ETH (≈4× the deposit) from the iETH lending pool, giving 6,500 ETH of total exposure.
  3. The contract executes a swap on a DEX to convert ETH → WBTC, expecting the swap to clear at near-market rate.
  4. The resulting WBTC is held by the position-token contract as the short’s “owed asset” — closing the position will swap WBTC back to ETH and return the borrowed ETH to the lending pool, with the residual being the user’s PnL.
  5. The position’s health (whether it’s solvent enough to remain open) is computed against the oracle’s view of WBTC/ETH price.

Three things to notice for the audit:

  • The swap (step 3) is executed by bZx itself. The protocol routes the trade through an aggregator (originally Kyber, which in turn quoted Uniswap V1 reserves for WBTC/ETH because Uniswap was Kyber’s deepest reserve for that pair). The protocol is the trader — which means the protocol’s own swap can move the price before the protocol’s own health check reads that price.
  • The slippage check that protects against exactly this — a swap that fills at a much worse rate than the oracle quotes — was the bug.
  • The oracle and the execution venue are the same. When the swap routes through Kyber→Uniswap, and the price check also looks at Kyber→Uniswap, the protocol is asking the question it just answered. This is the structural error.

2.3 The oracle bZx used (and why it was fragile)

bZx’s BZxOracle contract priced collateral by calling Kyber’s getExpectedRate(srcToken, destToken, srcAmount). Kyber routed the query through its reserve aggregator, which — for most ERC-20s in early 2020 — meant a Uniswap V1 reserve. Uniswap V1’s price was, by definition, reserve_quote / reserve_base at the moment of the query.

This is a spot price. It changes the instant a swap clears in the pool. If you can clear a big-enough swap in the same transaction as the oracle read — and a flash loan gives you that capability — you can manipulate the answer to any number you want, subject only to the depth of the underlying reserve.

The Uniswap V1 WBTC/ETH pool in February 2020 had roughly 2,300 WBTC and ~6,000 ETH of liquidity [verify Etherscan history]. A short opened on bZx that pushed ~5,600 ETH into that pool via the bZx-Kyber-Uniswap path tilted the constant-product curve hard enough to move the spot price by >2×. That’s the leverage that turned a 300k+ profit opportunity.

Same shape for sUSD on Attack 2: Kyber’s sUSD reserves were thin enough that ~900 ETH of buys (across multiple Kyber reserves, see §4.2) moved the sUSD/ETH price 2.5× off-market.

2.4 Why bZx is a “before” picture

Pre-bZx (Feb 2020), the prevailing assumption in DeFi was:

  • “On-chain DEXes are decentralized; their prices are ‘real’ market prices.”
  • “Anyone using an on-chain DEX has skin in the game (LP capital, slippage cost), so manipulation has a natural cost.”
  • “Flash loans are a curiosity for arbitrage bots.”

bZx demolished all three. The post-bZx consensus is:

  • A spot price from any AMM is a quote, not a price — it is whatever the last swap set it to, including a swap the attacker controls.
  • Flash loans eliminate the capital constraint on price manipulation. The attacker doesn’t need to fund $1M of trade; they borrow it for ten thousand gas, manipulate, profit, repay — all atomically.
  • Any “natural cost” the protocol imagined for manipulation can be bounded by the profit the manipulator extracts elsewhere. If the manipulation costs 300k drain, the cost is no longer a deterrent.

Every audit checklist written after February 2020 treats “spot price oracle on AMM” as a high-severity finding by default. The default before bZx was “low — it’s decentralized”. The default after bZx is “critical — show me how you mitigate this”.


3. The Vulnerabilities

3.1 Attack 1 — the slippage-bypass bug in takeOrderFromiToken

The vulnerable function lived in bZx’s LoanTokenLogic contract (the iToken implementation). Simplified to the relevant predicate:

// bZx LoanTokenLogic (simplified, paraphrased — the exact field layout
// uses a fixed-size sentAmounts[] array; see palkeo's writeup for the
// concrete indices).
function takeOrderFromiToken(
    bytes orderData,
    address[] orderAddresses,
    uint256[] sentAmounts,    // [0]=loanTokenSent, [1]=collateralTokenSent,
                              // [2]=tradeTokenSent, ..., [6]=collateralTokenAmount
    bytes loanDataBytes
) internal returns (...) {
 
    // ... open the loan, transfer collateral in, execute the swap ...
 
    require(
        (loanDataBytes.length == 0 && sentAmounts[6] == sentAmounts[1])
        || !OracleInterface(oracle).shouldLiquidate(loanOrderHash, ...),
        "unhealthy position"
    );
 
    // ... finalize ...
}

Read the require:

“Either the trade routed via the default path with matching amounts, OR the position is healthy according to the oracle.”

That || is the bug. The first condition is a configuration check, not a solvency check. It says “this is a vanilla single-hop trade with no custom data” — which the attacker can trivially make true by sending the trade through the default Kyber path with matching sentAmounts[1] and sentAmounts[6]. The OR-clause then short-circuits past the oracle’s shouldLiquidate call, which would have refused to open a position that the swap had just rendered insolvent.

This is the single line that turned the protocol from “vulnerable to outside oracle manipulation” into “vulnerable to its own swap moving its own price”. With the slippage check intact, opening a 5× short that filled at 3× off-market would have reverted on shouldLiquidate. With the slippage check bypassed, the position was allowed to open even though it was instantly underwater — and the underwater portion was bZx’s loss, not the attacker’s.

Audit takeaway: || in a require is a code smell. The reader has to prove that neither sub-condition is a configurable bypass of the security property the require is enforcing. Here, (loanDataBytes.length == 0 && sentAmounts[6] == sentAmounts[1]) had nothing to do with solvency — but it was OR’d against the solvency check. The function says “I will enforce X or skip-X-if-you-look-like-the-default-path”. That’s not enforcement.

3.2 Attack 1 — the secondary role of oracle manipulation

It’s worth being precise: Attack 1 was primarily a bZx slippage-check bug. The attacker did manipulate Uniswap’s WBTC/ETH price (a side-effect of the bZx-routed 5,600-ETH push into that pool), but they didn’t need bZx to read that manipulated price for the attack to pay.

The manipulation paid the attacker via a different mechanism: the 112 WBTC they had borrowed from Compound before opening the bZx short was now sold into a Uniswap pool whose price had been pushed 1.6× off-market by the bZx swap. Selling 112 WBTC at that inflated rate returned 6,871 ETH — substantially more than the ~4,300 ETH the WBTC would have been worth at the pre-attack rate.

The bZx-side loss (the under-collateralized short left underwater) was the funding source for the price manipulation; the Compound-side dump was the profit extraction. The slippage bug let the bZx position open at a price that bZx could not afford. The Uniswap thinness let the price move enough to pay the rest.

If you want the cleanest mental model: Attack 1 used bZx as an over-paying counterparty, then sold the now-inflated asset elsewhere. Attack 2 (next section) is the cleaner oracle-manipulation case.

3.3 Attack 2 — the spot-oracle manipulation on sUSD

Attack 2 is the textbook “flash-loan-oracle-manipulation” attack. The bZx BZxOracle priced sUSD collateral via Kyber. Roughly:

// BZxOracle.getCurrentMarginAmount, simplified to the relevant rate fetch
(uint expectedRate, ) = kyberNetwork.getExpectedRate(sUSD, WETH, sUSDAmount);
uint collateralEthValue = (sUSDAmount * expectedRate) / 1e18;
 
// Then collateralEthValue is compared against the borrow size + minMargin
// to decide whether to authorize the loan.

The bug is not in the code, it’s in the trust model:

  • kyberNetwork.getExpectedRate is a spot quote, computed against Kyber’s current reserve balances.
  • Kyber’s reserves for sUSD/ETH in February 2020 were thin: a Uniswap-backed reserve plus a Synthetix-direct reserve, both shallow enough that a few hundred ETH of buys would move the spot price by 2×+.
  • getExpectedRate has no memory. It does not blend across time, does not require multiple reporters, does not check against any oracle of last resort. It answers exactly the question “what does the next swap clear at?”
  • That answer, fed to bZx’s collateralization check, trusts the attacker’s just-completed swap to be representative of fair value.

The attacker’s job is therefore mechanical: (a) tilt Kyber’s sUSD reserves with an affordable amount of capital, (b) deposit sUSD into bZx, (c) borrow against the inflated valuation, (d) repay the capital used in (a), (e) pocket the difference. Flash loans make (a) free of capital cost — the attacker borrowed 7,500 ETH from bZx’s own flash-loan endpoint to fund the entire choreography.

Audit takeaway: when reading any DeFi codebase, the minute you see getExpectedRate, latestAnswer on a non-Chainlink feed, or any single-source spot read used for collateralization, you flag it. The fix is never “the manipulation is too expensive” — flash loans removed that defense.

3.4 The trust assumption that failed (both attacks)

bZx’s implicit model, articulated in their public statements before the attacks:

“We rely on Kyber. Kyber’s reserves come from many sources. Manipulating Kyber requires moving the underlying reserves, which is expensive.”

What was missing:

  • “Expensive” assumes the attacker is paying with their own capital. Flash loans break this.
  • “Many sources” is meaningless if all sources are AMMs with the same shallow pools. Kyber’s sUSD/ETH reserves were dominated by a Uniswap-derived reserve and a Synthetix-direct reserve. Both were AMMs, both were thin. “Many sources” turned out to be “many views of the same shallow pool”.
  • bZx and Kyber were composable, but had different threat models. Kyber’s job was to execute a trade at the current marginal price. bZx’s job was to value collateral at fair market price. These are not the same number when the marginal trade is large relative to pool depth. samczsun’s 2019 disclosure said this explicitly: “an accurate rate for a DEX means a trade can be made using that rate, while an accurate rate for DeFi means it’s close to fair market value.”

3.5 Why this bug class is structural, not local

You cannot patch this with a smaller diff than “change the oracle architecture”. Specifically:

  • Slippage caps on swaps don’t help — the attacker is happy to pay the slippage; it’s part of their cost basis.
  • Single-block sanity checks (“did the price change >X% from last block?”) don’t help — the manipulation happens in the block; the last-block snapshot doesn’t include it.
  • “Increase pool depth” doesn’t help — pool depth is determined by market participants, not the protocol; and a richer attacker just borrows more.
  • “Use multiple DEXes” only helps if those DEXes are independent (different LPs, different liquidity sources). Kyber-routed-via-Uniswap and Uniswap-direct are not independent.

The patches that do work (TWAP, Chainlink, multi-oracle median with deviation thresholds) all share one property: they make the price not be the spot price. Either it’s averaged across time (TWAP), aggregated across off-chain reporters (Chainlink), or required to match multiple independent sources within a tolerance (median).

This is the lesson of bZx. It is the reason TWAP exists as a primitive and Chainlink became the default DeFi oracle within twelve months of these attacks.


4. The Attacks, Step by Step

4.1 Attack 1 — Feb 15, 2020 (tx 0xb5c8…9838, block 9,484,688)

The attacker’s contract 0x4f4e0f2cb72E718fC0433222768c57e823162152 orchestrated five steps inside a single transaction. (Numbers below from PeckShield’s analysis; cross-checked against palkeo and SlowMist where they diverge it is noted.)

Step 1 — Flash-loan 10,000 ETH from dYdX.

dYdX.SoloMargin.operate(
    [accounts], [actions]
)
└── action: Withdraw 10,000 ETH to attackerContract
└── action: Call attackerContract.callFunction(...)   ← the attack body runs here
└── action: Deposit 10,000 ETH from attackerContract  ← repayment, checked at end

dYdX’s operate validates collateral after all actions complete, so the attacker controls 10,000 ETH for the duration of the body.

Step 2 — Open a Compound borrow: 5,500 ETH collateral → 112 WBTC debt.

attackerContract → Compound cETH.mint{value: 5,500 ETH}()
attackerContract → Comptroller.enterMarkets([cETH])
attackerContract → Compound cWBTC.borrow(112e8 WBTC)

After step 2: attacker holds 4,500 ETH (10k − 5.5k) + 112 WBTC. Compound position: +5,500 ETH collateral / −112 WBTC debt. WBTC is worth ~4,300 ETH at market, so the position is ~5,500 vs ~4,300 = healthy with margin to spare.

Step 3 — Open the 5× WBTC short on bZx, with the swap routed through Kyber → Uniswap.

attackerContract → bZx LoanToken (iETH).marginTrade(
    leverageAmount: 5x,
    loanTokenSent: 1,300 ETH,    // attacker's "deposit"
    collateralTokenSent: 0,
    tradeTokenSent: 0,
    trader: attackerContract,
    depositTokenAddress: WETH,
    collateralTokenAddress: WBTC,
    loanDataBytes: 0x            // ← length == 0 triggers the bypass!
)

Inside marginTrade:

  • bZx borrows ~5,200 ETH from the iETH lending pool, plus the attacker’s 1,300 ETH → 6,500 ETH of trade size.
  • bZx routes 5,637 ETH through Kyber, which routes through its Uniswap V1 reserve for WBTC/ETH.
  • The Uniswap V1 WBTC/ETH pool absorbs 5,637 ETH of WETH and pays out 51.35 WBTC — a rate of 1 WBTC = 110 ETH (vs market ~36).
  • The constant-product curve now reads WBTC/ETH at ~3× the pre-attack price.
  • bZx’s takeOrderFromiToken reaches the requireloanDataBytes.length == 0 && sentAmounts[6] == sentAmounts[1] evaluates true — the || short-circuits past shouldLiquidate. The position opens, deeply underwater.

After step 3: bZx’s sETHwBTCx5 token holds 51.35 WBTC against a 5,637 ETH borrow from the iETH pool. At pre-attack prices that’s ~1,850 ETH of WBTC against 5,637 ETH of debt — a ~3,800 ETH gap that bZx is on the hook for.

Step 4 — Dump the Compound-borrowed 112 WBTC into the now-tilted Uniswap pool.

attackerContract → Uniswap V1 WBTC/ETH exchange.tokenToEthSwapInput(112e8 WBTC, ...)
                                                                 ↑
                                          112 WBTC sold into a pool whose
                                          reserves were tilted by step 3

The pool returns 6,871 ETH for the 112 WBTC (vs ~4,300 ETH at pre-attack price). The dump partially un-tilts the pool — the final WBTC/ETH price ends up around 1/61 (vs pre-attack 1/36).

Step 5 — Repay the dYdX flash loan and pocket the residue.

attackerContract → dYdX repayment 10,000.000000000011 ETH

The attacker started with 0 ETH (functionally), ends with:

  • +71 ETH directly in the attack contract’s balance (arbitrage spread).
  • +a Compound position worth ~+5,500 ETH WETH collateral / −112 WBTC debt = roughly +$300k of net equity at then-current market prices.
  • −1,300 ETH of bZx margin position, but the position is held by sETHwBTCx5which is not the attacker’s address. The attacker walks away from the deposit; bZx absorbs the loss.

Total profit: 1,271 ETH (620k** of iETH-pool loss. bZx’s insurance fund and BZRX treasury covered this. [verify exact split — figures vary across writeups]

4.2 Attack 2 — Feb 18, 2020 (tx 0x7628…ac15, block 9,504,627)

Five days later, the same general theme — flash-loan → manipulate price → over-borrow against the manipulated price → repay — but this time the bug is the oracle itself, not the slippage check. The attacker used a different attack contract; flash-loan capital came from bZx’s own flash-loan endpoint that bZx had launched in the interim.

Step 1 — Flash-loan 7,500 ETH from bZx.

bZx had shipped a flash-loan endpoint sometime between attacks (timeline of this feature deserves [verify] against the bZx changelog). The attacker borrowed 7,500 ETH for the duration of a single transaction.

Step 2 — Acquire 943,837 sUSD at market from Synthetix Depot.

attackerContract → SynthetixDepot.exchangeEtherForSynths{value: 3,518 ETH}()
                  → returns 943,837 sUSD at the Depot's fixed rate (~268 sUSD/ETH)

The Synthetix Depot was Synthetix’s own ETH→sUSD vending machine, priced off Synthetix’s internal oracle. It cleared the trade at fair market rate. This step does not manipulate price — it is hoarding inventory at honest prices.

Step 3 — Manipulate Kyber’s sUSD/ETH spot price.

// First sweep: 540 ETH through Kyber's Uniswap-backed sUSD reserve
attackerContract → Kyber.swapEtherToToken{value: 540 ETH}(sUSD)
                  → 92,419 sUSD received; Uniswap sUSD/ETH reserve drained

// Second sweep: 18 batches of 20 ETH each through Kyber's sUSD reserve directly
for (i = 0; i < 18; i++) {
    attackerContract → Kyber.swapEtherToToken{value: 20 ETH}(sUSD)
}
                  → 63,584 sUSD total; the per-batch fragmentation drained
                    multiple sub-reserves rather than just one

// Net manipulation: Kyber's spot quote for sUSD/ETH goes from ~268 sUSD/ETH
// (fair) to ~111 sUSD/ETH (manipulated) — i.e. 1 sUSD now "costs" 2.5× more ETH

Why two sweeps? Kyber’s reserve aggregator splits a query across whichever reserves give the best rate. A single 900-ETH buy would have been routed across many reserves and clipped by Kyber’s per-reserve rate-limiters. By splitting into one 540-ETH Uniswap-leg buy and 18 separate 20-ETH buys, the attacker manipulated each reserve below Kyber’s auto-rerouting threshold, ensuring the next quote actually saw the tilted reserves.

After step 3: the attacker holds ~1,099,841 sUSD total (940k from Step 2 + ~156k from Step 3). Kyber’s spot quote for sUSD/ETH is now ~2.5× off-market.

Step 4 — Deposit the sUSD into bZx as collateral and borrow against the inflated valuation.

attackerContract → bZx LoanToken (iETH).borrowTokenFromDeposit(
    borrowAmount: 6,796 ETH,
    collateralTokenAddress: sUSD,
    depositAmount: 1,099,841 sUSD,
    ...
)

Inside borrowTokenFromDeposit, bZx calls BZxOracle.getCurrentMarginAmount, which calls Kyber’s getExpectedRate(sUSD, WETH, 1,099,841 sUSD). Kyber returns the manipulated rate: ~111 sUSD/ETH instead of ~268.

bZx computes:

  • “1,099,841 sUSD × 1 ETH / 111 sUSD = 9,908 ETH of collateral”
  • Required margin for 6,796 ETH borrow at ~50% LTV: ~3,398 ETH collateral
  • 9,908 ETH ≥ 3,398 ETH → loan approved.

The actual value of 1,099,841 sUSD at honest prices is ~4,100 ETH, so the loan is already underwater the instant it opens. But the slippage check (different from Attack 1’s bug — here the function actually does run the oracle check; the oracle is just wrong) sees a “healthy” 9,908 vs 6,796 and lets it through.

Step 5 — Repay the bZx flash loan; the attack contract exits with profit.

attackerContract → bZx flash-loan repayment: 7,500 ETH (from the 6,796 borrowed
                   plus the residual ETH left from prior steps)

Accounting at the end of the transaction:

  • Attacker spent: 3,518 ETH (Synthetix Depot) + 900 ETH (Kyber manipulation) + tiny gas = ~4,418 ETH out.
  • Attacker received: 6,796 ETH (the bZx loan, which they will not repay because the collateral is bZx’s problem now) = 6,796 ETH in.
  • Plus: 7,500 ETH flash loan in, 7,500 ETH flash loan out — net zero.
  • Net profit: 2,378 ETH ($645k).
  • bZx is left holding 1,099,841 sUSD (real value ~4,100 ETH) against a 6,796 ETH receivable that the attacker will never repay. bZx’s loss: ~2,700 ETH gap [verify; CoinDesk reported ~650k].

4.3 Why the bZx flash loan was poetic

Attack 1’s flash loan came from dYdX. Attack 2’s came from bZx itself. Between the two attacks, bZx had shipped a flash-loan endpoint as part of their general product expansion — and the attacker used that endpoint to fund the attack against bZx. The protocol financed its own exploit.

This is a generalizable lesson: any flash-loan venue increases the single-block balance sheet of every actor in the system, including attackers of the venue itself. When you ship a flash-loan endpoint, you are not just exposing your own contract to flash-loan attacks — you are increasing the capital available to every attacker of every protocol that integrates anywhere downstream of you. The threat model is global, not local.


5. Reproduction in Foundry

We will not reconstruct the full bZx-Kyber-Uniswap-Synthetix-Compound dance; the lesson lives in the primitive — a spot-oracle lending market plus a flash-loaned price manipulation. Below is a stripped Foundry harness that captures the structural bug and the structural exploit.

5.1 Mock primitives

// src/MockUniswapV2Pair.sol
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.20;
 
/// @notice Minimal CPMM (x*y=k) pool used as the "thin" reserve.
contract MockUniswapV2Pair {
    uint256 public reserveBase;   // e.g. sUSD-equivalent
    uint256 public reserveQuote;  // e.g. ETH-equivalent
 
    constructor(uint256 _rBase, uint256 _rQuote) {
        reserveBase = _rBase;
        reserveQuote = _rQuote;
    }
 
    /// @notice Sell `amountIn` of quote (ETH-equivalent) for base (sUSD).
    function swapQuoteForBase(uint256 amountIn) external returns (uint256 out) {
        uint256 k = reserveBase * reserveQuote;
        uint256 newRq = reserveQuote + amountIn;
        uint256 newRb = k / newRq;
        out = reserveBase - newRb;
        reserveBase = newRb;
        reserveQuote = newRq;
    }
 
    /// @notice Spot quote: base units per 1 quote unit (precision 1e18).
    function spotBasePerQuote() external view returns (uint256) {
        return (reserveBase * 1e18) / reserveQuote;
    }
}
// src/SpotOracle.sol — bZx-shaped oracle
contract SpotOracle {
    MockUniswapV2Pair public pair;
    constructor(MockUniswapV2Pair _pair) { pair = _pair; }
 
    /// @notice Returns base/quote ratio at THIS BLOCK'S marginal price.
    /// This is the bZx-bug shape: the read is `getExpectedRate` over the
    /// current reserves, with no smoothing.
    function basePerQuote() external view returns (uint256) {
        return pair.spotBasePerQuote();
    }
}
// src/VulnerableLendingMarket.sol — bZx-shaped collateralization
contract VulnerableLendingMarket {
    SpotOracle public oracle;
    address public quoteToken;   // e.g. WETH
    IERC20  public baseToken;    // e.g. sUSD
 
    uint256 public constant LTV_BPS = 5000; // 50% LTV
 
    mapping(address => uint256) public collateral;  // base units
    mapping(address => uint256) public debt;        // quote units (ETH)
 
    function deposit(uint256 amount) external {
        baseToken.transferFrom(msg.sender, address(this), amount);
        collateral[msg.sender] += amount;
    }
 
    /// @notice Borrow `qty` of quote-token against deposited base collateral.
    /// Vulnerable to spot-oracle manipulation: `oracle.basePerQuote()` is
    /// whatever the last swap left in the pool.
    function borrow(uint256 qty) external {
        uint256 r = oracle.basePerQuote();           // ← MANIPULABLE
        // Collateral expressed in quote units:
        uint256 collInQuote = (collateral[msg.sender] * 1e18) / r;
        uint256 maxDebt = (collInQuote * LTV_BPS) / 10_000;
        require(debt[msg.sender] + qty <= maxDebt, "undercollateralized");
        debt[msg.sender] += qty;
        IERC20(quoteToken).transfer(msg.sender, qty);
    }
}

The vulnerable surface is one line: oracle.basePerQuote() is a spot read against a thin pool. Everything else is a normal-looking lending market.

5.2 The attacker contract

// test/Manipulator.sol
contract Manipulator {
    MockUniswapV2Pair public pair;
    VulnerableLendingMarket public market;
    IERC20 public baseToken;
    IERC20 public quoteToken;
 
    /// @notice One-shot oracle-manipulation attack.
    /// 1. (External) caller pre-funds with a flash loan of `flashQuote`.
    /// 2. Spend `manipQuote` of quote into the thin pool to tilt the spot price.
    /// 3. Deposit pre-acquired `collateralBase` into the lending market.
    /// 4. Borrow against the inflated collateral valuation.
    /// 5. (External) caller repays the flash loan from the borrowed amount.
    function attack(
        uint256 manipQuote,
        uint256 collateralBase
    ) external returns (uint256 borrowed) {
        // (2) Tilt the pool: spend quote-token to drive base/quote *up*
        //     (i.e., each unit of base now "costs" more quote, so collateral
        //     appears more valuable).
        quoteToken.approve(address(pair), manipQuote);
        pair.swapQuoteForBase(manipQuote);
 
        // (3) Deposit pre-acquired sUSD.
        baseToken.approve(address(market), collateralBase);
        market.deposit(collateralBase);
 
        // (4) Borrow against the inflated valuation.
        //     With base/quote pumped, `oracle.basePerQuote()` returns a smaller
        //     number — sUSD per ETH falls — and the market thinks our collateral
        //     is worth more ETH than it really is. We can borrow ~LTV% of that.
        uint256 r = market.oracle().basePerQuote();
        uint256 collInQuote = (collateralBase * 1e18) / r;
        borrowed = (collInQuote * 5000) / 10_000;  // LTV_BPS
        market.borrow(borrowed);
    }
}

5.3 The test — drain the market

// test/SpotOracleManipulation.t.sol
contract SpotOracleManipulationTest is Test {
    MockUniswapV2Pair pair;
    SpotOracle oracle;
    VulnerableLendingMarket market;
    MockERC20 baseToken;     // "sUSD"
    MockERC20 quoteToken;    // "WETH"
    Manipulator attacker;
 
    function setUp() public {
        // Thin pool: 200k sUSD / 1000 ETH → fair price ~200 sUSD/ETH.
        baseToken = new MockERC20("sUSD", "sUSD");
        quoteToken = new MockERC20("WETH", "WETH");
        pair = new MockUniswapV2Pair(200_000e18, 1_000e18);
        baseToken.mint(address(pair), 200_000e18);
        quoteToken.mint(address(pair), 1_000e18);
 
        oracle = new SpotOracle(pair);
        market = new VulnerableLendingMarket(
            oracle, address(quoteToken), baseToken
        );
 
        // Market is funded with 10,000 ETH of lendable liquidity.
        quoteToken.mint(address(market), 10_000e18);
 
        attacker = new Manipulator(pair, market, baseToken, quoteToken);
    }
 
    function test_drainViaSpotOracleManipulation() public {
        // Pre-attack: a user could borrow honestly against sUSD collateral.
        // Acquire 200k sUSD at fair price for the attacker (in the real world
        // this would be flash-loaned + Synthetix-Depot acquired; we shortcut).
        baseToken.mint(address(attacker), 200_000e18);
 
        // Flash-loan analog: hand the attacker 4_000 ETH for the duration.
        quoteToken.mint(address(attacker), 4_000e18);
 
        emit log_named_uint("Spot before",    oracle.basePerQuote()); // ~200e18
        emit log_named_uint("Market quote balance before", quoteToken.balanceOf(address(market)));
 
        // Attack: spend 4_000 ETH to tilt the 1_000-ETH-deep pool hard,
        // then borrow against 200k sUSD at the manipulated rate.
        uint256 borrowed = attacker.attack(4_000e18, 200_000e18);
 
        emit log_named_uint("Spot after",     oracle.basePerQuote()); // pumped
        emit log_named_uint("Borrowed",       borrowed);
        emit log_named_uint("Market quote balance after",  quoteToken.balanceOf(address(market)));
 
        // Without the manipulation, 200k sUSD @ 200 sUSD/ETH = 1,000 ETH of
        // collateral → max 500 ETH borrow at 50% LTV.
        // With the manipulation, the same 200k sUSD looks worth ~4_000+ ETH,
        // so the attacker can borrow ~2_000 ETH.
        assertGt(borrowed, 1_500e18, "attacker should borrow far more than honest LTV");
    }
}

5.4 Expected numbers

With this configuration:

  • Pre-attack spot: 200_000e18 / 1_000e18 = 200e18 (200 sUSD/ETH — “fair”).
  • After the 4,000-ETH manipulation: pool now holds (~1_000 + 4_000 = 5_000) ETH and (~200_000 × 1_000 / 5_000 = 40_000) sUSD, so spot becomes 40_000 / 5_000 = 8e18 (8 sUSD/ETH).
  • Lending market reads basePerQuote() = 8e18 and concludes that 200,000 sUSD of collateral is worth 200_000 / 8 = 25,000 ETH.
  • At 50% LTV, max borrow = 12,500 ETH — except the market only has 10,000 ETH of lendable liquidity, so the attacker drains it all.
  • Honest value of the 200k sUSD: ~1,000 ETH. Loss to the lender: ~9,000 ETH net.

The exact numbers depend on tuning; the structural property — “manipulate spot → borrow at manipulated price → walk away” — is robust to all reasonable parameter choices. A thinner pool, a more thinly-collateralized market, or a higher flash-loan capacity each make the attack more profitable.

5.5 The patches

Two structural patches, in increasing order of robustness:

(a) TWAP — Uniswap V2 cumulative price

Replace SpotOracle with a TWAP that averages over a window large enough that single-block manipulation contributes only a small fraction:

// src/TWAPOracle.sol — sketch
contract TWAPOracle {
    MockUniswapV2Pair public pair;
    uint256 public priceCumulativeLast;
    uint32 public blockTimestampLast;
    uint256 public price0Average;       // FixedPoint.uq112x112-style sketch
 
    function update() external {
        (uint256 priceCumulativeNow, uint32 now) = pair.currentCumulativePrice();
        uint32 elapsed = now - blockTimestampLast;
        require(elapsed >= 30 minutes, "stale window");
        price0Average = (priceCumulativeNow - priceCumulativeLast) / elapsed;
        priceCumulativeLast = priceCumulativeNow;
        blockTimestampLast = now;
    }
 
    function basePerQuote() external view returns (uint256) { return price0Average; }
}

A 30-minute TWAP means an attacker who tilts the pool for one block contributes 1 block / (30 × 60 / 12) ≈ 0.7% of the window’s weight. To skew the TWAP by, say, 10%, the attacker must hold the manipulation for most of the 30-minute window — which means committing capital for 30 minutes (no longer free), facing arbitrageurs who will re-balance the pool against them every block, and paying the LP fees of all those arb trades. The attack cost goes from ~0 (flash-loanable) to nontrivial-and-bounded-by-pool-depth.

Uniswap V3 uses an observation-array variant; the security property is the same.

(b) Multi-oracle median (Chainlink + on-chain TWAP + bounds)

The most robust posture, used by Aave/Compound/Maker today:

function priceOf(address asset) external view returns (uint256) {
    uint256 chainlinkPx = chainlinkOracle.getLatestPrice(asset);
    uint256 twapPx      = uniswapV3TWAP.getPriceAverage(asset, 30 minutes);
    require(_withinDeviation(chainlinkPx, twapPx, 200), "oracle disagreement");
    return chainlinkPx;     // Chainlink is primary; TWAP is the sanity check
}

Chainlink aggregates off-chain reports from independent operators; manipulating it requires colluding with a majority of those operators. The TWAP sanity-check protects against the (rare but real) Chainlink-feed-stale or Chainlink-feed-misconfigured failure mode.

Re-run the test against the TWAP version: the attack contributes a tiny tilt to the cumulative price; borrowed collapses to ~500 ETH (the honest LTV-bound). The drain is prevented.

5.6 Stretch lab — flash-loan the manipulation, not just the borrow

The harness above shortcuts step (2) by minting quote-token into the attacker. The “real” version flash-loans the manipulation capital from a third venue (Aave / Balancer / dYdX). Build a FlashLender mock with a callback pattern, have the attacker take a 4,000-ETH flash loan inside attack(), do the manipulation, the deposit, the borrow, and repay the flash loan from the borrow proceeds. The economic result is identical to §5.3 but the attacker’s pre-funding requirement drops to ~0. This is the structural lesson of bZx in one Foundry test.


6. Aftermath

6.1 Immediate response

  • Feb 15, 2020 (~02:00 UTC) — Attack 1 lands. bZx tweets that funds are safe.
  • Feb 15 — bZx pauses the protocol; PeckShield publishes the first technical analysis within ~24 hours.
  • Feb 17 — bZx publishes their own post-mortem and rolls a patch that closes the loanDataBytes.length == 0 && sentAmounts[6] == sentAmounts[1] short-circuit. The team states publicly that the underlying oracle dependency on Kyber is “being addressed”.
  • Feb 18, 2020 (~03:13 UTC) — Attack 2 lands, exploiting the oracle dependency that had not been addressed. ~30 hours after the first patch.
  • Feb 18 — bZx pauses again. The team announces a migration of price feeds to Chainlink for the affected assets.
  • Feb 19 — CoinDesk and the wider crypto press use “flash loan attack” as a top headline; the phrase enters mainstream DeFi vocabulary.

bZx covered the user-side shortfall from its insurance fund (a pool of BZRX tokens and treasury reserves designed exactly for this contingency). No retail user lost principal in either attack. This was, in the short term, an important PR win — the protocol “made users whole” before the news cycle fully formed. In the longer term, it set an unhelpful precedent: many later protocols promised insurance-fund coverage without the capital to back it.

6.2 The oracle migration

Post-Feb-18, bZx migrated its key price feeds from Kyber-spot to Chainlink. The migration was incremental — stablecoin pairs first, then the riskier long-tail pairs — and took several weeks to complete fully. By Q2 2020, the BZxOracle had become a Chainlink-first feed with on-chain fallbacks; Kyber was demoted to a quote source for execution, not for valuation.

This migration is interesting because it worked: through 2020 and 2021, bZx was not exploited again via the same oracle-manipulation pattern. The bug class was structurally removed.

6.3 Subsequent bZx incidents (different bugs, same protocol)

bZx is unusual in that the team continued operating after Feb 2020 and suffered multiple subsequent incidents with different root causes:

  • September 2020: A duplicate-transfer bug in an iToken contract allowed an attacker to double-credit a deposit and drain ~$8M. Different bug class (token-accounting, not oracle). bZx covered the loss from treasury again.
  • November 2021: A private key compromise — the bZx team’s developer keys were phished, and the attacker drained ~$55M from bZx-deployed contracts on Polygon and BSC. Operational-security failure, not a smart-contract bug at all.

The lesson worth extracting: a protocol can patch one bug class without becoming secure. bZx fixed the spot-oracle problem completely and was exploited via three additional unrelated vectors over the next two years. Modern audit practice treats “this finding is fixed” as a local statement, not a global one — and treats the culture that produced the bug (single audit firm, no formal threat model, oracle dependency unmodeled, no live-incident playbook) as the actual risk surface.

bZx today (May 2026) is effectively dormant. The brand, the token (BZRX, since rebranded to OOKI in late 2021), and a small DAO persist; TVL is negligible. The protocol is studied historically; it is not used commercially.

6.4 Industry-wide consequences

The “flash loan attack” template was born here. Over 2020 alone, the same shape — flash-loan capital + oracle manipulation on a spot-priced AMM-backed lender — accounted for:

  • Harvest Finance (Oct 2020): ~$24M via Curve y-pool spot manipulation.
  • Cheese Bank (Nov 2020): ~$3.3M via Uniswap V1 spot manipulation on collateral pricing.
  • Origin Dollar (Nov 2020): ~$7M via a flash-loaned re-entrancy-plus-manipulation hybrid.
  • Warp Finance (Dec 2020): ~$7.7M via LP-token-as-collateral with manipulated underlying.

By the end of 2020, the pattern was so well-documented that every reputable audit firm had a dedicated “oracle dependency” section in their template. By 2021 most major protocols had either migrated to Chainlink or built a multi-oracle median with TWAP fallback.

The “spot-priced AMM oracle” pattern was effectively retired as an industry default. It still appears in long-tail or specialty protocols, but it is now a finding, not a design choice.


7. Lessons for Auditors

7.1 Spot price is not a price

The single most important sentence in this case study, and the most-quoted lesson:

A spot price on an AMM is a quote, not a price. It is whatever the last swap left in the pool. Used as a collateral oracle, it is the value of the next swap, not the value of the asset.

This generalizes beyond AMMs:

  • Order-book best-bid/best-ask is a spot quote.
  • DEX aggregator quoted rate is a spot composite.
  • Even a “median of two DEXes” is a spot composite if both DEXes share LP liquidity.

The auditor’s reflex: when reading any state-changing function that touches user value (deposit, borrow, withdraw, liquidate), find every oracle read and ask “is this a spot read, an aggregated read, or a time-averaged read?” Spot reads in a value-determining path are a critical finding by default.

7.2 TWAP is a defense, with caveats

Uniswap V2’s cumulative-price TWAP, and V3’s observation-array TWAP, are the canonical defense. The mechanism: a manipulator who wants to skew the average must hold the manipulation across the entire window, paying ongoing LP fees and facing arbitrageur unwinds every block. The cost goes from “0 (flash-loanable)” to “function of pool depth × window length × fee tier”.

Caveats:

  • Window length is a tradeoff. A 30-minute TWAP costs 50k to skew on a thin pool but is far slower to react to honest price moves. For volatile assets, this can create liquidation lag — positions that should liquidate at the new market price remain “healthy” against the stale TWAP, until the TWAP catches up. Curve and Reaper both lost money to this exact failure mode in 2022–2023.
  • Pool depth matters. A “30-minute TWAP” on a X for the duration × the pool fee). The TWAP is not magic; it converts manipulation-cost from “free” to “proportional to depth × window × fee”.
  • TWAP is not real-time. For liquidations (which want freshness) you typically pair it with a Chainlink primary + TWAP-as-sanity-check.

7.3 Multi-oracle median + deviation checks

The modern best-practice posture, used by Aave V3, Compound V3, Maker DSR, etc.:

  1. Primary: Chainlink aggregated feed (off-chain reporters, on-chain median).
  2. Secondary: Uniswap V3 TWAP over a protocol-appropriate window (5–60 minutes).
  3. Sanity: Require that |primary − secondary| / secondary < deviation_threshold (typically 1–5%); revert otherwise.
  4. Circuit breaker: If the deviation persists across N consecutive reads, pause borrowing / liquidations until governance reviews.

This defends against: (a) AMM-spot manipulation (TWAP), (b) Chainlink feed stale or misconfigured (deviation check catches), (c) extreme volatility where both feeds disagree (circuit breaker pauses risky actions).

7.4 Flash loans don’t create new bug classes — they remove the capital constraint

A common but slightly-wrong takeaway from bZx is “flash loans are dangerous”. The more precise statement:

Flash loans don’t create new vulnerabilities. They remove the implicit “the attacker doesn’t have $10M” defense that many protocols relied on.

Every bug exploited by a flash loan was already exploitable by a sufficiently-capitalized attacker. The flash loan democratizes the attack. The auditor’s job is therefore to assume an attacker has unlimited single-block balance sheet, and design the protocol so that no economically-attractive single-block sequence drains it.

The corollary: the question “could a flash loan exploit this?” is a useful audit prompt, but it is not a sufficient one. If your protocol is safe against a 1B real-capital multi-block attack, you have not fixed the bug — you have only made the attacker wait two blocks. Real-capital adversaries exist (hedge funds, sophisticated MEV searchers, sovereign attackers); flash-loan defenses that rely on single-block atomicity are insufficient.

7.5 “We use a DEX as our oracle” is the highest-leverage audit finding

If you read one sentence in a protocol’s docs and it is “we use [Uniswap | SushiSwap | Curve | Balancer | DEX-of-the-week] as our price oracle”, that sentence is the audit. Everything else is detail. The follow-up questions:

  1. Spot or time-averaged? If spot, this is a critical finding; stop reading and write up the manipulation PoC.
  2. What’s the deepest reserve for this asset on that DEX? Compute the cost-to-manipulate to a profit threshold (typically: cost-to-tilt the pool such that the protocol mis-prices by enough to drain X). This is the dollar-cost-of-attack.
  3. Is the manipulation cost greater than the value at stake? If yes, the design is borderline safe — but only against rational attackers. Irrational/political attackers (state actors, market vandals) are not bounded by profit.
  4. Are there flash-loan venues that can fund the manipulation? If yes, the cost-to-attack collapses to the slippage + fees, not the capital. In practice, in 2026 there’s always a flash-loan venue.
  5. What’s the upgrade path to a TWAP or Chainlink feed? If the protocol can’t migrate, the design is intrinsically risky.

7.6 Composability multiplies trust assumptions

bZx assumed Kyber’s reserves were “diverse”. Kyber’s reserves were dominated by AMM-pool-backed reserves that shared liquidity with Uniswap. So bZx’s “diverse” oracle was actually a single-source oracle wearing two hats.

The auditor’s worksheet:

  • For every external dependency a protocol consumes, walk the dependency’s dependencies until you hit a non-on-chain source (Chainlink reporters, off-chain proof verifier, real-world data).
  • For each intermediate hop, ask: “what is the failure mode of this hop, and does the consumer protocol’s threat model survive it?”
  • Pay special attention to cases where two “different” sources actually share underlying liquidity. Many DeFi protocols accidentally consume the same Uniswap pool through three different routers and call that a “multi-oracle”.

bZx’s failure was at the bottom of this walk: every “diverse” Kyber reserve ultimately read from a thin Uniswap pool that the attacker controlled. The diversity was an illusion.


8. What You Would Have Caught (Pre-Attack Auditor Exercise)

If bZx landed in your inbox in January 2020 — one month before the attack — here is what should fire on read.

8.1 Immediate fires (under 60 seconds)

SignalWhy it fires
BZxOracle.getCurrentMarginAmount calls kyberNetwork.getExpectedRateThe single highest-leverage finding. Spot read against an AMM-backed aggregator. Manipulation cost is bounded by pool depth, which is small for sUSD.
**The slippage require in takeOrderFromiToken uses `
The protocol is the trader AND consults the oracle that prices the tradebZx executes the swap that moves the price, then reads the moved price to check collateral. Circular dependency.
No TWAP, no Chainlink, no multi-oracle median anywhere in the codebaseThe codebase doesn’t even have the primitives for a robust oracle. Migration cost is not “add a function call” — it’s “redesign the oracle layer”.
Flash loans existed at dYdX (and on Aave V1 from ~Jan 2020)The capital constraint on manipulation is gone. Any “but it costs millions to do this” argument is dead.

8.2 Secondary signals (next 5 minutes)

  • Public disclosure: samczsun’s “Taking undercollateralized loans for fun and for profit” was published Sept 18, 2019 and named bZx by name. The auditor should find the existing literature before opening Solidity. Failing to do so is an audit-process bug.
  • Kyber’s reserves for long-tail tokens (sUSD, WBTC, etc.) are dominated by AMM-backed reserves. Manipulating Kyber’s spot quote is equivalent to manipulating one of those AMMs. The “multi-source aggregator” branding is misleading.
  • The Uniswap V1 WBTC/ETH pool depth (~2,300 WBTC / ~6,000 ETH in Feb 2020) is publicly observable. Compute: a 5,000-ETH push into that pool moves the spot price by ~2.5×. A 5× leveraged short of $200k could move the pool by 5×. This is a dollar-cost-of-attack calculation that any auditor can do with a calculator.
  • No emergency pause on individual position-tokenssETHwBTCx5 cannot be unilaterally frozen without freezing the whole protocol. Incident response is binary (pause all / pause none).
  • bZx ships its own flash-loan endpoint (added between attacks) — this is a force multiplier for any oracle vulnerability in the same protocol.

8.3 The 60-second auditor verdict

“Critical: BZxOracle.getCurrentMarginAmount uses Kyber’s getExpectedRate as a spot oracle for collateral valuation. Kyber’s reserves for long-tail assets (sUSD, WBTC) are AMM-backed with shallow depth; an attacker funded by a flash loan (dYdX, Aave V1) can manipulate the spot price by 2×+ for the cost of slippage alone, then borrow against the manipulated collateral and walk away. PoC: flash-loan 5,000 ETH from dYdX, sweep Kyber’s sUSD reserves with ~900 ETH, deposit pre-acquired sUSD as bZx collateral, borrow ~2× the honest LTV in ETH, repay flash loan. Estimated loss: full lendable liquidity of any iToken whose collateral asset has shallow Kyber depth. Migrate BZxOracle to a TWAP and/or Chainlink primary feed before allowing the affected assets back to production.

Critical (separate): the || short-circuit in takeOrderFromiToken bypasses OracleInterface.shouldLiquidate whenever loanDataBytes.length == 0 && sentAmounts[6] == sentAmounts[1]. This is a slippage-check bypass — a margin position can open instantly underwater. PoC: open a 5x WBTC short with loanDataBytes = 0x and matching sent-amounts; verify the position opens with collateral worth < required margin. Severity: critical (entire iETH pool at risk per position).”

That paragraph, plus the two PoC sketches, would have been the report. Each PoC is ~50 lines of Foundry. The patches are: change one || to a re-architected check (one PR); migrate the oracle layer (multi-week refactor). The audit firm that reported these findings in January 2020 would have prevented $1M of losses and a permanent reputational dent for bZx.

8.4 What this teaches about audit methodology

bZx was not unaudited. It had been audited (ZK Labs and others), it had read samczsun’s disclosure, and the team genuinely believed the issue was fixed. Why did the audits miss it?

  1. Audits at this period focused on individual functions, not composition. takeOrderFromiToken is “safe” in isolation; the oracle read inside BZxOracle.getCurrentMarginAmount is “safe” in isolation; the swap routing through Kyber is “safe” in isolation. The deadly composition is the three of them in one transaction, with the attacker controlling the connector.
  2. The threat model didn’t include flash loans. Flash loans existed (Marble Protocol had one in 2018, Aave V1 launched its flash loan in Jan 2020), but they weren’t yet in every auditor’s threat model. Audits assumed the manipulator was constrained by their own balance sheet.
  3. The “we use Kyber” reasoning was treated as a strength, not a weakness. Kyber’s marketing emphasized “diverse reserves”; auditors didn’t walk the dependency to find the AMM-backed bottom.
  4. samczsun’s pre-disclosure was read as “this bug is fixed”, not “this bug class persists”. When samczsun reported the issue, the bZx team patched the specific surface he reported — but didn’t generalize to “every spot read in our codebase is a candidate”. This is the most generalizable lesson: when a disclosure tells you a class of bugs is exploitable, audit your codebase for the class, not just the instance.

The modern auditor’s playbook treats every “we use [DEX] as our oracle” as a finding, every flash-loan-funded sequence as a vector, and every public disclosure of a bug-class as a mandate to audit all instances. bZx is the reason for each of those rules.


9. References

Primary post-mortems and analyses

Pre-attack disclosure

Long-form retrospectives and oracle-design guidance

Etherscan key addresses

Source code

  • bZx-monorepo (GitHub) — historical Solidity source, including BZxOracle and LoanTokenLogic: https://github.com/bZxNetwork/bZx-monorepo [verify — repo may have been archived or renamed under OOKI rebrand]
  • bZx post-mortem statements (bZx blog / Medium archives, Feb 15 and Feb 18 official posts) [verify URLs — bZx blog hosting changed during the OOKI rebrand]

Subsequent oracle-manipulation cases (the bZx template, applied)

  • Harvest Finance (Oct 2020) — Curve y-pool spot manipulation; ~$24M.
  • Cheese Bank (Nov 2020) — Uniswap V1 spot manipulation; ~$3.3M.
  • Warp Finance (Dec 2020) — LP-token collateral mis-pricing; ~$7.7M.
  • Cream / Iron Bank (Oct 2021) — flash-loan + ERC-777 callback + price manipulation hybrid; ~$130M.
  • Mango Markets (Oct 2022) — single-pool collateral pricing; ~$117M.

Last updated: 2026-05-16 See also: Tuan-09-Oracle-MEV-Economic-Attack · Tuan-08-DeFi-Security-AMM-Lending-Vault · Tuan-06-Vulnerability-Classes-Part-2 · Case-Harvest-Finance-2020 · Case-Cheese-Bank-2020 · Case-Cream-Iron-Bank-2021 · Case-Mango-Markets-2022 · Case-Euler-Finance-2023 · audit-checklist-master · severity-rubric-immunefi-c4 · Roadmap · References