How Was the Kyber Network Exploited?

13 min read

Learn how Kyber Network was exploited across multiple chains, resulting in a loss of $48.3 million.

TL;DR#

On November 23, 2023, the Kyber Network was exploited across six different chains due to a smart contract vulnerability, which resulted in a loss of approximately $48.3 million worth of assets.

Introduction to Kyber Network#

Kyber Network is a hub of liquidity protocols that aggregates liquidity from various sources to provide instant transactions on any dApp.

Vulnerability Assessment#

The root cause of the exploit is tick manipulation and double liquidity counting.

Attack Scenario#

As seen in one of the attack transactionsthe exploiter borrowed a substantial amount of flash-loan and then proceeded to drain the pools with low liquidity.

They manipulated the current prices and ticks of the affected pools by executing swaps and altering positions.

function computeSwapStep(
  uint256 liquidity,
  uint160 currentSqrtP,
  uint160 targetSqrtP,
  uint256 feeInFeeUnits,
  int256 specifiedAmount,
  bool isExactInput,
  bool isToken0
) internal pure returns (int256 usedAmount, int256 returnedAmount, uint256 deltaL, uint160 nextSqrtP) {
  // in the event currentSqrtP == targetSqrtP because of tick movements, return
  // eg. swapped up tick where specified price limit is on an initialised tick
  // then swapping down tick will cause next tick to be the same as the current tick
  if (currentSqrtP == targetSqrtP) return (0, 0, 0, currentSqrtP);
  usedAmount = calcReachAmount(liquidity, currentSqrtP, targetSqrtP, feeInFeeUnits, isExactInput, isToken0);

  if ((isExactInput && usedAmount > specifiedAmount) || (!isExactInput && usedAmount <= specifiedAmount)) {
    usedAmount = specifiedAmount;
  } else {
    nextSqrtP = targetSqrtP;
  }

  uint256 absDelta = usedAmount >= 0 ? uint256(usedAmount) : usedAmount.revToUint256();
  if (nextSqrtP == 0) {
    deltaL = estimateIncrementalLiquidity(absDelta, liquidity, currentSqrtP, feeInFeeUnits, isExactInput, isToken0);
    nextSqrtP = calcFinalPrice(absDelta, liquidity, deltaL, currentSqrtP, isExactInput, isToken0).toUint160();
  } else {
    deltaL = calcIncrementalLiquidity(absDelta, liquidity, currentSqrtP, nextSqrtP, isExactInput, isToken0);
  }
  returnedAmount = calcReturnedAmount(liquidity, currentSqrtP, nextSqrtP, deltaL, isExactInput, isToken0);
}

The attacker then triggered multiple swap steps and cross-tick operations, resulting in double liquidity counting and consequently draining the pools.

Attack Explained#

Experts reveal that the attack on Kyber might be one of the most complex and carefully engineered smart contract exploits to have ever been executed. The invariants required for the attack had to be extremely precise for it to work successfully.

Let's analyze the attack on the ETH/wstETH pool that the attacker drained on the Ethereum Mainnet. The other pools also followed a similar strategy.

This transaction drained three separate pools, and the process to drain each pool was independent of each other. Essentially, each pool exploit was carried out using flash-loan to manipulate token prices and liquidity.

For the wstETH/ETH drain, the attacker took a flash loan of 10,000 wstETH, worth approximately $23 million. The exploiter then swapped 2800 wstETH, worth $6 million, into the pool to push the price from 1.05 ETH to 0.0000152.

undefined

The intention behind this wasn't to manipulate the oracle but to move the pool price to an area of the concentrated liquidity curve where there was practically no existing liquidity. The attack vector relied on an extremely precise manipulation of Kyber's concentrated liquidity math, so this created a fresh canvas.

As a result of this, it mints 3.4 wstETH of liquidity in the price range of 0.0000146 to 0.0000153. The exploiter then burns 0.56 wstETH of liquidity to maybe line up a somewhat perfect sequence of numerical calculations.

The exploit executes two swaps around this price. As there was no liquidity in the pool, anyone attempting to exploit the liquidty pool would practically be just trading back and forth with their own liquidty, and all the flow would result in a net zero, barring the transaction fees.

However, this resulted in an infinite money glitch. The exploiter executed their first swap to sell 1056 wstETH for 0.0157 ETH, thereby pushing the price to 0.0000146, just below their liquidity price range.

The second swap is in the opposite direction, where the exploiter purchases 3911 wstETH from the pool for 0.06 ETH, thereby pushing the price of the pool back up to 0.00001637, which is slightly above the upper edge of their liquidity range.

