bZx Hack Full Disclosure (With Detailed Profit Analysis)

bZx Hack Full Disclosure (With Detailed Profit Analysis)

5: Flashloan Repay. With the netted 6871.4127388702245 ETH from the dumped 112 WBTC, the attacker repays the flashloan 10000.000000000011ETH back to dYdX, thus completing the flashloan.

We re-calculate the following asset breakdown after this step. It turns out that the attacker gains the 71ETH arbitrage profit, plus the two positions, one in Compound (+5,500WETH/-112WBTC) and another in bZx (+4,337WETH/+51WBTC). The Compound position is very profitable while the bZx position is in default state. Apparently, right after the exploit, the attacker starts to arrange the payment of Compound debt (112BTC) to claim the collateral (5,500WETH). For the bZx position, since it is already in default, the attacker shows no futher interest.

Considering the average market price of 1WBTC=38.5WETH (or 1WETH=0.025BTC), the attacker can get 112 WBTC with ~4,300 ETH. As a result, the attacker gains 71 WETH + 5,500 WETH — 4,300 ETH = 1,271 ETH, roughly $355,880 (assuming the ETH price of $280).

The magic under the hood is the fact how the Uniswap WBTC/ETH was manipulated up to 61.4 for profit. As mentioned in Step 3, the WBTC/ETH price was even pumped up to 109.8 when the normal market price was at only around 38. In other words, there is an intentional huge price slippage triggered for exploitation. However, such a huge price slippage should cause the bZx position not fully collateralized. But why the under-collateralized position will be allowed in the first place, which naturally leads to the discovery of a hidden bug in the bZx smart contract implementation.

In particular, the margin pump started from the function, marginTradeFromDeposit().

Figure 5: marginTradeFromDeposit()

As shown in Figure 5, marginTradeFromDeposit() invokes _borrowTokenAndUse() with the fourth parameter set as true in line 840.

Figure 6: _borrowTokenAndUse()

Inside _borrowTokenAndUse(), _getBorrowAmountAndRate() is invoked in line 1348 when amountIsADeposit is true. The returned borrowAmount would be stored in sentAmounts[1].

Figure 7: _borrowTokenAndUse()

Also in _borrowTokenAndUse(), sentAmounts[6] is filled with the value of sentAmounts[1] in line 1355 in the case of amountIsADeposit == true (we’ll see this later). Later on, _borrowTokenAndUseFinal() is called in line 1370.

Figure 8: _borrowTokenAndUseFinal()

In line 1414, _borrowTokenAndUseFinal() calls takeOrderFromiToken() through the IBZx interface such that the transaction flows into the bZxContract.

Figure 9: bZxContract::takeOrderFromiToken()

Here comes the interesting part. In line 145–153, there’s a require() call to check whether the position is healthy or unhealthy. Unfortunately, in the case loadDataBytes.length == 0 && sentAmounts[6] == sentAmounts[1], the sanity check bZxOracle::shoudLiquidate() would be skipped. That’s exactly the condition that the exploit triggered to avoid the sanity check.

Figure 10: bZxOracle::shouldLiquidate()

If we take a look into bZxOracle::shouldLiquidate(), the check getCurrentMarginAmount() <= loanOrder.maintenanceMarginAmount in line 514 would do the job by catching the margin pump step and thus preventing this attack.

PeckShield Inc. is an industry leading blockchain security company with the goal of elevating the security, privacy, and usability of current blockchain ecosystem. For any business or media inquiries (including the need for smart contract auditing), please contact us at telegram, twitter, or email.

Published at Mon, 17 Feb 2020 23:09:03 +0000