On September 2, Bunni was exploited for ~$8.4m by a sophisticated attacker. Two pools were affected: weETH/ETH on Unichain and USDC/USDT on Ethereum.

The transactions can be found here:

Here is our analysis on how the exploit worked, what went wrong, and what we can do next.

Exploit Analysis

The two pools were exploited in largely the same way, and in this analysis we will use the USDC/USDT pool as the example.

The exploit consisted of three steps:

  1. Swap with flashloaned funds
    • The attacker first flashborrowed 3m USDT then made multiple swaps from USDT (token1) to USDC (token0), and the spot price tick of the pool is pushed to 5000 which corresponds to 1 USDC = 1.68 USDT.
    • Crucially, the pool’s active balance in USDC, i.e. the amount of USDC used to provide swap liquidity, is decreased to a small value of 28 wei.
  2. Large number of tiny withdrawals
    • The attacker made 44 tiny withdrawals that exploited rounding errors to decrease the USDC active balance from 28 wei to 4 wei, which is a 85.7% decrease that’s disproportionate to the amount of liquidity shares being burnt.
    • This caused the total liquidity of the pool to erroneously decrease by 84.4% from 5.83e16 to 9.114e15.
  3. Sandwich attack
    • With the liquidity decreased, the attacker made a large swap from USDT to USDC, such that the spot price tick was pushed more than it should have been to 839189 which corresponds to 1 USDC = 2.77e36 USDT.
    • The first swap caused the total liquidity of the pool to increase by 16.8% from 9.114e15 to 1.065e16 which is a reversion of the liquidity decrease in step 2.
    • The attacker then made a second swap from USDC to USDT at the inflated prices. Because of the liquidity increase, the attacker was able to extract a profit.
    • After repaying the flashloan from step 1, the attacker was left with ~1.33m USDC and ~1m USDT of profit.

Essentially, the attacker constructed an atomic liquidity increase that they were then able to sandwich.

After the exploit, there were several attempts at analyzing the exploit by others (example1 example2). They suggested that the issue may stem from Bunni’s rebalancing/shifting features. These analyses were wrong, as the pool liquidity did not shift or rebalance during the exploit.

A more detailed analysis by Cyfrin can be found here.

Why Wasn’t Unichain USDC/USD₮0 Exploited?

While we were analyzing the exploit, one question loomed over our minds: Why was the largest Bunni pool, Unichain USDC/USD₮0, not exploited? What spared it?

Analysis from Cyfrin suggested the answer: luck.

Step 1 of the exploit requires flashborrowing a large amount of tokens from an external venue (Uniswap v3 and Morpho were used) in order to use swaps to push the pool’s spot price to extreme values. On Unichain, there simply wasn’t enough flashloan liquidity. The largest venue was Euler whose USD₮0 vault had ~11m cash balance, but an estimated ~17m flashloan was needed to exploit the USDC/USD₮0 pool due to its size. Uniswap v4 couldn’t be used for flashloans in step 1 since step 2 made withdrawals that also interacted with Uniswap v4 and would’ve failed if Uniswap v4 was already being used by step 1.

It also didn’t help that millions of the pool’s assets was rehypothecated to Euler and lent out to borrowers, which made those assets safe from being exploited.

What Went Wrong

TL;DR: A rounding direction that’s safe in the context of a single operation may not be safe in the context of multiple operations.

The key to the exploit was the erroneous liquidity decrease resulting from the tiny withdrawals. It stemmed from this line in BunniHubLogic::withdraw() that handles the pool’s idle balance update.

// decrease idle balance proportionally to the amount removed
{
    (uint256 balance, bool isToken0) = IdleBalanceLibrary.fromIdleBalance(state.idleBalance);
>>> uint256 newBalance = balance - balance.mulDiv(shares, currentTotalSupply); // this line
    if (newBalance != balance) {
        s.idleBalance[poolId] = newBalance.toIdleBalance(isToken0);
    }
}

A pool’s balance has two parts: the active balance, which is used to provide swap liquidity, and the idle balance, which is not part of the swap liquidity. Note that this concept is orthogonal to rehypothecation: both the active balance and the idle balance may be partially rehypothecated to a lending protocol.

What this code block does is that when a liquidity provider withdraws their LP shares, the pool decreases the pool’s idle balance proportionally. balance.mulDiv(shares, currentTotalSupply) is the decrease amount, and it was intentionally rounded down during development. The assumption was that this would round up the idle balance and thus round down the active balance, which was considered the safe rounding direction since lower liquidity meant more price impact during swaps and thus is in favor of the pool.

This assumption was unfortunately wrong, and the attacker used the rounding direction to their advantage. The attacker used the series of tiny withdrawals to round down the USDC active balance from 28 wei to 4 wei, which is a 85.7% decrease that’s disproportionately large compared to the amount of liquidity shares being burnt. This caused the pool’s total liquidity to disproportionately decrease.

Underestimating the total liquidity by itself is safe, but if this unnatural underestimation gets reversed then we’d have an increase in total liquidity which is not safe and can be sandwiched. This was exactly what happened during step 3 of the exploit.

To understand why the liquidity increased in step 3, you need to understand how a Bunni pool’s total liquidity is computed.