undefined

The exploiter drained the pool to complete the exploit.  In the second swap, the exploiter received more money (3911 wstETH) than what they had paid in the first (1052 wstETH).

The offset here is the 3 wstETH that the attacker minted at the outset in the beginning of this exploit.

In Kyber's smart contract, the value of the resting in-range liquidity comes from the `baseL` poolData variable, which should have been `0` as the second swap ended at a price outside the attacker's liquidity range, thereby asserting no liquidity.

The exploit fooled the pool into thinking that it had more liquidity than it actually did at the given price range; due to this, the pool overpaid for large swaps.

As evident from the PoolTicksState smart contract of Kyber, when tick boundaries are crossed, the `updateLiquidityAndCrossTick` function is invoked, which adjusts the curve's liquidity value based on the LP range positions at that tick.

function _updateLiquidityAndCrossTick(
  int24 nextTick,
  uint128 currentLiquidity,
  uint256 feeGrowthGlobal,
  uint128 secondsPerLiquidityGlobal,
  bool willUpTick
) internal returns (uint128 newLiquidity, int24 newNextTick) {
  unchecked {
    ticks[nextTick].feeGrowthOutside = feeGrowthGlobal - ticks[nextTick].feeGrowthOutside;
    ticks[nextTick].secondsPerLiquidityOutside = secondsPerLiquidityGlobal - ticks[nextTick].secondsPerLiquidityOutside;
  }
  int128 liquidityNet = ticks[nextTick].liquidityNet;
  if (willUpTick) {
    newNextTick = initializedTicks[nextTick].next;
  } else {
    newNextTick = initializedTicks[nextTick].previous;
    liquidityNet = -liquidityNet;
  }
  newLiquidity = LiqDeltaMath.applyLiquidityDelta(currentLiquidity, liquidityNet >= 0 ? uint128(liquidityNet) : liquidityNet.revToUint128(), liquidityNet >= 0);
}

The second swap handled it, starting when it was out of range above the LP position. When the price moved into the range, the above function was called once. As the price moved out of range, the function was called again when the swap ended at a price below the LP position.

However, in the first swap, this function should have also been called, but it was never called. Even though the curve price started in the range, the swap moved the price until it was just slightly out of the range.

Their smart contracts work in such a way that when an LP position moves out of range, that function is responsible for removing that liquidity from the curve. And, when it moves back in range, it adds liquidity back into the curve.

Preventing the call from being invoked when their LP position moves out of range allows the attacker to maintain liquidity on the curve, tricking the pool into thinking it has more liquidity than it does. The attacker would now have tricked the pool into thinking it has more liquidity than it does.

However, if they move back in range and invoke the call to that function, the liquidity is added back in, even though it was never initially removed. The result is now an infinite money glitch due to double-dipping, as the pool is double-counting the liquidity from the original LP position.

So, how was the first by-pass possible?

Concentrated AMMs calculate swaps as a series of steps. At each step, you have to determine if you'll reach a tick boundary or exhaust the swap.

Kyber runs this swap step, and it checks to see if the ending price of the step is the same as the next tick price. If it isn't, it assumes the swap is exhausted, i.e., it didn't reach the tick boundary, and `updateLiq` doesn't need to be called.

…..

// if price has not reached the next sqrt price
  if (swapData.sqrtP != swapData.nextSqrtP) {
    if (swapData.sqrtP != swapData.startSqrtP) {
      // update the current tick data in case the sqrtP has changed
      swapData.currentTick = TickMath.getTickAtSqrtRatio(swapData.sqrtP);
    }
    break;
  }

…..

(swapData.baseL, swapData.nextTick) = _updateLiquidityAndCrossTick(
  swapData.nextTick,
  swapData.baseL,
  cache.feeGrowthGlobal,
  cache.secondsPerLiquidityGlobal,
  willUpTick
);

….

However, as the check is about inequality and not a directional comparison, if an attacker were somehow able to execute a swap step and get the price to end outside the tick boundary, the check would fail and `updateLiquidity` would never be called, even though they crossed a tick boundary.

This shouldn't normally happen because the `computeSwapStep` function first calculates an upper limit of the amount that can be swapped before reaching the tick. If that amount is less than the remainder of the swap, it confidently predicts the ending price will not reach the tick.

However, in this case, the calcReachAmount predicted the swap quantity would not reach the tick boundary, yet somehow the ending price ended just slightly beyond the tick boundary. The exploiter achieved this by setting a swap quantity of 220799999, which was slightly below the upper bound of the reach quantity calculated as 22080000. The exploiter carefully engineered the exploit to cause the smart contract check to fail by less than 0.00000000001%.

