bZx / Fulcrum ETH-WBTC Margin Pump shouldLiquidate() Bypass (2020-02-15)

  • Name: bZx / Fulcrum ETH-WBTC margin pump shouldLiquidate() bypass
  • Date: 2020-02-15
  • Protocol: bZx / Fulcrum
  • Chain: Ethereum
  • Category: unhealthy-position check bypass / slippage exploitation / toxic-order-flow leakage / flash-loan-assisted arbitrage
  • Loss: commonly framed as roughly $350K of attacker profit after unwinding the Compound leg, while bZx’s lending side absorbed roughly $600K+ of equity damage from a massively undercollateralized WBTC short position
  • Summary: In the first February 2020 bZx exploit, the attacker did not begin by spoofing an oracle. Instead, they used a 10,000 ETH dYdX flash loan to assemble a two-leg trade around Fulcrum’s sETHwBTC 5x margin product. After posting 1,300 ETH to open the short, bZx itself borrowed about 5,637.62 ETH and routed the trade through Kyber into Uniswap, receiving only about 51.3456 WBTC because the order was far too large for available liquidity. That trade should have left the new position immediately liquidatable, but bZx’s final shouldLiquidate() sanity check was skipped because loanDataBytes.length == 0 and sentAmounts[6] == sentAmounts[1] made the require(...) short-circuit true. Once bZx had paid to move the WBTC/ETH market violently out of line, the attacker dumped 112 WBTC borrowed from Compound back into the same distorted Uniswap pool for about 6,871.41 ETH, repaid dYdX, and later unwound the profitable Compound leg while bZx kept the toxic short.
  • Trigger / entry point:
    • Fulcrum’s ETH/WBTC 5x token path let a user open a very large leveraged short with only 1,300 ETH of posted capital and protocol-borrowed ETH providing the rest of the trade size.
    • bZx routed the borrowed ETH through Kyber, which in practice selected the KyberUniswap reserve as the only route deep enough to fill the order.
    • The protocol relied on a final shouldLiquidate() check to reject any newly opened position whose slippage had already pushed it below maintenance margin.
    • A logic bug in takeOrderFromiToken() let that final health check be skipped exactly on the path used by the attacker.
  • Root cause:
    • bZx let an enormous market order execute before proving that the resulting position was still solvent.
    • The intended post-trade protection existed, but a Boolean short-circuit in takeOrderFromiToken() treated the path as if no meaningful liquidation check was needed when loanDataBytes was empty and withdrawalAmount == newLoanAmount.
    • Once that check was skipped, the protocol effectively socialized the slippage from its own oversized Uniswap-routed swap into the lending pool, creating a toxic position the attacker could arbitrage against.
    • This was adjacent to oracle risk but not the same thing as the 2020-02-18 bZx sUSD oracle attack. The central failure here was unhealthy-position admission after catastrophic execution slippage.
  • Exploit path:
    1. The attacker executed transaction 0xb5c8bd9430b6cc87a0e2fe110ece6bf527fa4f170a4bc8cd032f768fc5219838 in Ethereum block 9484688.
    2. Inside the transaction, the attacker flash-borrowed 10,000 ETH from dYdX.
    3. The attacker deposited 5,500 ETH into Compound and borrowed 112 WBTC, creating inventory they could later dump into a distorted market.
    4. The attacker then used 1,300 ETH to open a 5x ETH/BTC short on Fulcrum via mintWithEther, cascading into marginTradeFromDeposit().
    5. bZx borrowed about 5,637.62376238 ETH on behalf of the position and routed the trade through Kyber, which sourced liquidity from the KyberUniswap reserve and exchanged that ETH for only about 51.34557581 WBTC.
    6. That execution implied roughly 109.8 ETH per WBTC, versus a normal market level closer to the high-30s ETH per WBTC at the time. The new position should have been instantly unhealthy because slippage had already eaten through the maintenance margin.
    7. In takeOrderFromiToken(), the check require((loanDataBytes.length == 0 && sentAmounts[6] == sentAmounts[1]) || !OracleInterface(oracle).shouldLiquidate(...), "unhealthy position") passed without ever calling shouldLiquidate(), because the attacker used the exact branch where both left-side conditions were true.
    8. With Uniswap now badly skewed by bZx’s own trade, the attacker sold the 112 WBTC borrowed from Compound back into the pool and received about 6,871.41273887 ETH.
    9. The attacker repaid the 10,000 ETH dYdX flash loan, kept residual ETH in the transaction, and later repaid the Compound WBTC debt over time to unlock the far more valuable 5,500 ETH collateral.
    10. bZx was left holding the underwater side of the short: a position whose catastrophic execution slippage had already transferred value out to Uniswap and then to whoever sold WBTC back into the pool first.
  • What the attacker needed to be true:
    • Fulcrum had to allow a large enough ETH/WBTC short that its own trade would move the destination pool far beyond the product’s safety margin.
    • Kyber had to route the order into a liquidity source shallow enough that the protocol’s borrow-funded swap would create extreme slippage instead of reverting or being size-limited.
    • The takeOrderFromiToken() branch with empty loanDataBytes and sentAmounts[6] == sentAmounts[1] had to remain reachable so shouldLiquidate() would never run.
    • A second venue leg had to exist for the attacker to monetize the distortion immediately; here that leg was the 112 WBTC hoarded from Compound.
    • The whole sequence had to fit atomically around the dYdX flash loan so the attacker did not need to pre-fund 10,000 ETH.
  • Technical anatomy worth remembering:
    • This exploit is best remembered as protocol pays catastrophic slippage on a leveraged trade, final health check is skipped, attacker harvests the mispricing from the other side.
    • The attacker did not need to pre-pump Uniswap before calling bZx. Fulcrum’s own borrowed-trade path created the distortion, and the attacker simply arranged to be the best-positioned seller into that distortion.
    • Lev Livnev’s trace analysis showed why the safety check disappeared: marginTradeFromDeposit() eventually set up sentAmounts[6] = sentAmounts[1], and because loanDataBytes was empty, the left side of the require became true and short-circuited the liquidation call entirely.
    • PeckShield’s breakdown usefully separates the trade into five stages: flash-loan borrow, WBTC hoard on Compound, margin pump on bZx, WBTC dump on Uniswap, and flash-loan repayment.
    • Economically, the exploit transferred value from bZx’s lending pool into Uniswap through a toxic trade, then captured a large share of that leaked value by dumping hoarded WBTC back through the now-mispriced pool.
    • The case is a strong reminder that post-trade solvency checks are part of the execution path, not optional cleanup. If a leveraged product can borrow first and validate later, then any branch that skips validation becomes a direct solvency bug.
  • Representative transactions and artifacts:
    • Main exploit transaction: 0xb5c8bd9430b6cc87a0e2fe110ece6bf527fa4f170a4bc8cd032f768fc5219838
    • Attacker EOA highlighted in public analyses: 0x148426fdc4c8a51b96b4bed827907b5fa6491ad0
    • Primary attacker contract highlighted by Palkeo: 0x4f4e0f2cb72e718fc0433222768c57e823162152
    • dYdX-calling helper contract highlighted by Palkeo: 0x0de0dd63d9fb65450339ef27577d4f39d095eb85
    • Key vulnerable entrypoints discussed in public analyses: mintWithEther, marginTradeFromDeposit(), takeOrderFromiToken(), and shouldLiquidate()
  • Response / mitigation:
    • bZx paused affected protocol functionality during the incident response window.
    • The team patched the condition so the health check would execute even on the branch that had previously been treated like an overcollateralized path.
    • bZx also moved toward tighter oracle / trade protections and maximum trade-size constraints, recognizing that giant route-through-AMM execution was itself part of the danger surface.
    • The broader lesson is to fail before honoring toxic execution, not after. If a route cannot support the trade size without destroying maintenance margin, the protocol should never finalize the position.

