KyberSwap Elastic: When a Rounding Bug Drains a Pool
On 22 November 2023, an attacker drained KyberSwap Elastic pools across more than a dozen chains. Estimates of the total vary with the valuation snapshot and which back-running bots you count, landing somewhere around $48–56 million. There was no stolen key, no governance takeover, no oracle to manipulate. The whole thing came down to one rounding decision in the swap math being off by the smallest possible amount at exactly the wrong moment.
This one is worth studying because the bug is invisible at the level most people audit at. The Solidity reads correctly. The arithmetic is “right” in the sense that every individual operation does what it says. The vulnerability only exists in the gap between two computations that were each supposed to agree on a single question: did this swap reach the next tick boundary?
A two-minute tour of concentrated liquidity
KyberSwap Elastic is a concentrated-liquidity AMM in the style of Uniswap v3 — though not a fork; Kyber added its own “reinvestment liquidity” curve on top. In a concentrated-liquidity design, liquidity providers don’t spread capital across the entire price range. They deposit it into a bounded range, and the contract tracks price as a square root, sqrtP, moving along a number line divided into discrete ticks.
Each tick stores a liquidityNet value: the amount of liquidity that activates or deactivates when the price crosses it. As a swap pushes the price through a tick, the pool applies that tick’s liquidityNet so the active liquidity is always correct for the current price band. (The Uniswap v3 whitepaper is the clearest reference for this model if you want the full derivation.)
The integrity of the whole system rests on a deceptively simple invariant: a swap step either reaches the next tick boundary, in which case the tick is crossed and its liquidityNet is applied, or it stops short, in which case it does not. Get the boundary test wrong and the pool’s accounting of active liquidity quietly diverges from reality.
The step that lies about where it stopped
The per-step swap math lives in SwapMath.sol. The function computeSwapStep figures out how far a swap moves the price within a single step. Its own NatSpec states the property the exploit broke:
/// @notice nextSqrtP should not exceed targetSqrtP.
targetSqrtP is the price at the next tick boundary. To decide whether the swap reaches it, the code calls calcReachAmount — the amount of input needed to move price exactly to that boundary. If the swap’s input is at least that much, the step is treated as boundary-reaching: nextSqrtP is set to targetSqrtP and the caller knows to cross the tick. Otherwise the step stops short and calcFinalPrice computes where it actually landed.
calcReachAmount does its arithmetic with FullMath.mulDivFloor — multiply, divide, round down. Rounding down is the correct, conservative default almost everywhere in AMM math; you never want to hand a trader more than they paid for. The trouble is that this amount feeds a decision, not a payout. computeSwapStep compares it against the trader’s input to pick one of two paths: snap the price exactly to targetSqrtP (the boundary was reached), or compute a final price somewhere short of it. Right at a tick boundary, a one-unit rounding discrepancy is enough to push a step down the wrong path — snapping the price onto the boundary when the input did not quite justify it, or stopping just shy of a boundary the input should have reached. Either way the step’s record of where the price ended up stops matching the swap’s true arithmetic, and the property printed three comments above the function — nextSqrtP should not exceed targetSqrtP — is no longer something the surrounding code can lean on.
flowchart TD
classDef process fill:#1a2233,stroke:#7ea8d4,stroke-width:2px,color:#c0d8f0
classDef attack fill:#331a1a,stroke:#d97777,stroke-width:2px,color:#f0c0c0
classDef data fill:#332a1a,stroke:#d4b870,stroke-width:2px,color:#f0e0c0
A["calcReachAmount<br/>(mulDivFloor — rounds down)"]:::process
B["computeSwapStep:<br/>snap to boundary,<br/>or stop short?"]:::process
C{"resulting sqrtP ==<br/>tick boundary price?"}:::process
D["Cross tick:<br/>apply liquidityNet"]:::data
E["No cross"]:::process
F["Floor rounding at the boundary<br/>makes this test disagree<br/>with the true swap math"]:::attack
G["Active liquidity mis-counted<br/>pool drained"]:::attack
A --> B --> C
C -->|"equal"| D
C -->|"not equal"| E
F -.->|"corrupts the test"| C
D --> G
From a false boolean to a drained pool
The consequence lands in the swap loop in Pool.sol. After each step, swapData.nextSqrtP holds the price at the next tick boundary (from TickMath.getSqrtRatioAtTick), while swapData.sqrtP holds where the step actually ended. The loop crosses the tick — and calls _updateLiquidityAndCrossTick, which applies the tick’s liquidityNet — only when those two are equal, i.e. the step landed exactly on the boundary:
// swapData.nextSqrtP is the price at the next tick boundary
if (swapData.sqrtP != swapData.nextSqrtP) break; // ended inside the range: no cross
// reached the boundary exactly -> cross the tick and apply liquidityNet
_updateLiquidityAndCrossTick(swapData.nextTick, /* ... */);
So an exact-equality check on a 160-bit price decides whether liquidity is added or removed at the boundary. When the rounding in the step math makes that equality fire — or fail to fire — out of step with the swap’s true progress, the tick’s liquidityNet is applied in a state the pool’s accounting did not expect. Its view of how much liquidity is active drifts from what actually backs the positions. A reverse swap through the same region then draws against liquidity that has effectively been counted twice, and that gap is the drain.
The attacker engineered the surrounding conditions to make that gap exploitable:
- Borrow. Take a flash-style loan of a liquid token to fund the setup.
- Position the price. Swap to push the target pool’s price to a chosen tick boundary.
- Provide thin liquidity. Mint a narrow concentrated-liquidity position straddling that boundary, so the corrupted accounting governs almost the entire active range.
- Trip the bug. Execute a precisely sized swap that lands the price on the tick boundary, where the floor rounding makes the cross-or-not test disagree with the swap’s true math.
- Extract. Reverse-swap to pull out the phantom liquidity, draining the pool, then repeat across pools and chains.
The narrow position is the clever part. The bug is a rounding artifact worth a sliver of value per swap; concentrating nearly all the pool’s active liquidity into the boundary the attacker controlled turned that sliver into the whole pool.
Could static analysis have caught it?
Honestly, not easily, and it’s worth being clear about why. Detectors that flag division-before-multiplication or missing scaling factors catch a large share of precision bugs because those patterns are locally visible — you can see the bad ordering in a single expression. This bug isn’t local. Each operation is individually defensible; mulDivFloor is the right primitive in isolation. The defect is a cross-function agreement failure: two computations that must round in compatible directions to keep a boolean honest, and don’t.
Catching this class reliably means encoding the boundary invariant itself — the step’s reported price must be consistent with the step’s reach decision — and checking it as a semantic property, not a syntactic pattern. That’s the harder, more valuable end of static analysis, and it’s a good argument for property-based and invariant testing during development: a fuzzer asserting “active liquidity after a round-trip swap equals active liquidity before” would have surfaced the divergence without anyone naming the bug first.
The takeaway
Rounding direction is a security property. In financial contracts, every floor and ceil is a decision about who absorbs the fractional remainder, and the answer has to stay consistent everywhere the value flows — especially when a rounded number feeds a comparison rather than a payout. KyberSwap Elastic rounded down in a spot where down was conservative for the trade but wrong for the control flow, and the two computations that were supposed to agree on a single yes-or-no question quietly stopped agreeing.
The on-chain trail is public: the primary exploiter account is labeled on block explorers, and the affected swap math is open source, so you can read the exact step that lies about where it stopped.
References
- KyberNetwork/ks-elastic-sc — KyberSwap Elastic contracts
- SwapMath.sol —
computeSwapStep,calcReachAmount, and thenextSqrtP should not exceed targetSqrtPinvariant - Pool.sol — swap loop and the tick-crossing branch
- PoolTicksState.sol —
_updateLiquidityAndCrossTick - Uniswap v3 whitepaper — concentrated liquidity, ticks, and
liquidityNet - Uniswap/v3-core — reference v3 swap-step math
- Etherscan — labeled exploiter address
- CWE-682: Incorrect Calculation
- CWE-1339: Insufficient Precision or Accuracy of a Real Number