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.
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.
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.
The net liquidity of liquidity providers and other parameters are stored in a linked list data structure.
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.
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.
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.
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.
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.
After the swap, there was 249.5 ETHX and 13.2 USDC left in the pool.
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.
After that, the attacker removed part of the liquidity, but still left some liquidity between tick 305000 to 305408.
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.
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.
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.
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.
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.
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.