KyberSwap Elastic Legacy Double-Added Liquidity / Tick-Boundary Desynchronization Near Miss (2023-04-17)

  • Name: KyberSwap Elastic Legacy double-added liquidity / tick-boundary desynchronization near miss
  • Date: 2023-04-17
  • Protocol: KyberSwap Elastic Legacy
  • Chain: multichain (demonstrated on Ethereum mainnet)
  • Category: concentrated-liquidity AMM boundary-state desynchronization / double-added-liquidity near miss
  • Loss: no realized onchain loss; KyberSwap and the reporting whitehat described roughly $100M of Elastic Legacy liquidity as being at risk before deposits were disabled and LPs withdrew
  • Summary: In April 2023 a whitehat disclosed a critical bug in KyberSwap Elastic Legacy’s concentrated-liquidity pool logic that could make newly minted liquidity get counted twice at a tick boundary. The exploit required pushing pool state into a very specific edge case where sqrtP sat exactly on a valid tick boundary, currentTick and nearestCurrentTick became desynchronized by one tick, and the next one-for-zero swap incorrectly treated currentTick itself as the next initialized boundary to cross. Because the position’s liquidity had already been added once on mint, that boundary crossing added the same liquidity again, inflating baseL and letting a flash-loan attacker buy out almost the entire cheap-side inventory of the pool. Kyber validated the report on 2023-04-17, disabled new Elastic Legacy liquidity deposits the same day, urged LPs to exit, and later relaunched Elastic with patched contracts, so the issue became a near miss rather than a live drain.
  • Trigger / entry point:
    • The vulnerable surface was the legacy ks-elastic-sc-legacy CLMM implementation, specifically the interaction between Pool.sol swap-step state updates and tick-navigation logic.
    • The whitehat’s write-up showed the exploit depended on forcing sqrtP to sit exactly on a tick boundary using the router’s limitSqrtP, then using small swaps to move the pool into a state where nearestCurrentTick == currentTick - 1 even though price still sat on the boundary.
    • In that state, a subsequent one-for-zero swap miscomputed swapData.nextTick as currentTick rather than the next initialized tick boundary to the right.
    • Crossing that wrongly chosen boundary caused freshly minted liquidity in (currentTick, currentTick + n) to be applied again to baseL.
  • Root cause:
    • KyberSwap Elastic Legacy relied on a tight CLMM invariant: if price sits on one side of a boundary, the pool’s currentTick, nearestCurrentTick, and active liquidity must all agree about which liquidity is already active.
    • The whitehat demonstrated an exploitable edge case when sqrtP sat exactly on a tick-range boundary. In that boundary state, TickMath.getTickAtSqrtRatio(sqrtP) and the pool’s nearestCurrentTick bookkeeping could become out of sync after a tiny reverse swap.
    • The sharper mechanism, preserved in the whitehat postmortem, was that the reverse leg was actually split into two sub-swaps. After the first sub-swap crossed back over the boundary, currentTick and nearestCurrentTick were briefly coherent again. But the second sub-swap was so small that swapData.sqrtP did not move at all.
    • Kyber’s root-cause analysis pointed to legacy Pool.sol:405-408: even when that second sub-swap left swapData.sqrtP unchanged, the code still recomputed currentTick = TickMath.getTickAtSqrtRatio(swapData.sqrtP). Because sqrtP was still sitting exactly on the boundary, that recomputation incremented currentTick by one while nearestCurrentTick remained on the older boundary.
    • That one-tick disagreement was enough to corrupt boundary traversal. The next one-for-zero swap believed currentTick itself was the next boundary to cross, so liquidity that had already been made active by minting got activated again.
    • This was therefore not just a generic rounding bug. It was a concentrated-liquidity boundary-state bug where price state, boundary state, and active-liquidity state stopped telling the same story.
  • Exploit path:
    1. The attacker uses swaps with a chosen limitSqrtP to land pool price exactly on a valid tick boundary.
    2. They perform a small forward/backward swap sequence to push pool bookkeeping into the special state where nearestCurrentTick == currentTick - 1 while price still sits on the boundary.
    3. They mint liquidity in the range (currentTick, currentTick + n). Because the current price is just inside that range, the mint already adds the new position’s liquidity to baseL.
    4. The attacker then executes a minimal one-for-zero swap.
    5. Because the pool now misidentifies currentTick as the next initialized boundary, crossing that boundary adds the same just-minted liquidity again.
    6. With liquidity effectively double-added, the attacker can perform a large trade at an impossible price, using flash liquidity to first push the pool into a favorable low-price region and then drain most of the valuable-side inventory.
    7. They repay the flash loan from the underpriced inventory they extracted, leaving the pool with most of the bad-side asset and very little of the good-side asset.
  • What the attacker needed to be true:
    • The pool had to run the legacy Elastic code path rather than the patched 2023 relaunch contracts.
    • Pool state had to be maneuverable onto an exact tick-boundary edge case where sqrtP sat on the boundary and currentTick / nearestCurrentTick could be made inconsistent.
    • The attacker needed to mint liquidity that was already active at the current price so the later wrong boundary crossing would add it again.
    • A follow-up one-for-zero swap had to trust the corrupted boundary bookkeeping and reuse the same liquidity activation event.
    • The attacker needed enough capital or flash liquidity to push price far enough, then cash out the double-added depth before anyone paused the pool.
  • Technical anatomy worth remembering:
    • This is the preserved pre-exploit version of the same broad family later realized in KyberSwap Elastic (2023-11-22), but the legacy case centered on boundary bookkeeping and same-liquidity double-addition rather than the later reinvestment-liquidity rounding bug.
    • The whitehat’s PoC used a mainnet fork at block 17050000 and simulated draining most of the ETH side of a wstETH/ETH pool with an Aave flash loan after first pushing price down, then minting only enough narrow-range liquidity that the erroneous double-add would create drainable depth on the cheap side.
    • The dangerous invariant was simple: once a liquidity range is already active because price is inside it, crossing the next boundary must not add that same liquidity again.
    • The bug also shows why CLMM boundary-edge cases are special: when sqrtP sits exactly on a tick boundary, currentTick can no longer be treated as a casual derived convenience value. It becomes part of the solvency-critical state machine.
    • The patched contracts fixed the specific legacy edge by refusing to recompute currentTick from unchanged sqrtP inside that sub-swap branch, effectively adding a sqrtP != startSqrtP guard before the tick refresh.
  • Representative artifacts:
    • Legacy repo referenced by the whitehat: KyberNetwork/ks-elastic-sc-legacy
    • Legacy root-cause snippet highlighted in the postmortem: contracts/Pool.sol lines 405-408
    • Patched logic referenced by the whitehat: new ks-elastic-sc contracts/Pool.sol lines 413-419, adding the swapData.sqrtP != swapData.startSqrtP guard before recomputing currentTick
    • Tick-navigation helper involved in the bad next-boundary choice: PoolTicksState.sol lines 143-146 in the legacy repo, which could return currentTick itself as swapData.nextTick once the one-tick desynchronization existed
    • Periphery entrypoint used to force the exact-boundary setup: router ExactInputSingleParams.limitSqrtP
    • Whitehat simulation context: Ethereum mainnet fork at block 17050000
    • Example simulated target mentioned in the whitehat postmortem: the wstETH/ETH Elastic Legacy pool
  • Response / mitigation:
    • Kyber said the whitehat submitted the report on 2023-04-17 and the team validated it the same day; the whitehat says the bug was confirmed as valid by 2023-04-17 06:06 UTC.
    • The immediate mitigation was to disable minting / new Elastic Legacy liquidity deposits, halt Elastic farming rewards, and push public withdrawal guidance so LPs could exit before the legacy product was fully frozen.
    • The whitehat’s postmortem says Elastic liquidity fell from roughly $100M to about $2.5M in under 48 hours as LPs withdrew.
    • On 2023-04-18, Kyber captured a snapshot of remaining farming positions and rewards so users could later claim them safely.
    • Kyber later archived the vulnerable Elastic Legacy product, patched the logic in the new Elastic contracts, sent the updated code for another audit, and relaunched on 2023-05-25.