Pattern tags

Three execution-and-solvency promotions this incident collapsed

  • A borrow-funded route existed: Fulcrum was willing to finance a 5x ETH/WBTC short, and Kyber’s route discovery found a real executable path into Uniswap even though that path was far too shallow for the size. The first dangerous promotion was treating there is some route that fills as if it were already this route can support the financed position without consuming maintenance margin.
  • That toxic fill then became canonical position truth: after bZx sold about 5,637.62 ETH for only 51.3456 WBTC, the newly opened short should have failed admission as immediately unhealthy. Instead, the loanDataBytes.length == 0 && sentAmounts[6] == sentAmounts[1] branch let takeOrderFromiToken() promote trade already happened into position may now exist without forcing shouldLiquidate() to re-derive solvency from the realized fill.
  • Once that false position truth was accepted, it still inherited live lender-balance-sheet authority: bZx left the underwater short on its books while the attacker sold 112 WBTC into the same distorted Uniswap pool and harvested the slippage that Fulcrum had already paid for. Healthy review should keep separate route can execute, realized fill still supports a solvent position, and that accepted position deserves lender-funded market exposure.

Why the exploit worked

  • bZx let one execution path spend borrowed ETH into a real market before proving the resulting position still cleared maintenance margin. That made market impact part of the solvency boundary rather than an implementation detail.
  • The intended safety check was only conditionally enforced. On the attacker-chosen marginTradeFromDeposit(..., loanDataBytes = "") path, amountIsADeposit = true eventually made sentAmounts[6] == sentAmounts[1], so the require(...) in takeOrderFromiToken() treated the trade as safe without ever calling shouldLiquidate().
  • Fulcrum sized the short against notional leverage, but the real route went through Kyber into Uniswap liquidity far too thin for the borrowed trade size. Once the protocol itself crossed that depth boundary, it authored the mispricing the attacker later harvested.
  • The attacker did not need an independent oracle bug or a pre-manipulation phase. They only needed the protocol to honor its own toxic execution and leave enough time inside the same transaction to sell hoarded WBTC back into the skewed pool.

