bZx Hack Full Disclosure (With Detailed Profit Analysis)
On 02/15, we have provided a transaction-level recap on the bZx hack that recently captures various headlines in DeFi-related tweets and media. There are quite a few misunderstandings circulating around about the nature of this particular hack. We emphasize that this is not an oracle attack. Instead, it is a clever arbitrage execution, which did exploit a bug in bZx smart contract implementation to allow for the leakage of supposedly-locked bZx funds to Uniswap and further absorb the leaked funds into a Compound position. In this blog, we’d like to provide a full disclosure of the hack with an in-depth profit analysis, just as promised in our previous blog.
Five Exploitation Steps For Arbitrage
The culprit transaction is 0xb5c8bd9430b6cc87a0e2fe110ece6bf527fa4f170a4bc8cd032f768fc5219838, which was mined at 2020–02–15 01:38:57 +UTC at the block height #9484688. As shown in the above figure, this attack can be separated into five distinct steps: Flashloan Borrow, Hoard, Margin Pump, Dump, Flashloan Repay. In the following, we examine each specific step.
1: Flashloan Borrow. This step basically takes advantage of the dYdX flashloan feature to borrow 10,000 ETH. This part is already known and we will not go into the details.
After this step, we notice the attacker has the following asset breakdown. There is no gain yet.
2: Hoard. With the borrowed flashloan, the attacker deposits 5500 ETH into Compound as collateral to borrow 112 WBTC. This is a normal Compound operation and this hoarded WBTC is to be dumped in Step 4.
After this step, we notice the following changes regarding the attacker-controlled assets. Apparently, there is still no gain yet.
3: Margin Pump. After hoarding, this step takes advantage of the bZx margin trade feature to short ETH in favor of WBTC (i.e., sETHwBTCx5). In particular, the attacker deposits 1300 ETH and calls bZx margin trading function, i.e., mintWithEther
(that cascadingly invokes marginTradeFromDeposit
). The margin trading function leverages KyberSwap to swap the borrowed 5637.623762 ETH for 51.345576 WBTC in return. Notice that it is 5x borrow to short ETH. The swap essentially drives up the conversion rate of 1 WBTC to around 109.8 WETH, roughly triple the normal conversion rate (~38.5 WETH/WBTC).
Specifically, to complete this trade, bZx forwards the order to KyberSwap, which then essentially consults its reserves and finds the best rate. It turns out to be the KyberUniswap reserve. This step essentially drives the WBTC price up in Uniswap three times higher.
It should be noted that this step should be thwarted by the built-in sanity check, which verifies the position will not go default after the swap. However, this check did not kick in when the attack occurs and we examine the details later in the smart contract bug section.
After this step, we notice the following changes regarding the attacker-controlled assets. Still, there is no gain yet after this step.
4: Dump. With the spiked WBTC price in Uniswap, the attacker sells the Compound-borrowed 112 WBTC back for WETH in Uniswap.
This dump step leads to the net of 6871.4127388702245 ETH in return with the overall conversation rate of 1WBTC=61.4 WETH. After this step, the attacker observes substantial profits with the following asset breakdown.
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).
bZx Smart Contract Bug
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()
.
As shown in Figure 5, marginTradeFromDeposit()
invokes _borrowTokenAndUse()
with the fourth parameter set as true
in line 840.
Inside _borrowTokenAndUse()
, _getBorrowAmountAndRate()
is invoked in line 1348 when amountIsADeposit
is true
. The returned borrowAmount
would be stored in sentAmounts[1]
.
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.
In line 1414, _borrowTokenAndUseFinal()
calls takeOrderFromiToken()
through the IBZx interface such that the transaction flows into the bZxContract
.
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.
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.
Here we’d also like to thank Bloxy for the wonderful tools we used to generate some of the diagrams in this article.
About us
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.