KyberSwap Hack Analysis (11/22)

Seungmin Jeon
11 min readNov 30, 2023

Introduction

On November 22nd, KyberSwap was attacked and over $46M in funds were stolen. In this article, we’ll take a look at what led to this attack.

The hack occurred on KyberSwap Elastic, which uses a concentrated liquidity mechanism, which is invented by Uniswap V3. However, the codebase is slightly different from Uniswap V3, as the code was modified to implement concentrated liquidity before V3 was unlicensed. However, the modified code contained a vulnerability that led to the hack.

The hacker was able to extract all of the tokens from all of Elastic’s pools in just three swaps, one liquidation and one withdrawal.

To understand the hack, let’s take a quick refresher on the structure of centralized liquidity: DEXs that use centralized liquidity typically express prices in units called ticks, which can be used to slice and dice liquidity very finely.

(Tick Bitmap | Source: Uniswap V3 Book)

If a swap occurs, it will execute the requested swap amount within the interval from one tick to the next. If the swap uses up the liquidity in that tick range, it will move on to the liquidity in the next tick, fetch the liquidity information in that tick range, and continue the swap. This is repeated until

- it uses up all of the swap amount
- or the slippage limit is reached.

It’s also important to point out that the vulnerability in this hack only exists in KyberSwap Elastic. Other concentrated liquidity protocols like Uniswap or Ambient do not have this vulnerability.

Hack Overview

Let’s now follow the hacker’s transactions one by one in the frxETH-WETH pool (where the first hacked transaction occurred) to see where things went wrong.

  1. Flashloans

    First, the hacker borrowed 2,000 WETH from Aave via a flashloan and puts a swap request into the pool, where he put in a swap request for the entire 2,000 WETH and places a slippage limit, moving the tick of the pool from -24 to 110909. The point here was to move the price by a tick with zero liquidity.

2. Supplying and withdrawing liquidity

He then supplied liquidity consisting of 0.0069 frxETH and 0.1078 WETH in the tick range from 110909 to 111310, and then immediately burned some of it, leaving only 0.0058 frxETH and 0.01 WETH in that range. This was to adjust the numbers so that the calculations used in the next swap are just right (more on that later).

3. Two Swaps (Swap 1, Swap 2)

The hacker then executed two swaps back and forth at this price. Since he was the only liquidity provider and there is no other liquidity inside the tick range, these two swaps would originally be nothing more than back-and-forth swaps against his own liquidity.

The first swap (Swap 1) involved taking 387.17 WETH and buying 0.00579 frxETH, which sent the pool’s price to the upper boundary of the tick at 111310 and turned all of the liquidity into WETH.

The second swap (Swap 2) was the opposite, with 0.00586 frxETH to buy 396.24 WETH. This moved the price of the pool to 111105, a tick within the range of liquidity supplied by the hacker.

In the above two swaps, the hacker took 387.17 WETH and got 396.24 WETH, draining the pool for a profit of about 9 WETH. How did they do it?

Detailed Analysis

To start with the bottom line, the hacker used a very sophisticated attack on the way liquidity is calculated within the KyberSwap codebase to trick the pool into thinking it had double amount of liquidity. Here’s the starting point of the problematic code:

As mentioned above, the swap is performed in a while loop until a set amount of liquidity is used up or a set slippage limit is reached. If the liquidity for the current tick range is used up, the _updateLiquidityAndCrossTick function at the end of the code above will be called to retrieve the liquidity for the next tick and then move on to the next tick to continue the same calculation.

However, when the break inside the if statement above is called, it immediately exits the while statement without allowing the _updateLiquidityAndCrossTick below to be called. Let’s take a closer look at the conditions that trigger this if statement.

The above if statement is called after finishing calculating the swap within one tick range, where swapData.nextSqrtP is the price at the next tick. In other words, the syntax is saying, “Finish calculating the swap and if you don’t get to the next tick, end the swap calculation”.

The problem is that the hacker bypassed this, causing the price to cross the tick after finishing the swap. Normally, this syntax would work as intended, but the hacker made the current price swapData.sqrtP cross the price swapData.nextSqrtP at the next tick, causing the while statement to terminate and prevent the liquidity from being updated. This caused an error in the pool calculation, allowing the hack.

Why didn’t KyberSwap have a mechanism to prevent this attack? Before the above syntax is called, the computeSwapStep function, which calculates the swap result within a tick interval, first calculates the upper limit of the amount that can be swapped via the calcReachAmount function before the tick is reached. In other words, the attack shouldn’t have happened as it did.

However, the hacker was able to manipulate the calculations and execute the hack in the following way.

  1. The hacker requests just one less swap (387170294533119999999) than the amount of swaps (387170294533120000000) that will reach the tick (we’ll explain why later).
  2. The computeSwapStep function will then calculate that enough liquidity will be written to cover the tick.
  3. However, if calculating the price through calcFinalPrice, which is to calculate the price that changed based on the swap, we get a price that is above the tick.

The key was to leverage the unique properties of KyberSwap to make the calculation meaningless.

KyberSwap has a feature called “liquidity reinvestment”. Uniswap V3 had the disadvantage that the fees generated by the pool were stored separately from the liquidity, requiring LPs to manually reinvest or reclaim them, and liquidity reinvestment is a feature that aims to improve this. As a result, the fees generated by the pool in KyberSwap Elastic are stored in the form of liquidity called reinvestL in the pool, allowing LPs to enjoy the benefits of compounding.

Here’s where it had issues:

