All incidents

Kyber Elastic Tick Rounding Exploit

Share
Nov 22, 2023 22:55 UTCAttackLoss: 11.91 frxETH, 17.18 MONA +3 morePending manual check1 exploit txWindow: Atomic
Estimated Impact
11.91 frxETH, 17.18 MONA +3 more
Label
Attack
Exploit Tx
1
Addresses
5
Attack Window
Atomic
Nov 22, 2023 22:55 UTC → Nov 22, 2023 22:55 UTC

Exploit Transactions

TX 1Ethereum
0x485e08dc2b6a4b3aeadcb89c3d18a37666dc7d9424961a2091d6b3696792f0f3
Nov 22, 2023 22:55 UTCExplorer

Victim Addresses

0xfd7b111aa83b9b6f547e617c7601efd997f64703Ethereum
0xfbc887823dd414edf76c5da88ffcd9eef2ba87ccEthereum
0x93fc0acf77edd68e611f8a776c9995f350f84274Ethereum
0x35e6f409565a98500001783ea090e6f669744c83Ethereum
0x36240069ff26cecbde04d9e49a2af8d39146263eEthereum

Loss Breakdown

11.91frxETH
17.18MONA
261,977.53MEME
6,781.08FRAX
4,806.89USDC

Similar Incidents

Root Cause Analysis

Kyber Elastic Tick Rounding Exploit

1. Incident Overview TL;DR

On Ethereum mainnet transaction 0x485e08dc2b6a4b3aeadcb89c3d18a37666dc7d9424961a2091d6b3696792f0f3 in block 18630392, unprivileged EOA 0x50275e0b7261559ce1644014d4b78d4aa63be836 used helper contract 0xaf2acf3d4ab78e4c702256d214a3189a874cdc13 to exploit five KyberSwap Elastic pools in a single transaction. The helper repeatedly moved each pool price near a tick boundary, minted a narrow position through the public whitelisted position manager 0xe222fBE074A436145b255442D919E4E3A6c6a480, triggered a rounding-sensitive swap that left active base liquidity stale, and then reversed the swap to withdraw more assets than the pool should have released.

The root cause is a rounding-direction bug in Kyber Elastic swap math. In the final exact-input token1 step, SwapMath.estimateIncrementalLiquidity rounds fee liquidity down even though the inline comment says it must round up. That understated deltaL lets computeSwapStep return a nextSqrtP on the wrong side of the target tick boundary, and Pool.swap then skips the _updateLiquidityAndCrossTick path that should have updated baseL. The result is stale active liquidity that can be counted again on the reverse swap.

This was an ACT opportunity. The attacker used only public capital sources, public on-chain state, public Kyber Elastic contracts, and a public position manager. The lower-bound value captured by payout address 0xc9b826bad20872eb29f9b1d8af4befe8460b50c6 was 11062.806768 USD in stablecoins, with 1270.651308634543100060445 USD of gas cost, for a deterministic lower-bound net gain of 9792.15546 USD before valuing the additional volatile tokens that were also extracted.

2. Key Background

Kyber Elastic is a concentrated-liquidity AMM. Active liquidity is piecewise by tick range, so baseL must change exactly when price crosses an initialized tick. The pool also tracks reinvested fee liquidity as reinvestL, which means swap math must keep price movement and fee-liquidity updates consistent in the final, non-crossing step.

The critical pool in the clearest realization path is the FRAX/USDC pool at 0x36240069ff26cecbde04d9e49a2af8d39146263e, but the same transaction applied the pattern to four other Kyber Elastic pools at:

  • 0xfd7b111aa83b9b6f547e617c7601efd997f64703
  • 0xfbc887823dd414edf76c5da88ffcd9eef2ba87cc
  • 0x93fc0acf77edd68e611f8a776c9995f350f84274
  • 0x35e6f409565a98500001783ea090e6f669744c83
  • 0x36240069ff26cecbde04d9e49a2af8d39146263e

The attacker needed no privileged contract access. The seed trace shows public flash liquidity calls into Aave, public token transfers, direct pool swaps, and public position-manager mint and removeLiquidity calls. The AntiSnip position manager is relevant because it is the public whitelisted path used to create and rebalance the narrow boundary position that makes the rounding window exploitable.

The safety invariant that matters is simple: when a swap step reaches or crosses the next initialized tick, the pool must run the tick-cross transition so that baseL reflects the correct active range. If price ends up past the boundary but the pool still takes the non-cross branch, liquidity accounting and price state diverge.

3. Vulnerability Analysis & Root Cause Summary