Detection and audit pivots

  • Trace every borrow-funded trade path to the exact point where post-trade health is enforced. If a leverage-opening function can route assets out to live liquidity before the unavoidable solvency assertion runs, the protocol is lending against execution assumptions instead of settled collateral.
  • Compare quoted price logic with realized route shape. Here the dangerous object was not the mid-price read from Kyber but the actual executable depth of the Kyber-selected Uniswap reserve under full trade size.
  • Search for Boolean short-circuits around final solvency checks, especially branches that treat some input combinations as obviously safe and therefore skip the canonical liquidation / health test.
  • Model same-transaction monetization after a protocol-authored bad trade. If the protocol can pay catastrophic slippage into a pool, ask who can immediately stand on the other side of that pool and capture the leak.

Failure-prevention checklist

  • Make post-trade solvency validation mathematically unavoidable. No leverage-opening path should be able to satisfy a convenience branch that skips the same health check the core engine depends on.
  • Bind maximum trade size to executable route depth, not only to nominal collateral factor or ex-ante quoted price. If the destination liquidity cannot absorb the protocol-sized order without crossing maintenance margin, revert before opening the position.
  • Collapse quote, execution, and health into one path-complete risk model. The protocol should validate the exact route and realized fill assumptions it is about to honor, not a cleaner proxy price from earlier in the call chain.
  • Test protocol-funded slippage as a first-class attack model: protocol opens oversized leveraged trade -> pool becomes distorted -> attacker harvests opposite side in same transaction.
  • Treat route-selection modules and aggregators as solvency-critical code. Kyber / router choice is not an execution detail once a leveraged product is willing to finance the trade from lender capital.

Audit prompts

  • Which exact line proves a newly opened leveraged position is healthy after all route-dependent execution has already happened, and can any branch skip that line without reverting?
  • Are quoted prices, reserve-selection logic, and realized executable depth all describing the same market object, or can the protocol validate against one view and settle against another?
  • What is the largest borrow-funded order the protocol can push through each allowed route before maintenance margin is consumed by slippage alone?
  • If the protocol itself moves the market during position opening, who can immediately capture the other side of that distortion inside the same transaction or same block?
  • Do any safe path, Kyber only, no extra calldata, or similar helper conditions silently stand in for the actual post-trade health assertion?

Lessons that should already be standard

  • A leverage protocol does not get to outsource market-impact risk to execution. If lender capital finances the trade, execution depth is part of solvency.
  • Final safety check exists in code is not enough; the meaningful question is whether every economically reachable path is forced through it.
  • Mid-price or aggregator quote sanity does not protect a system that will still honor route-specific toxic fills at full size.
  • A protocol can create its own exploitable market dislocation. In this case the attacker mostly arranged financing and timing; Fulcrum itself paid to skew the pool.
  • Post-trade validation belongs after the trade and before the position becomes real. Anything weaker is permission to socialize slippage into lenders.

Why this incident matters

  • It is one of DeFi’s earliest canonical flash loan + multi-protocol atomic exploit incidents, but the flash loan was only the financing convenience.
  • The deeper lesson is about execution-path solvency: a leveraged protocol cannot safely route huge trades into shallow liquidity and rely on a final conditional check unless that check is mathematically unavoidable.
  • It also pairs cleanly with bZx’s second February 2020 exploit: first the protocol failed by admitting an already-unhealthy leveraged trade, then days later it failed by honoring manipulated collateral pricing.

Internal linkages

Control surface