Back to all stories
Reports
Incident Analysis
KyberSwap Elastic
11/24/2023
KyberSwap Elastic

Summary

On Nov 22nd (+UTC) 2023, KyberNetwork suffered multiple flashloan exploits across several chains resulting in a loss of ~$47M worth of assets. Using carefully calculated operations on liquidity pools (LPs) at empty tick ranges, the attacker exploited the cross swap liquidity counting to drain many KyberSwap pools containing low liquidity.

Background

For this exploit it's important to understand how concentrated liquidity market makers work.

The basic automated market makers implement a standard constant product curve (x*y=k) where all trades takes place.

It was later discovered that liquidity could be better utilized if the pools were created with a more tightly bounded price curve. More liquidity supporting a narrower price range also effectively reduces slippage risks as the trade size would have to scale up according to the pool to have the same price impact.

1b1afc49-833e-4691-9538-18a945162431

Popularized by Uniswap v3, the concentrated liquidity market makers model enables LPs to add liquidity to their preferred price ranges.

As a consequence of this design, each LP position had to be uniquely tracked as liquidity within the pool became non-fungible. The range of possible prices are partitioned into discrete "ticks" whereby LPs could contribute liquidity between any two ticks. Tick index i is defined as a logarithm of the price.

f87f21e2-b606-4901-8f14-b40b5e5228ec

The net liquidity of liquidity providers and other parameters are stored in a linked list data structure.

e806ee7d-35f6-4759-b4ab-d0963b8539af

Each of these positions effectively formed their own user-defined price curve. Through aggregating all the different positions into a single price curve, it enabled a single pool to support the diversity of LP preferences.

e806ee7d-35f6-4759-b4ab-d0963b8539af

Whenever you add/remove liquidity to a tick range, the pool has to record how much virtual liquidity is kicked in/out when crossing these ticks and how many tokens are used to activate the liquidity within the range. Swapping token 0 for token 1 causes the current pool price and current tick to move downwards, while swapping token 1 for token 0 causes the current pool price and current tick to move upwards. When swapping, the total amount of liquidity that is added or removed when the tick is crossed goes left and right which is where the KyberSwap exploit took place.

Vulnerability

In short, the vulnerability is in the implementation of KyberSwap Elastic’s computeSwapStep() function. The function computes the actual swap input / output amounts to be deducted or added, the swap fee to be collected and the resulting sqrtP.

aec7485d-d92f-4e26-b7ae-08889aa3b63d

This function first called the calcReachAmount() function and concluded that the attacker’s swap won’t cross the tick boundary, but incorrectly produced a slightly larger price than the targetSqrtP which is calculated by calling “calcFinalPrice“. As a result the liquidity was not removed and lead to the subsequent attack.

Attack Flow

This example is based on Ethereum transaction 0x396a83df7361519416a6dc960d394e689dd0f158095cbc6a6c387640716f5475.

The example transaction contains six attacks which all employed the same method. As such we used the USDC-ETHX pair attack for this example.

  1. First, the attacker flashloaned 500 ETHx from Uniswap and manipulated the KS2-RT (KyberSwap v2 Reinvestment Token) pool, which only held 2.8 ETHx, by swapping an excessive amount of ETHx. The attacker exchanged 246.754 ETHx for 32389.63 USDC which exhausted the pool’s liquidity and raised the currentTick to 305000.

r1

After the swap, there was 249.5 ETHX and 13.2 USDC left in the pool.

69ffc743-30c7-485c-bc8c-21e2981825b1

We can see that the attacker initially wanted to exchange 500 ETHX for USDC, but 246.754 ETHx was enough to gain the 32389.63 USDC and increase the currentTick to 305000. This means that there was no liquidity available above the 305000 tick range for the attacker to exchange at the time, it is considered a vacuum zone.