Kyber Elastic documents that nextSqrtP should not exceed targetSqrtP, but the implementation breaks that guarantee in one exact-input path. In SwapMath.estimateIncrementalLiquidity, the exact-input token1 branch uses FullMath.mulDivFloor even though the nearby comment states that deltaL must be rounded up to force nextSqrtP down. Because deltaL is understated, calcFinalPrice can place the post-step price beyond the intended boundary. Pool.swap then compares the returned sqrtP against nextSqrtP and treats the step as non-crossing, which skips _updateLiquidityAndCrossTick and leaves baseL stale. The attacker deliberately engineered a narrow position around the lower boundary so the stale baseL became economically exploitable on the reverse swap. That second swap re-added liquidity that should already have been removed, which is why the pool logs show liquidity doubling from 5109053135792022 to 10218106271584044. This is an ATTACK root cause, not pure MEV, because the exploit depends on a broken code-level invariant in swap execution.

The explicit violated principles are:

  • Rounding direction must preserve branch invariants.
  • Price state and active-liquidity state must be updated atomically across tick boundaries.
  • Critical AMM math assumptions must be enforced in code, not left as comments.

4. Detailed Root Cause Analysis

The first breakpoint is in Kyber Elastic swap math. The exact-input token1 final-step path says it must round deltaL up so that nextSqrtP is rounded down, but the implementation calls mulDivFloor instead:

// Kyber Elastic SwapMath exact-input token1 path
if (isExactInput) {
  if (isToken0) {
    deltaL = FullMath.mulDivFloor(
      currentSqrtP,
      absDelta * feeInFeeUnits,
      C.TWO_FEE_UNITS << C.RES_96
    );
  } else {
    // Because nextSqrtP = (liquidity + absDelta / currentSqrtP) * currentSqrtP / (liquidity + deltaL)
    // so we round up deltaL, to round down nextSqrtP
    deltaL = FullMath.mulDivFloor(
      C.TWO_POW_96,
      absDelta * feeInFeeUnits,
      C.TWO_FEE_UNITS * currentSqrtP
    );
  }
}

That understated deltaL feeds calcFinalPrice, so the returned sqrtP can land past the target boundary even though the step is treated as a final non-crossing step. The second breakpoint is the branch in Pool.swap that assumes any step with swapData.sqrtP != swapData.nextSqrtP did not cross the next initialized tick:

// Kyber Elastic Pool.swap branch after computeSwapStep
(usedAmount, returnedAmount, deltaL, swapData.sqrtP) = SwapMath.computeSwapStep(
  swapData.baseL + swapData.reinvestL,
  swapData.sqrtP,
  targetSqrtP,
  swapFeeUnits,
  swapData.specifiedAmount,
  swapData.isExactInput,
  swapData.isToken0
);

if (swapData.sqrtP != swapData.nextSqrtP) {
  if (swapData.sqrtP != swapData.startSqrtP) {
    swapData.currentTick = TickMath.getTickAtSqrtRatio(swapData.sqrtP);
  }
  break;
}

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

Because the bugged sqrtP is already beyond the boundary, that break leaves baseL representing the old tick range even though currentTick has moved below the minted lower tick. The FRAX/USDC pool logs show the stale state directly:

Decoded FRAX/USDC events during the seed transaction
Swap log 0x26f:
  deltaQty0 = 27736096068180377599999
  deltaQty1 = -377259
  sqrtP = 289301399671799345325
  liquidity = 5109053135792022
  currentTick = -388583

Swap log 0x278:
  deltaQty0 = -40410314811613531939916
  deltaQty1 = 546747
  sqrtP = 293539049659837832076
  liquidity = 10218106271584044
  currentTick = -388292

Those values matter because the attacker’s narrow position sits at ticks -388582 to -388180. After the trigger swap, currentTick is already below tickLower, yet liquidity still reports 5109053135792022 instead of being removed from the active range. The reverse swap then traverses the same boundary again and re-adds liquidity that should already have been removed, which is why the reported active liquidity doubles.

The attacker prepared that state deliberately. The decoded helper logs for the same pool show the exact liquidity engineering:

Step 3, building up fake liquidity:
  initL = 3547889445829
  Glitch Liquidity = 5112601025237851
  exactLiqNeeded = 5109053135792022
  amt0 = 150481319823445010406
  amt1 = 452415

That matches the pool receipt logs:

  • Mint log 0x25b: qty = 6130843304189434, qty0 = 150480817665596601916, qty1 = 452415
  • Burn log 0x261: qty = 1021790168397412, qty0 = 25079717812725759910, qty1 = 75401

So the exploit sequence is deterministic at code level and on chain:

  1. Move the pool to a boundary-adjacent price.
  2. Mint a narrow position through the public manager.
  3. Partially remove that position until baseL equals the exact glitch-liquidity target.
  4. Execute the trigger swap that crosses the lower boundary while Pool.swap still takes the non-cross branch.
  5. Execute the reverse swap to double-count active liquidity and drain assets.