Pattern tags

  • tick-liquidity-desynchronization
  • concentrated-liquidity-boundary-coherence-and-reverse-path-discipline
  • directional-rounding-bias-roundtrip-non-invariance as a nearby arithmetic cousin, but the preserved mechanism here is sharper as semantic boundary state changed even though the economically relevant price position had not really advanced.
  • The decisive exploit primitive was not Kyber read the wrong external oracle; it was the pool's own tick-side state machine let one already-active position become active again, which is why the incident belongs in the internal-mark / boundary-coherence family more than in vanilla price-feed abuse.
  • The note is also a strong reverse-path case because the corrupting move was not the large monetizing swap but the tiny back-and-forth sequence that let price on boundary, currentTick, and nearestCurrentTick stop agreeing.

Why the exploit worked

  • Kyber Elastic Legacy let exact-boundary price state and exact-boundary semantic state drift apart. A second sub-swap that left sqrtP unchanged could still refresh currentTick, so the pool crossed from price is on the boundary into the bookkeeping says we are on the next side of the boundary without a real economic move.
  • Once that one-tick disagreement existed, the next one-for-zero swap reused currentTick itself as the next boundary to cross. That turned the pool’s own tick-navigation helper into a depth-printing primitive.
  • The attack only needed a narrow amount of attacker-minted liquidity because the protocol had already made that liquidity active on mint. The bug made the same liquidity become active a second time, which was enough to create drainable cheap-side inventory.
  • Router-controlled boundary placement mattered. limitSqrtP let the attacker force the exact edge state needed for the desynchronization, so this was not a hypothetical math corner case that only appeared in abstract tests.
  • The incident became a near miss only because a whitehat found it first and Kyber disabled deposits quickly. Onchain, the core path was already a full drain recipe.

