The following content was copied from samczun's bug bounty report on bugs that he discovered and were patched consequently by bZx and Fulcrum, this was not an actual exploit.
Introduction
bZx is a decentralized margin-trading protocol, while Fulcrum is a project built by the bZx team on top of bZx itself. One feature of Fulcrum is the ability to take a loan on an iToken (read more about that here) using any* other token as collateral. In order to determine how much collateral is needed, bZx uses the Kyber Network as an on-chain decentralized oracle to check the conversion rate between the collateral token and the loan token.
* if it's tradable on Kyber
However, it's important to first understand how the Kyber Network functions. Unlike most other DEXes, the Kyber Network derives liquidity from reserves (read more about that here). When a user wants to make a trade between two tokens A and B, the main Kyber contract will query all registered reserves for the best rate between A/ETH and ETH/B, then perform the trade using the two reserves selected.
Reserves can be listed automatically through the PermissionlessOrderbookReserveLister contract, which will create a permissionless reserve. Reserves can also be listed by the Kyber team on behalf of a market maker after KYC and legal requirements are met. In this case, the reserve will be a permissioned reserve. When conducting a trade using Kyber, traders have the option of only using permissioned reserves, or using all available reserves.
The attack
This means that if we can somehow increase the rate reported by a permissioned reserve, we can trick Fulcrum into thinking our collateral is worth more than it really is.
A permissioned OrderbookReserve
On June 16 2019, the Kyber team listed an OrderbookReserve for the WAX token as a permissioned reserve in this transaction. This was interesting because the statement "an OrderbookReserve is always permissioned" was considered to be axiomatic.
After this reserve was listed, the Kyber Network itself continued to perform according to specifications. However, we can now significantly affect the apparent exchange rate between WAX and ETH simply by listing an order, which means that we can trick any project which relies on Kyber to provide an accurate FMV.
Demo
The following script will turn a profit of approximately 1200ETH by:
Listing an order buying 1 WAX for 10 ETH, increasing the price from 0.00ETH/WAX to 10ETH/WAX
Borrowing DAI from bZx using WAX as a collateral
Cancelling all orders and converting all assets to ETH
The bZx team blocked this attack by whitelisting tokens which can be used as collateral.
Eth2Dai
Now that there's a whitelist on the tokens that can be used as collateral, we'll need to through all the permissioned reserves to see if there's anything else that we can abuse. It turns out that DAI, one of the whitelisted tokens, has a permissioned reserve which integrates with Eth2Dai. As Eth2Dai allows users to create limit orders, this is essentially the previous attack but with more steps.
Interestingly, we first observe that although the Eth2Dai contract is titled MatchingMarket, it's not strictly true that all new orders will be automatically matched. This is because while the functions offer(uint,ERC20,uint,ERC20,uint) and offer(uint,ERC20,uint,ERC20,uint,bool) will trigger the matching logic, the function offer(uint,ERC20,uint,ERC20) does not.
// Make a new offer. Takes funds from the caller into market escrow.
function offer(
uint pay_amt, //maker (ask) sell how much
ERC20 pay_gem, //maker (ask) sell which token
uint buy_amt, //maker (ask) buy how much
ERC20 buy_gem, //maker (ask) buy which token
uint pos //position to insert offer, 0 should be used if unknown
)
public
can_offer
returns (uint)
{
return offer(pay_amt, pay_gem, buy_amt, buy_gem, pos, true);
}
function offer(
uint pay_amt, //maker (ask) sell how much
ERC20 pay_gem, //maker (ask) sell which token
uint buy_amt, //maker (ask) buy how much
ERC20 buy_gem, //maker (ask) buy which token
uint pos, //position to insert offer, 0 should be used if unknown
bool rounding //match "close enough" orders?
)
public
can_offer
returns (uint)
{
require(!locked, "Reentrancy attempt");
require(_dust[pay_gem] <= pay_amt);
if (matchingEnabled) {
return _matcho(pay_amt, pay_gem, buy_amt, buy_gem, pos, rounding);
}
return super.offer(pay_amt, pay_gem, buy_amt, buy_gem);
}
Furthermore, we observe that even though the comments seem to suggest that only authorized users can call offer(uint,ERC20,uint,ERC20), there's no authorization logic at all.
// Make a new offer. Takes funds from the caller into market escrow.
//
// If matching is enabled:
// * creates new offer without putting it in
// the sorted list.
// * available to authorized contracts only!
// * keepers should call insert(id,pos)
// to put offer in the sorted list.
//
// If matching is disabled:
// * calls expiring market's offer().
// * available to everyone without authorization.
// * no sorting is done.
//
function offer(
uint pay_amt, //maker (ask) sell how much
ERC20 pay_gem, //maker (ask) sell which token
uint buy_amt, //taker (ask) buy how much
ERC20 buy_gem //taker (ask) buy which token
)
public
returns (uint)
{
require(!locked, "Reentrancy attempt");
var fn = matchingEnabled ? _offeru : super.offer;
return fn(pay_amt, pay_gem, buy_amt, buy_gem);
}
While in practice lack of authorization is irrelevant as arbitrage bots will quickly fill any orders that can be automatically matched, in an atomic transaction we can create and cancel arbitrage-able orders and no bots will be able to fill them.
All that's left is to slightly modify our script from the previous attack to place orders on Eth2Dai instead of the OrderbookReserve. Note that in this case we will need to call both order(uint,ERC20,uint,ERC20) to submit the order to Eth2Dai without it being atomically matched, and then insert(uint,uint) in order to manually sort the order without triggering matching.
Demo
The following script will turn a profit of approximately 2500ETH by:
Listing an order buying 1 DAI for 10 ETH, increasing the price from 0.006ETH/DAI to 9.98ETH/DAI.
Borrowing ETH from bZx using DAI as collateral
Cancelling all orders and converting all assets to ETH
The bZx team blocked this attack by modifying the oracle logic such that if the collateral and loan token were both either DAI or WETH, then the exchange rate would be loaded directly from Maker's oracles.
However, this solution was incomplete because of the way Kyber resolves the best rate. If you'll recall, Kyber determines the best rate for A/B by determining the best rate for A/ETH and ETH/B, then calculating the amount of B that could be bought with the ETH received by trading A.
This meant that if we were to attempt to borrow a non-ETH token such as USDC using DAI as collateral, Kyber would first determine the best exchange rate for DAI/ETH, then the best rate for ETH/USDC, and finally the best rate for DAI/USDC. Because we can artificially increase the exchange rate for DAI/ETH, we can still manipulate the exchange rate for DAI/USDC even though we don't control a permissioned USDC reserve.
The bZx team blocked this attack in two ways:
If either the loan token or collateral token wasn't ETH, then bZx would manually determine the exchange rate between the token and ETH, unless
The loan token or collateral token was a USD-based stablecoin, in which case bZx would use the rate from Maker's oracle
Uniswap
An astute reader may notice at this point that bZx's solution still does not handle incorrect FMVs for arbitrary tokens. This means that if we can find another permissioned reserve which can be manipulated, we can take out yet another undercollateralized loan.
After sifting through all the registered permissioned reserves for the whitelisted tokens, we notice that the REP token has a reserve which integrates with Uniswap. We already know from our attacks on DDEX that Uniswap's prices can be manipulated, so we can re-purpose our previous attack and substitute Eth2Dai and DAI for Uniswap and REP.
Demo
The following script will turn a profit of approximately 2500ETH by:
Performing a large order buy on Uniswap's REP exchange, increasing the price from 0.05ETH/REP to 6.05ETH/REP
Borrowing ETH from bZx using REP as collateral
Cancelling all orders and convert all assets to ETH
The bZx team reverted their changes for the previous attack and instead implemented a spread check, such that if the spread was above a certain threshold then the loan would be rejected. This solution handles the generic case so long as both tokens being queried has at least one non-manipulable reserve on Kyber, which is currently the case for all whitelisted tokens.
When bZx checks the price of a collateral token, it specifies that only permissioned reserves should be used. This decision was made based on the Kyber whitepaper at the time, with the logic being that permissioned reserves had to undergo review and so the rates should be "correct".Source, Credit: Kyber Network