r1

  1. The attacker then called mint() from the KyberSwap: Elastic Anti-Snipping Position Manager contract to create a new liquidity pool with 16 USDC and 5.87e-3 ETHX. The tick set in a narrow range between 305000 and 305408, meaning the attacker created his own liquidity pool to follow the tick at 305000.

r1

After that, the attacker removed part of the liquidity, but still left some liquidity between tick 305000 to 305408.

r1

  1. The attacker conducted a second ETHx for USDC swap. They swapped 244.08 ETHX at tick 305000 for 13.6 USDC which pushed the tick to 305408.

r1

On the surface this might seem like a strange swap, since the attacker is the only person who provided liquidity between 305000 to 305408, but it is a precursor for the next step.

  1. KyberSwap uses the computeSwapStep() function to determine whether a swap will cross a tick range. Part of the function is shown below:

abbd3b0f-6fff-4d7a-bf56-0ba60f5e69fa

The focus here is the calcReachAmount() function. This function calculates how many tokens will be needed for a swap if the currentSqrtP (square root price) reaches the targetSqrtP.

Looking back at the attacker's transaction, we can see that in this swap, the value of usedAmount is 244080034447360000000, while the amount of ETHx that the attacker input to be exchanged is 244080034447359999999, one less than the value of usedAmount. The computeSwapStep() function determined that this swap was not enough to exhaust the liquidity in the current tick range and did not need to swap across the tick boundary. This meant nextSqrtP was not updated to targetsqrtP.

r1

The calcFinalPrice function is then called to calculate nextSqrtP. The important part here is that during this calculation the swap fee is included in the liquidity. This resulted in the final nextSqrtP actually being slightly larger than the price of tick 305408, instead of being under it, as previously thought.

74e6aef6-5b74-4d51-b72b-119631b5c852 r1

Looking back at the upper swap function, the values of sqrtp and nextSqrtP are being compared to determine whether it is necessary to swap tick boundaries. The condition here only determines whether sqrtP is 'equal' to nextSqrtP and, only when it is, the lower level updateLiquidityAndCrossTick() function is called to add or remove the liquidity (swapData.baseL) and cross tick boundary. At this point sqrtP is currently greater than nextSqrtP. This means the attacker has created a situation in which the current price has exceeded the upper limit of the tick interval, yet it did not trigger the updateLiquidityAndCrossTick() function to remove liquidity, leading to the existence of false liquidity.

bb84424a-e8f5-4ae9-adbc-494661640d13 b863d513-d20d-4d5d-8ce2-7ea1d0c143b5

  1. Finally, the attacker conducted a reverse swap, exchanging USDC for ETHx. This decreased the price from slightly above the 305408 tick, the upper boundary of the attacker-provided liquidity range (305000 to 305408), to slightly below the 304982 tick price.

r1

As the price entered the 305000 - 305408 tick range, where liquidity was provided by the attacker, from slightly higher than 305408 the updateLiquidityAndCrossTick() function was called. The falsified liquidity was added to the liquidity contained in the 305000 - 305408 tick range which makes the liquidity between the range much greater than the actual liquidity. The attacker then exchanged 493.638 ETHx for 27.517 usdc in this price range (which includes about 250 ETHx that is not included in the 305000 - 305408 tick price range). As a result the previous costs were recovered and at the same time the pool was drained of USDC as a profit.

r1

  1. Return the flash loan and complete the attack.

ece908fc-8963-4783-ad1e-04b591684e95

This process was repeated on many kyberSwap pairs across multiple chains, leading to the following losses.

POLY: $1,180,097 ETH: $7,486,868 OP: $15,504,542 BASE: $318,413 ARB: $16,833,861 AVAX: $23,526

The attacks came from three different EOAs with 0x502 taking the majority of the assets. 0x502 initially reached out the project to state they would negotiate after resting. The KyberSwap team have since reached out to offer a 10% bounty with a deadline of 6AM UTC Nov 25.

b4752ef1-2524-4e83-9a07-944c11766afc