Detection and audit pivots

  • Instrument exact-boundary states directly. Alert if sqrtP remains unchanged across a swap step but currentTick, nearestCurrentTick, or active liquidity still changes.
  • Build explicit assertions for already active once cannot become newly active again and run them across mint, reverse-swap, and minimal follow-up-swap sequences.
  • Diff boundary traversal against a reference implementation whenever router-supplied limitSqrtP pins the pool exactly on a valid tick.
  • Treat tiny reverse-path swaps as first-class audit objects. The dangerous event here was not a large directional move but a no-real-price-change cleanup leg that silently changed semantic boundary state.
  • Page operators on price pinned exactly on initialized tick + active-liquidity jump + fresh mint in adjacent range because that combination is the practical pre-drain signature of this family.
  • When a whitehat proves one CLMM core is vulnerable, assume every deployment sharing that core is already in scope and rehearse disable minting / halt incentives / tell LPs to leave as one linked response, not three separate decisions.

Why this incident matters

  • It preserves the important whitehat caught the CLMM boundary bug before mainnet theft branch of the tick/liquidity-desynchronization family.
  • It shows that concentrated-liquidity exploits do not need a bad oracle or an external callback. Internal disagreement about exactly which tick boundary was crossed is already enough to print tradeable depth.
  • It also provides a strong chronological precursor to the later 2023 KyberSwap Elastic exploit: the protocol had already seen one class of boundary-state failure in legacy Elastic months before a different custom-CLMM boundary/math bug turned into a live multichain drain.