In the computeSwapStep function, the liquidity input is swapData.baseL, the liquidity that exists within the current tick range, plus swapData.reinvestL, the liquidity that exists globally in the pool. Based on this liquidity, the usedAmount is calculated and compared to the requested amount, as shown below.

Calculating usedAmount at computeSwapStep function

If swapData.reinvestL is not added, then usedAmount is less than specifiedAmount, and the price to be returned by the else statement is fixed at targetSqrtP, which is nextSqrtP. In other words, the price cannot be manipulated in this situation.

However, if swapData.reinvestL is added to the total liquidity, then usedAmount is greater than specifiedAmount and the following in the if statement is executed.

The specifiedAmount is calculated to be the value of liquidity just before the tick, which means that the usedAmount calculated in this function will result in the swap not going over the tick.

On the other hand, the price calculated below will return a value that goes over the tick. The calcFinalPrice function that calculates the price has the following structure:

calcFinalPrice function at SwapMath.sol

absDeltais equal to the amount of swap requested. In addition, deltaL is the amount of liquidity that is automatically reinvested back into the pool from the fees incurred by the swap you are currently performing, which is a very small value here.

Based on this, the price value returned by calcFinalPrice can be expressed as a formula.

The hacker wants to increase this value as much as possible to make the price move out of the tick, which is why they set absDelta as high as possible (by one less than the amount it would theoretically reach in a tick).

So the price returned will be above the price at the tick, and the while statement will end as break in the syntax below is called.

In the end, the swap has moved the price beyond the tick and into a new tick range, but because _updateLiquidityAndCrossTick hasn’t been called, the pool doesn’t modify the liquidity. Originally, when you enter a new tick range, _updateLiquidityAndCrossTick should have brought the liquidity for that tick with it, and since we’re out of the liquidity range here, the liquidity for that tick range should be recognized as zero. However, since _updateLiquidityAndCrossTick was not called, the liquidity for that tick range is recognized as the same as the liquidity for the previous range, meaning we have tricked the pool into calculating double the liquidity.

Swap 2 is a very normal swap. If you execute the swap in the opposite direction, the price will cross a tick, and the _updateLiquidityAndCrossTick function will be called to proceed with the swap. Here, the pool will double-check liquidity and proceed with the swap as if liquidity had doubled during the interval.

Here, the hacker has supplied the same amount of liquidity that was in the pool before the attack, meaning that he was able to trick the pool into moving the liquidity that was in the pool before the attack to near the tick he specified and drain the pool.

Let’s illustrate this whole process to make it easier to understand.

Let’s say the total liquidity that was initially in the pool was 80M (it’s actually much larger). The hacker made the first swap, buying all the frxETH in the pool and moving the price to tick 110909.

The hacker then supplied liquidity in the interval between tick 110909 and tick 111310. Importantly, the total liquidity supplied by the hacker was 80M.

The hacker proceeded with swap 1. As mentioned above, the swap used up the liquidity supplied by the hacker and then pushed the price slightly above tick 111310.

However, _updateLiquidityAndCrossTick was not called at this point, meaning that the liquidity configuration calculated by the pool looks like this:

Since the liquidity was 80M in the tick before swap 1, and this wasn’t updated when it crossed the tick, the pool would still recognize the liquidity as 80M in the next tick. So while there should be some WETH around tick -24 originally, due to the manipulation of the swap price calculation, it would be calculated as if it were around tick 111310.

This is why the hacker supplied 80M of liquidity. By supplying the same amount of liquidity that existed before the attack, the hacker can move all of the liquidity near the intentionally manipulated price.

The hacker then executed swap 2. Before Swap 2 was executed, there was a total of 160M worth of liquidity, all in WETH, which is a swap that converts all of it to frxETH and takes all of the WETH in the pool.

The event logs that occurred as a result of swaps 1 and 2 confirm that this is how the swap actually worked. Before executing swap 1, the liquidity for that tick range was 74692747583654757908. If Swap 1 had executed normally and _updateLiquidityAndCrossTick had been executed when it crossed the tick range, the liquidity captured in the swap event should have been 0. However, if we look at the events generated by the actual Swap 1, we see that the liquidity is still 74692747583654757908.

Correspondingly, the event log from swap 2 calculates the liquidity to be 149385495167309515816, which is exactly twice as much.

The hacker performed this type of attack against all pools on KyberSwap Elastic, draining the pools and stealing approximately $54.7M in funds. At this time, the hacker does not appear to have responded to any repayment demands from Kyberswap. Additionally, during this attack, a negotiation was made with the owner of the frontrun bot, who had made approximately $5.7M in arbitrage profits, resulting in the return of approximately $4.67M in funds.

Conclusion

This KyberSwap hack was highly sophisticated. The hackers

  • Identified a vulnerability in the swap calculation within the concentrated liquidity mechanism, and
  • Calculated all the swap and liquidity supply inputs that could be used to exploit the protocol.

Due to the nature of concentrated liquidity protocol, the calculation was composed of so many equations, making other hackers and auditors really difficult to find the vulnerability. Hayden Adams also said that the Concentrated AMM development is one of the darkest forests in crypto, and the Uniswap devs are investing tons of efforts in testing and ensuring security.

Hacks are becoming increasingly sophisticated. The Curve Finance hack in August targeted a vulnerability in the Vyper language itself, while the KyberSwap hack broke the protocol by finding a tiny edge case in a very complex computational logic.

If hacks like these continue to occur at the app end, people’s trust in blockchains could be eroded to the point of no return. I hope that continued efforts on security at the builder level, as well as extensive research on contract security and proper rewards for bug reporters, will create an environment where users can trust blockchain apps more.

Reference

--

--