Therefore, in a very carefully controlled and precisely engineered case, the bounds check will tell you that anything less than the X swap quantity will keep you inside the tick price.

/// @dev calculates the amount needed to reach targetSqrtP from currentSqrtP
  /// @dev we cast currentSqrtP and targetSqrtP to uint256 as they are multiplied by TWO_FEE_UNITS or feeInFeeUnits
  function calcReachAmount(
    uint256 liquidity,
    uint256 currentSqrtP,
    uint256 targetSqrtP,
    uint256 feeInFeeUnits,
    bool isExactInput,
    bool isToken0
  ) internal pure returns (int256 reachAmount) {
    uint256 absPriceDiff;
    unchecked {
      absPriceDiff = (currentSqrtP >= targetSqrtP)
        ? (currentSqrtP - targetSqrtP)
        : (targetSqrtP - currentSqrtP);
    }
    if (isExactInput) {
      // we round down so that we avoid taking giving away too much for the specified input
      // ie. require less input qty to move ticks
      if (isToken0) {
        // numerator = 2 * liquidity * absPriceDiff
        // denominator = currentSqrtP * (2 * targetSqrtP - currentSqrtP * feeInFeeUnits / FEE_UNITS)
        // overflow should not happen because the absPriceDiff is capped to ~5%
        uint256 denominator = C.TWO_FEE_UNITS * targetSqrtP - feeInFeeUnits * currentSqrtP;
        uint256 numerator = FullMath.mulDivFloor(
          liquidity,
          C.TWO_FEE_UNITS * absPriceDiff,
          denominator
        );
        reachAmount = FullMath.mulDivFloor(numerator, C.TWO_POW_96, currentSqrtP).toInt256();
      }
……
……
  }

But the parallel price change calculation will apply X swap quantity and wind up outside the tick bound.

function calcReturnedAmount(
  uint256 liquidity,
  uint160 currentSqrtP,
  uint160 nextSqrtP,
  uint256 deltaL,
  bool isExactInput,
  bool isToken0
) internal pure returns (int256 returnedAmount) {
  if (isToken0) {
    if (isExactInput) {
      // minimise actual output (<0, make less negative) so we avoid sending too much
      // returnedAmount = deltaL * nextSqrtP - liquidity * (currentSqrtP - nextSqrtP)
      returnedAmount =
        FullMath.mulDivCeiling(deltaL, nextSqrtP, C.TWO_POW_96).toInt256() +
        FullMath.mulDivFloor(liquidity, currentSqrtP - nextSqrtP, C.TWO_POW_96).revToInt256();
    }

…….
……
}

Attack Simplified#

Now, as explained in detail here, let's try to decipher the attack in simpler terms by referencing two of our favorite characters: Alice and Bob.

Kyber, as with Uniswap V3, divides the liquidity of their pool between specific prices, known as ticks.

Liquidity providers can specify a range to execute their trade. Liquidity providers assert that their liquidity should only be used to execute a trade when the price of an asset falls within the specified range.

As each liquidity provider can specify their own range, liquidity at any point in time is essentially a patchwork of different LP ranges.

In this example, Bob specifies the liquidity for a certain trade as the ticks of $1500-$1700 worth per ETH, while Alice specifies the ticks of $1000-$1600.

Alice puts in 8 ETH, and Bob puts in 10 ETH.

When the price of ETH is at $1400, there is 8 ETH worth of liquidity in the pool that comes from Alice's end. And, when the price of ETH is at $1500, there's 18 ETH worth of liquidity as the price also crossed the tick and fell into Bob's range.

In order to make this patchwork system work, there's a mechanism for adding or removing how much total liquidity is available from liquidity providers to a trader at a given tick.

It means that, when the price of ETH moves from $1499 to $1500, the pool adds 10 ETH from Bob to the available liquidity for the trader, now totaling 18 ETH.

If the price of ETH goes back to $1499 from $1500, it crosses the ticks set by Bob, thus the pool removes his 10 ETH of liquidity from the available liquidity for the trader, now totaling 8 ETH only.

If the pool counted only the ticks that crossed forward, this swap would execute differently. 

When the price of ETH crosses from $1499 to $1500, it will correctly add 10 ETH worth of liquidity from Bob to the total supply for the trader, now totaling 18 ETH. But if the price of ETH goes back to $1499 from $1500, it doesn't remove his liquidity from the pool.

In this situation, even when the tick is outside of Bob's range, the total ETH of liquidity available is still 18 ETH by inaccurately counting Bob's liquidity.

When the price of ETH reaches $1499 and then increases to $1500, the forward tick adds Bob's liquidity worth 10 ETH back to the pool, resulting in the pool overseeing a total liquidity of 28 ETH.

This continues until the total liquidity is out of sync with reality. At this point, the attacker is able to buy tokens at a massively deflated price and then sell those tokens on other markets where the liquidity is still in sync.

The attacker kept triggering the forward tick and adding fake liquidity without actually triggering the back tick. The difference between the tick bound and price change calculation for trade execution was less than 0.00000000001%.

Aftermath#

The team acknowledged the occurrence of the exploit and stated that KyberSwap Elastic experienced a security incident. They advised all of their users to promptly withdraw their funds while the team looked forward to investigating the issue in detail.

Their concentrated liquidity protocol saw its TVL fall from roughly $71.11 million to a bit over $2 million.

The stolen assets across the multiple chains include:
$7.5 million on the Ethereum Mainnet
$20 million on Arbitrum
$15 million on Optimism
$315,000 on Base Network
$2 million on Polygon
$23,497 on Avalanche

The exploiter has transferred approximately 1,000 WETH, worth $2.06 million, to this address on Arbitrum. This address had previously interacted with the Indexed Finance exploiter on the Ethereum Mainnet.

Approximately 500 WETH has been transferred to yet another address on Arbitrum, followed by bridging 300 WETH to the same address on ETH. At the time of this writing, this hacker's address has accumulated approximately $43,356,351 worth of stolen assets.

undefined

This address of the hacker, who made around $46 million worth of funds, initially utilized funds from Tornado Cash on Ethereum Mainnet and then leveraged bridges and Fixed Float to transfer funds to other chains for launching the attack.

The hacker apparently has on-chain flair, as they showed each step of their exploit execution through event logs.

undefined

The exploiter also sent an on-chain message to the team upon the attack's completion, stating that the negotiations will start in a few hours when they are fully rested.

Solution#

To address and mitigate risks similar to those seen in the Kyber Network exploit, a tailored solution would encompass several key strategies, each focusing on a specific aspect of smart contract security and resilience.

The cornerstone of this solution would be in-depth and comprehensive code audits. Unlike conventional audits, these would delve deeply into the contract's logic, examining how different components interact under a range of conditions, including edge cases. These audits would focus on identifying potential vulnerabilities by subjecting the contract to unusual market behaviors, such as drastic price fluctuations or low liquidity scenarios.

An essential part of the testing regime would be invariant testing. This approach is critical for ensuring that the contract maintains certain fundamental truths or conditions (invariants), irrespective of external factors. For instance, in a liquidity pool, the invariant might be that the total liquidity should always equal the sum of the individual contributions. Automated testing frameworks that continuously check these invariants would provide an ongoing assessment of the contract’s integrity.

Back-and-forth testing, or state testing, would also play a crucial role. This process involves altering the state of the contract—for example, changing the price of assets within a liquidity pool—and then reverting it to verify that the contract behaves as expected. Such testing could have been instrumental in identifying the specific manipulation of liquidity pools exploited in the Kyber Network incident.

Utilizing formal verification tools would further strengthen the security framework. These tools provide mathematical proofs to verify the correctness of the contract’s algorithms, offering a higher level of confidence in their resilience against unexpected behaviors.

Finally, the solution would include the implementation of continuous monitoring and real-time anomaly detection systems. DeFi platforms are dynamic environments where new threats can emerge quickly. Real-time monitoring can help in promptly detecting and responding to unusual activities indicative of potential exploits.

While the above measures are paramount, vulnerabilities may still surface from time to time. This is where Neptune Mutual steps in to protect the end-users. If the team associated with Kyber Network had established a dedicated cover pool with us prior to the exploit, the aftermath of this significant incident could have been notably alleviated. At Neptune Mutual, we understand the intricacies of the DeFi landscape, which is why we offer coverage to users who might face losses from vulnerabilities in smart contracts, such as those witnessed in the Kyber Network exploit.

With Neptune Mutual, users are spared the hassle of submitting a detailed proof of losses. As soon as an incident like the Kyber Network exploit is confirmed and resolved via our incident resolution mechanism, we prioritize quick compensation disbursement, providing prompt support to the victims.

Our marketplace operates on numerous popular blockchain networks, including EthereumArbitrum, and the BNB chain. This extensive reach allows us to serve a broad spectrum of DeFi users, offering protection from potential vulnerabilities like those seen in the Kyber Network incident and enhancing their faith in the ecosystem.

Reference Source BlockSec

By

Tags