bool noToken0 = balance0 == 0 || totalDensity0X96 == 0;
bool noToken1 = balance1 == 0 || totalDensity1X96 == 0;
uint256 totalLiquidityEstimate0 = noToken0 ? 0 : balance0.fullMulDiv(Q96, totalDensity0X96);
uint256 totalLiquidityEstimate1 = noToken1 ? 0 : balance1.fullMulDiv(Q96, totalDensity1X96);
bool useLiquidityEstimate0 =
    (totalLiquidityEstimate0 < totalLiquidityEstimate1 || totalDensity1X96 == 0) && totalDensity0X96 != 0;

Bunni’s Liquidity Density Function (LDF) contract returns the amount of each token a unit of liquidity corresponds to (totalDensity0X96 and totalDensity1X96). Bunni computes two estimates of the pool’s total liquidity using the active balances of the pool balance0 and balance1, and then uses the smaller estimate with the same assumption as before that underestimating liquidity is safe.

The series of withdrawals in step 2 of the exploit disproportionately reduced totalLiquidityEstimate0, the estimate based on the USDC active balance. At the same time totalLiquidityEstimate1 remained correct since balance1, the USDT active balance, was large enough to avoid the effects of rounding. As a result, totalLiquidityEstimate0 is used as the total liquidity since it’s smaller.

In step 3 of the exploit, the first swap pushed the price more than it should have due to the liquidity decrease in step 2, such that the pool reached this state:

useLiquidityEstimate0 false
totalLiquidityEstimate0: 237684487542793012780631851008, totalLiquidityEstimate1: 10647207614202719, balance0: 3
balance1: 10000002915014489519, totalDensity0X96: 1, totalDensity1X96: 74412173106968428796619613886602

Due to the drastic price change from the swap, totalDensity0X96 became a miniscule 1 wei since the pool should now have basically no USDC (token0), and as a result totalLiquidityEstimate0 became enormous (2.377e29). Thus, totalLiquidityEstimate1 is used as the total liquidity. This would have been fine in isolation had total liquidity not decreased so much during step 2 of the exploit. However, totalLiquidityEstimate1 is now 1.065e16, which is smaller than the initial 5.83e16 before the exploit due to the price having moved more than it should have, but it’s still larger than the erroneously small value of 9.114e15 after step 2. This led to the liquidity increase that was then sandwiched by the attacker.

To summarize, all of the rounding directions involved were safe in isolation, but when multiple operations are involved they led to an exploit. When we changed the rounding direction for updating the idle balance during withdrawal

- uint256 newBalance = balance - balance.mulDiv(shares, currentTotalSupply);
+ uint256 newBalance = balance - balance.mulDivUp(shares, currentTotalSupply);

the exploit became no longer profitable due to total liquidity no longer decreasing after step 2 of the exploit.

The Road Ahead

Fund Recovery

The stolen funds are currently stored in these two wallets:

We attempted to identify the attacker by tracing the wallet funding paths, but we reached a dead end since the wallets were ultimately funded via Tornado Cash. Barring identification, we have done the following:

  • Contacted the attacker onchain with an offer to give them 10% of the stolen funds if the remainder is returned.
  • Informed centralized exchanges of the wallets related to the attacker to prevent offramping the stolen funds.
  • Engaged law enforcement so that if the funds are not returned we can pursue all necessary legal means.

We will explore every avenue we can to recover the stolen funds.

Unpausing Withdrawals

Given our understanding of the exploit, unpausing only withdrawals while keeping all other operations like deposits and swaps paused is most likely safe to do. The exploit relied on making swaps, which is not possible in withdraw-only mode.

Cyfrin tested withdrawals via fork testing and it appeared to be working correctly. Details can be found here.

As a result, we have unpaused withdrawals on all networks. Liquidity providers are now free to withdraw their assets from Bunni. All other functions of Bunni, including deposits and swaps, remain paused.

Fixing the Code

We are still exploring what fixes are needed to make Bunni secure again. Changing the rounding direction of idle balance updates stops the current exploit, but it’s unclear if this change will introduce new attack vectors.

Whatever the fix may be, it’s clear that a better testing framework is needed. We have Foundry unit tests and fuzz tests as well as Medusa fuzz tests, but they did not cover the scenario that occurred during the exploit. We need to build up the fuzz test framework so that more complex scenarios are covered, as well as build up an invariant testing framework.

Needless to say, there’s a lot of work to be done before Bunni can be up-and-running once more.

Final Words

This exploit was a horrible thing that’s been hard on Bunni’s users as well as our team.

We’re a small team of 6 people who are passionate about building in DeFi and pushing the industry forward. We spent years of our lives and millions of dollars to launch Bunni, because we firmly believe it is the future of AMMs and will go on to process trillions of dollars in value. We did not fork someone else’s protocol and call it our own, because we prefer building actually valuable things and bringing forth new innovations.

And we did that. We built Bunni, an AMM that’s a full generation ahead of anything else, introducing new and powerful concepts like LDFs that will outlive all of us. Regardless of what happens, we will continue to build Bunni and invent the future of DeFi, and we hope you will join us on this journey.

Ackowledgments

After the exploit we received generous help from many people, for which we say: thank you.

Special thanks to:

  • Gio from Cyfrin for helping analyze the exploit and assisting every step of the way
  • Dan from Hypernative for organizing the war room and offering guidance on what to do
  • The Euler team for immediately offering assistance and guidance after the exploit
  • Certora for helping with ideas for fixes
  • The Bunni community for bearing with us through it all