Hack Analysis: BonqDAO, February 2023

2 months ago 16

The BonqDAO protocol was hacked on February 1st, 2023, losing around $120m in funds to a price oracle manipulation attack. A vulnerable price feed implementation allowed the attacker to momentarily change the price of the WALBT token to borrow much more protocol stablecoin (BEUR) then they were entitled to.

About two minutes after the first transaction, the attacker changed the oracle price once again, but this time to a very small value, leading the great majority of borrowers to move into a position to be liquidated. This devious tactic allowed the hacker to liquidate over 30 under-collateralized troves, collecting an impressive loot of approximately 113M WALBT.

In this article, we will be analyzing the exploited vulnerability in the BonqDAO contracts, and then we’ll create our own version of the price oracle manipulation in a two-step attack, testing it against a local fork. You can check the full PoC here.

This article was written by gmhacker.eth, an Immunefi Smart Contract Triager.

BonqDAO is a non-custodial, over-collateralized lending protocol. It allows any project or protocol to borrow against their own token at a zero interest rate. It is deployed on the Polygon blockchain.

Users lock their assets as collateral in a trove, which is a smart contract only controlled by the users, and then they can mint BEUR, a stablecoin pegged to the Euro. Users can always swap 1 BEUR for 1 EUR worth of collateral (minus fees) directly on the BonqDAO protocol. When debt is paid, BEUR tokens get burnt.

Troves have a minimum collateralization ratio. If they fall below it, anybody can liquidate the trove: the borrower keeps their borrowed BEUR, but the trove gets closed with no possibility of getting the collateral back (which is bought by the liquidator at a discount, to incentivize liquidation events).

Having a rough understanding of what the BonqDAO protocol is, we can dive into the actual smart contract code to explore the root cause vulnerability leveraged in the February 2023 hack. To do that, we need to dive into the code of the TellorFlex contract, Bonq’s oracle system. We’re particularly interested in the submitValue function.

Snippet 1: submitValue function in TellorFlex.sol

The submitValue function in the TellorFlex contract allows a reporter to submit a value to the oracle. Importantly, this function is permissionless, which means that anybody can report a value to a given queryId, provided some requirements are met:

The nonce is a value that makes sense.Reporter has staked a minimum necessary amount of TRB tokens.Reporter cannot report another value during a certain timelock.There is no other reported price for the same queryId with the same timestamp.

The problem is not that this reporting task is permissionless. Rather, the problem is that throughout the protocol contracts the spot price is considered to be the last reported value. Because of that, anybody can momentarily inflate or deflate the value of a given price feed and do damage to the protocol.

Now that we understand the vulnerability that compromised the BonqDAO protocol, we can formulate our own proof of concept (PoC). We will do two separate transactions, mimicking the way the original hacker exploited the protocol. The first transaction will report a very large price for WALBT, leading to a very large borrow. The second transaction, which will happen after one minute, will dangerously deflate the price value to be able to liquidate a large amount of troves in one go.

We’ll start by selecting an RPC provider with archive access. For this demonstration, we will be using the free public RPC aggregator provided by Ankr. We select the block number 38792977 as our fork block, 1 block before the first hack transaction.

Our PoC needs to run through a number of steps to be successful. Here is a high-level overview of what we will be implementing in our attack PoC:

Report a very large value for the spot price of ALBT.Create a WALBT trove, deposit a modest amount as collateral and borrow $100M worth of BEUR. This is possible due to the inflated WALBT price.Create another WALBT trove with a bigger quantity of collateral. This trove will be used to buy off all the liquidated collateral.Let at least 1 block get minted so that we can report a price on a new timestamp.Report a very small value for the spot price of ALBT.Liquidate all WALBT troves in debt.Use BEUR to buy the liquidated collateral.

Let’s code one step at a time, and eventually look at how the entire PoC looks. We will be using Foundry.

Snippet 2: interfaces.sol, with the interfaces we need

Let’s begin by creating our interfaces.sol file, where we will define the various functions we’re going to use on the protocol’s contracts. We’re dealing with 3 different key contract ABIs: TellorFlex, Trove and OriginalTroveFactory.

The TellorFlex contract is an implementation of the Tellor decentralized oracle protocol, responsible for recording reported market data. The Trove contracts are the aforementioned smart contracts controlled by the users, where their collateral and debt are recorded. The OriginalTroveFactory contract is responsible for creating new troves and keeping track of existing ones.

Besides these interfaces, we will be using the standard ERC20 interface, which is provided in the forge-std library.

Snippet 3: The structure of our Attacker contract

As already stated, we’re going to have two different attacking transactions separated in time: one that will borrow much more than what is allowed, and another that will liquidate all troves. This division is properly implemented in our Attacker contract, where we have a function for each of those transactions: attackBorrow and attackLiquidate.

Each of these external functions has three steps marked by internal functions.


_submitValue — it will change the spot price of ALBT to a very large value._createFirstTroveAndBorrow — responsible for opening the first trove and borrowing a large sum of BEUR._createSecondTrove — responsible for opening the second trove. Both the first and the second troves will be stored in global variables for usage inside other functions.