5. Adversary Flow Analysis

The full adversary cluster is:

  • Sender EOA: 0x50275e0b7261559ce1644014d4b78d4aa63be836
  • Helper contract: 0xaf2acf3d4ab78e4c702256d214a3189a874cdc13
  • Payout EOA: 0xc9b826bad20872eb29f9b1d8af4befe8460b50c6

The transaction sequence contains one adversary-crafted transaction, 0x485e08dc2b6a4b3aeadcb89c3d18a37666dc7d9424961a2091d6b3696792f0f3, against pre-state sigma_B at block 18630391. The seed trace shows the helper receiving public flash liquidity, then repeating the same four-stage pattern across five pools.

For the FRAX/USDC pool, the decoded execution flow is:

  1. Public capital sourcing and price preparation. The helper takes public flash liquidity and performs a large price-setting swap. The first FRAX/USDC swap log reports deltaQty0 = 5767742213478846776600, deltaQty1 = -4807432959, sqrtP = 295147905179352825856, and currentTick = -388182.
  2. Boundary position construction. The helper mints a narrow position at ticks -388582 to -388180 through AntiSnipAttackPositionManager::mint, then burns part of it with removeLiquidity until active liquidity becomes 5109053135792022.
  3. Rounding-window trigger. The helper performs the trigger swap with deltaQty0 = 27736096068180377599999 and deltaQty1 = -377259, pushing currentTick to -388583 while the pool still reports active liquidity.
  4. Extraction. The reverse swap returns 40410314811613531939916 FRAX against only 546747 USDC paid in, and the reported active liquidity jumps to 10218106271584044.

The decoded helper logs summarize the pattern succinctly:

STARTING Pool...
Step 1 Complete.
Step 2, finding liquidity required
Step 3, building up fake liquidity.
Step 4, pulling off the doubling move.
DONEEEEE...

The same helper contract repeats this strategy across the other Kyber Elastic pools in the same transaction. The payout address then receives the extracted assets. The balance diff shows transfers to 0xc9b826bad20872eb29f9b1d8af4befe8460b50c6 of:

  • 6255995712288710961401 FRAX
  • 4806811056 USDC
  • 11907522555278990764 frxETH
  • 17181396346774484787 MONA
  • 261977312989387423555651 MEME

The sender EOA paid 615095561124017350 wei in gas for the transaction.

6. Impact & Losses

The transaction drained public reserves from five Kyber Elastic pools and left the FRAX/USDC pool in a corrupted post-state with doubled active liquidity after the reverse swap. The measurable pool-side losses recorded in the analysis are:

  • frxETH: 11908702998026754674 base units (18 decimals)
  • MONA: 17182235077189764520 base units (18 decimals)
  • MEME: 261977528777761427077256 base units (18 decimals)
  • FRAX: 6781075430101436721311 base units (18 decimals)
  • USDC: 4806886457 base units (6 decimals)

The lower-bound profit predicate is deterministic even before valuing the volatile assets. Using only stablecoins transferred to the payout address, the attacker realized:

  • FRAX value: 6255.995712288710961401 USD-equivalent at a 1 USD lower-bound assumption
  • USDC value: 4806.811056 USD
  • Stablecoin-only total: 11062.806768288710961401 USD

The transaction fee side is also deterministic. The transaction used 13298675 gas at 46252394402 wei, for 615095561124017350 wei total. Using the public Chainlink ETH/USD feed 0x5f4eC3Df9cbd43714FE2740f5E3616155c5b8419 at block 18630392, with latestRoundData answer 206577870000 and 8 feed decimals, the gas cost was 1270.651308634543100060445 USD. That gives a stablecoin-only lower-bound net gain of 9792.15546 USD, excluding the additional frxETH, MONA, and MEME value also transferred to the payout address.

7. References

  • Seed transaction: 0x485e08dc2b6a4b3aeadcb89c3d18a37666dc7d9424961a2091d6b3696792f0f3
  • Public metadata and balance-diff artifacts for the seed transaction
  • Verified Kyber Elastic SwapMath.sol for the rounding bug
  • Verified Kyber Elastic Pool.sol for the skipped tick-cross branch
  • Verified AntiSnipAttackPositionManager.sol for the public mint and rebalance path
  • Decoded helper logs and FRAX/USDC receipt logs showing the trigger and reverse swaps
  • Deterministic profit-fee calculation using observed gas spend plus Chainlink ETH/USD at block 18630392
  • KyberSwap official postmortem: https://blog.kyberswap.com/post-mortem-kyberswap-elastic-exploit/