Failure-prevention checklist

  • Encode exact-boundary coherence as a first-class invariant: when sqrtP sits exactly on a tick boundary, currentTick, nearestCurrentTick, nextTick, and active liquidity must still describe the same side of the boundary.
  • Never recompute boundary state from unchanged price just because a sub-swap branch executed. Kyber’s preserved legacy bug existed because currentTick could refresh from the same boundary sqrtP while nearestCurrentTick stayed behind.
  • Treat sqrtP unchanged branches as security-critical, not harmless no-ops. If price did not move, any derived tick refresh or boundary-cross bookkeeping deserves a separate proof that it cannot change semantic state.
  • Differential-test exact-boundary behavior against a simpler reference CLMM, especially for tiny forward / reverse swaps that cross back over the boundary and then leave sqrtP unchanged on the second leg.
  • Assert that an already-active position cannot become newly active again without price first leaving and then legitimately re-entering its range. Mint made it active and boundary crossing made it active must not both be true for the same liquidity unit.
  • Fuzz with router-supplied price limits such as limitSqrtP because attacker-controlled exact-boundary placement is part of the exploit setup, not a weird integration quirk.
  • Make reverse-path and two-sub-swap simulations mandatory. This near miss depended on a tiny reverse leg whose first sub-swap restored coherence and whose second sub-swap silently broke it again.
  • Keep pause / deposit-disable controls ready for all pools sharing the same CLMM core. Once one exact-boundary double-add recipe is validated, every legacy deployment using that code path is effectively in the blast radius.

Audit prompts

  • When sqrtP lands exactly on a tick boundary, what explicit invariant proves currentTick and nearestCurrentTick cannot disagree by one tick after any tiny reverse swap?
  • Which code paths refresh currentTick from sqrtP, and do any of them run even when sqrtP did not change during the step?
  • Can a two-part swap or router-assisted boundary placement cause the pool to cross back over a boundary, then perform a second no-move sub-step that changes bookkeeping without changing price?
  • If liquidity is minted while price already sits inside the position’s range, what stops a later boundary traversal from adding that same liquidity again to active depth?
  • Do tick-navigation helpers ever return currentTick itself as the next boundary to cross, and under what state assumptions is that safe?
  • What tests exist for exact boundary -> tiny reverse swap -> mint -> minimal follow-up swap sequences, and do they prove active liquidity stays single-counted?
  • Are exact-boundary states instrumented in production or staging so operators can detect price did not move but tick state changed before an attacker gets the monetizing swap?
  • If a whitehat or monitor proves one pool is vulnerable, how quickly can the protocol disable minting, halt incentives, and steer LPs out of every deployment on the same legacy code path?

Lessons that should already be standard

  • In a CLMM, price unchanged does not mean state unchanged. If unchanged sqrtP can still advance semantic tick state, the pool may already be broken.
  • Exact-boundary states are not cosmetic edge cases. They are the places where derived tick values stop being convenient metadata and become solvency-critical state.
  • Already-active liquidity must be single-use truth. If one position can become active once by minting and again by a later boundary crossing, the pool has manufactured depth.
  • Reverse-path micro-swaps deserve the same review intensity as large directional swaps. The dangerous leg here was tiny precisely because it only had to corrupt bookkeeping, not move price meaningfully.
  • Router-level price limits and path-shaping knobs are part of the adversarial surface. If an attacker can pin the pool to an exact boundary on demand, that setup must be in the security model.
  • Near misses matter. Kyber’s later November 2023 live exploit came from a different bug, but this earlier whitehat report had already shown the protocol family was vulnerable when price, tick, and liquidity state were allowed to drift apart.

Internal linkages

Control surface

  • The leverage sat in the CLMM’s tick-boundary bookkeeping: sqrtP, currentTick, nearestCurrentTick, and the code path that decided which boundary crossing should update active liquidity.
  • Once those values stopped agreeing about whether liquidity had already become active, the pool could be tricked into treating one LP position as if it had been entered twice.

Sources

Last reviewed

  • 2026-07-28 UTC