_submitValue — it will change the spot price of ALBT to a very small value._liquidateTrovesInDebt — responsible for liquidating all WALBT troves that are now in debt due to the sudden price change._buyCollateral — it will pay for all the liquidated collateral.
Snippet 4: _submitValue function

We start by coding the internal function _submitValue. The first step is to deploy a contract whose sole purpose is to delegate back to our Attacker contract with the right calldata. As we’ve seen in Snippet 1, we want to do this so that we are not susceptible to the reporting timelock, i.e. we deploy a new reporter entity every time we want to report a new value. The contract deployment logic will be implemented inside _deployDelegatooor.

We ask the TellorFlex contract what is the necessary stake required to report a value, and we transfer that amount to our new delegatooor contract. Finally, we pass on the input value and the function signature of updatePrice, which will be an external function implemented in our Attacker contract, with the objective of staking the amount and reporting a new value for the desired price feed.

Snippet 5: functions _deployDelegatooor and updatePrice

The _deployDelegatooor function deploys a new contract just like the trove factory deploys a new trove inside createTrove. This method, in turn, is almost identical to the one implemented by OpenZeppelin’s Clone library. The bytecode of this implementation just encodes a delegatecall of the received calldata to the address of the Attacker, as can be seen in the following mnemonic representation portion of the bytecode.

Mnemonic representation of delegatooor from evm.codes

The updatePrice function implements the logic that we want delegatooor contracts to delegatecall to. It starts by staking the necessary amount of TRB using TellorFlex.depositStake. Then it encodes the query data for the feed we want: the spot price of ALBT/USD. This encoding is described in the Tellor Docs.

The received value will be submitted to TellorFlex using the submitValue function to report a new price for our feed. The delegatooor address will enter into a time lock to prevent it from reporting another value in the near future. But since we deploy a new delegatooor contract each time we want to submit a new price, we bypass this check. This concludes the logic in _submitValue.

Snippet 6: functions _createFirstTroveAndBorrow and _createSecondTrove

We proceed with the implementation of the two other internal functions required by the first attack transaction. The _createFirstTroveAndBorrow will create a WALBT trove by calling OriginalTroveFactory.createTrove. To be a WALBT trove means that WALBT is the supplied collateral. The function Trove.increaseCollateral allows for depositing more collateral into the trove through 2 different possible flows:

Approve the usage of WALBT and call increaseCollateral with the desired amount to deposit.Transfer the amount of WALBT directly to the trove contract and call increaseCollateral with the amount set to zero.

Here we are following the second flow, the same one used by the original attacker.

The amount of supplied collateral is 0.1 worth of WALBT, which is not very significant under normal circumstances. However, the price was changed to 5 billion, which means we can borrow very large amounts of BEUR. We call Trove.borrow to mint 100M BEUR.

The _createSecondTrove will create a new WALBT trove and supply collateral in the same fashion. This time, though, we supply a larger amount — 13 WALBT, similar to the amount chosen by the hacker. This concludes the work of our first transaction.

Snippet 7: _liquidateTrovesInDebt

The second transaction, as already shown, starts with a new price update to a very small number, followed by liquidateTrovesInDebt. This function is broken down into two steps:

Collect all WALBT troves — the factory contract tracks all existing troves, so we can use its methods to aggregate all WALBT trove addresses in one single list.Liquidate troves in debt — we iterate through our list of addresses to check which troves are in debt. We liquidate each one of them by calling Trove.liquidate.
Snippet 8: _buyCollateral

The final step of the second transaction is to collect the loot using the borrowed BEUR. To make sure we get the maximum amount of liquidated collateral as possible, we pass maximum values to ERC20.approve and to Trove.repay. Noticeably, we call repay on the second trove we created. The liquidated collateral will be transferred to the second trove, and Trove.decreaseCollateral is used to withdraw all WALBT funds.

This completes the entire exploit logic. If we count the Foundry logs and comments, our PoC still amounts to only 158 lines of code.

It should be noted that this attack requires some funds for staking and for supplying collateral — TRB and WALBT. Besides that, there needs to be a wait period between the two attack transactions, i.e. they cannot happen on the same block (otherwise they would have the same report timestamp). This is how our Foundry test script accomplishes those things:

Snippet 9: the Foundry test contract

If we run this PoC against the forked block number, we will finish with 113,796,981 WALBT.

The BonqDAO exploit was a very large hack kicking off the year 2023. The attack stresses the importance of using oracle protocols correctly. In this particular case, we’ve learned the spot price — the last reported price — should never be used as a safe value with which to calculate debt, interests or any sort of conversions. One should use either time-weighted average values or decentralized price feeds like Chainlink.

The BonqDAO protocol completed an airdrop to compensate for user losses, and there are plans to continue working towards a BonqDAO 2.0 version of the protocol. You can check their recovery/reboot proposal article here, as well as their incident report.

This is what our entire PoC looks like.

Snippet 10: All code.
Read Entire Article