Case: KyberSwap Elastic (November 2023)
“Concentrated-liquidity AMMs replaced a constant-product curve — twelve lines of math — with a tick-indexed, fee-reinvesting, square-root-price machine spanning thousands of lines and dozens of fixed-point rounding decisions. KyberSwap Elastic was the first protocol to lose nine figures because one of those rounding decisions went the wrong direction. The bug had been audited around (twice by ChainSecurity, once via a Sherlock contest with 207 watson reviewers), and missed by everyone. The attacker not only found it — they wrote a six-step exploit script, drained eleven chains in parallel, and then mailed a ransom letter on-chain demanding executive control of the company. This case is the high-water mark for ‘math edge case’ as a vulnerability class, and the warning every auditor needs before they review their next CLMM.”
Tags: case-study concentrated-liquidity amm tick-math precision-loss rounding defi kyber #2023 vulnerability Related: Tuan-08-DeFi-Security-AMM-Lending-Vault · Tuan-09-Oracle-MEV-Economic-Attack · Case-Euler-Finance-2023 · Case-The-DAO-Reentrancy-2016 · audit-checklist-master
1. At a Glance
| Field | Value |
|---|---|
| Date | November 22–23, 2023 (UTC) — coordinated drain across eleven chains within ~2 hours |
| Protocol | KyberSwap Elastic — a Uniswap-V3-style concentrated-liquidity AMM with an additional “reinvestment liquidity” mechanic that compounds swap fees back into the pool’s effective depth |
| Loss | ~54–56M in aggregate including front-running copycats (figures vary by snapshot price); ~706k of additional locked-state funds recovered later [verify final figure across post-mortems — Kyber’s Feb 2024 post-mortem gives the canonical breakdown] |
| Attack class | Math edge case in concentrated-liquidity tick-crossing logic — specifically a mis-directed rounding in computeSwapStep that allowed the pool’s currentSqrtP to exceed the target tick’s sqrtP while the tick counter recorded “no crossing,” producing a state where the next swap double-counted the liquidity at that tick boundary |
| Root cause | KyberSwap/elastic-contracts/contracts/libraries/SwapMath.sol used FullMath.mulDivFloor where mulDivCeiling (round-up) was required, in the deltaL step of the “amount insufficient to cross tick” branch. The rounding-down of deltaL caused nextSqrtP to round up past the boundary the function had just decided not to cross. |
| Attacker EOA | 0x50275E0B7261559cE1644014d4b78D4AA63Be836 |
| Attack contract | 0xaF2Acf3D4ab78e4c702256D214a3189A874CDC13 |
| Sample tx (frxETH/WETH pool, Ethereum) | 0x485e08dc2b6a4b3aeadcb89c3d18a37666dc7d9424961a2091d6b3696792f0f3 |
| Chains hit | Ethereum, Arbitrum, Optimism, Polygon, Avalanche, BSC, Fantom, Base, Scroll, Linea, Polygon zkEVM, BitTorrent, Cronos (13 deployments; primary exploiter targeted 11) |
| Affected LPs | 2,367 unique addresses lost funds; 96 LPs lost more than 1M each |
| Audit history pre-attack | ChainSecurity (multiple engagements — initial pre-launch 2021, re-audit 2023 after a separately-disclosed bug); Sherlock audit contest July–Sept 2023 with 207 watson participants; none flagged this bug |
| Outcome | Attacker negotiated publicly, demanded full executive control of Kyber Network, never returned the primary funds; KyberSwap laid off 50% of staff in December 2023; KyberSwap Elastic was shut down on 26 March 2024; LPs partially compensated via a Treasury Grant Program |
2. Background — Concentrated Liquidity and the Tick Machine
To understand the bug you need to understand what KyberSwap Elastic was trying to do, because the failure was not in any single function — it was in a subtle inconsistency between two ways of computing the same quantity.
2.1 From xy = k to xy = k-in-a-range
The original Uniswap (V1, V2) and most early AMMs used the constant-product invariant x · y = k: liquidity providers deposit two tokens, the pool maintains x · y = k, and any swap moves along that hyperbola. Simple, robust, and capital-inefficient — almost all of the liquidity sits at prices nobody will ever trade at.
Uniswap V3 (2021) introduced concentrated liquidity: LPs choose a price range [p_low, p_high] and their capital only participates when the pool’s price is inside that range. Inside the range, the pool still obeys x · y = k locally, but with a much larger virtual k — meaning much deeper liquidity and tighter slippage for the same dollar deposit. The trade-off: when the price exits your range, your position becomes 100% of one asset and earns no fees until price returns.
KyberSwap Elastic (Dec 2021) was a near-clone of Uniswap V3 with one addition: reinvestment liquidity. Swap fees were not paid out as separate fee-token claims (as in V3); they were reinvested directly back into the pool as additional liquidity, increasing the pool’s effective depth over time. This was the marketing differentiator — “auto-compounding LP positions” — and as we’ll see, the source of the bug.
2.2 Ticks — the price grid
In a concentrated-liquidity AMM, prices are not continuous; they live on a discrete logarithmic grid called ticks. Each tick i corresponds to a price p_i = 1.0001^i. Tick spacing (e.g. 1, 10, 60, 200) controls how fine-grained the grid is for a given pool.
LP positions span ranges expressed as tick indices: [tick_lower, tick_upper]. When the pool’s price p is at tick i, the active liquidity L is the sum of every LP position whose range contains tick i. A swap that moves the price across a tick boundary i → i+1 must update L by adding/removing the positions whose endpoints lie at that boundary.
Internally, the pool tracks not the price itself but its square root, sqrtP = √p, scaled to 96 bits of fractional precision (Q64.96 fixed-point format). All swap math is done in sqrtP space because the invariants linearize there:
Δx = L · (1/sqrtP_after − 1/sqrtP_before)Δy = L · (sqrtP_after − sqrtP_before)
These two formulas are the entire AMM in one line. Everything else is rounding and bookkeeping.
2.3 The swap loop
A swap from token X for token Y proceeds in a loop:
remaining = input_amount
while remaining > 0:
next_tick = find next initialized tick in direction of swap
sqrtP_target = sqrt(1.0001 ^ next_tick)
(sqrtP_new, in_used, out_paid) = computeSwapStep(
sqrtP_current, sqrtP_target, L, remaining)
sqrtP_current = sqrtP_new
remaining -= in_used
out_total += out_paid
if sqrtP_new == sqrtP_target:
# we hit the boundary — cross the tick
L += liquidityNet[next_tick] # add/remove positions at this tick
currentTick = next_tick
else:
# we ran out of input before reaching the boundary
currentTick = TickMath.getTickAtSqrtRatio(sqrtP_new)
break
Two branches: “reached the target” (cross the tick, update L, continue) or “ran out of input” (stop, recompute currentTick from the final price). The crucial invariant the entire system depends on is:
In the “ran out of input” branch,
sqrtP_newmust be strictly inside the current tick range — that is,sqrtP_new < sqrtP_target(when swapping up) orsqrtP_new > sqrtP_target(when swapping down).
If sqrtP_new equals the target the loop should have taken the first branch (cross the tick). If sqrtP_new exceeds the target the system has become inconsistent: the price has effectively crossed the tick, but the liquidity update L += liquidityNet[next_tick] has been skipped. The next swap step will see a stale L value and pay out as if no positions had been crossed.
This is exactly the inconsistency the KyberSwap attacker engineered.
2.4 Reinvestment liquidity — Kyber’s twist
In Uniswap V3, swap fees are tracked in separate “fee growth” accumulators and only added to liquidity when an LP explicitly compounds. In KyberSwap Elastic, swap fees are converted into additional liquidity in-flight, during the swap itself. The pool maintains two liquidity components:
baseL— sum of LP-position liquidity active at the current tick (the Uniswap-V3 equivalent).reinvestL— accumulated fee liquidity that participates in pricing alongsidebaseL.
The “active liquidity” used in the swap math is L = baseL + reinvestL. The reinvestment increment per step is deltaL = swapFee · L / sqrtP_new (approximately — actual code is more careful).
This addition created one new requirement: computeSwapStep had to estimate deltaL before it knew the final sqrtP, then use that deltaL to compute sqrtP, then verify the result was self-consistent. Bootstrapping this fixed point introduced a rounding choice that did not exist in Uniswap V3.
That rounding choice is the bug. [verify against SwapMath.sol source for the exact line — the BlockSec analysis pins it to the deltaL derivation in the “not crossing” branch, where FullMath.mulDivFloor was used and mulDivCeiling should have been]
3. The Vulnerability — Mis-Directed Rounding at the Tick Boundary
3.1 The intended logic (in pseudocode)
For the “amount insufficient to reach the next tick” branch, computeSwapStep does:
// Step 1: figure out the maximum input that would reach the boundary
uint256 reachAmount = calcReachAmount(sqrtP, sqrtP_target, L, ...);
if (specifiedAmount >= reachAmount) {
// crossing branch — handled separately
return (sqrtP_target, reachAmount, ...);
}
// Step 2: estimate the extra reinvestment liquidity for this partial step
uint256 deltaL = estimateIncrementalLiquidity(
absDelta, L, sqrtP, fee
);
// COMMENT IN-CODE: "deltaL should be rounded UP, so nextSqrtP is rounded DOWN"
// REALITY: implementation calls FullMath.mulDivFloor(...) ← BUG
// Step 3: compute the resulting price from the post-step liquidity
nextSqrtP = calcFinalPrice(absDelta, L + deltaL, sqrtP);The invariant the comment is enforcing: in the “did not cross” branch, nextSqrtP must round toward sqrtP_current (away from sqrtP_target), so the loop’s exit condition (nextSqrtP != sqrtP_target) reflects truth. Rounding deltaL up increases the effective denominator in the price computation, which rounds nextSqrtP down — toward sqrtP_current, away from the boundary. Safe.
Rounding deltaL down — what the code actually did — produces the opposite: nextSqrtP rounds up, toward the boundary, and in pathological inputs can round past it.
3.2 The pathological input
The attacker’s job was to construct a swap where this rounding error mattered. Two conditions:
specifiedAmountjust barely less thanreachAmount— so the function takes the “not crossing” branch.L + deltaLsmall enough relative toabsDelta— so the rounding error indeltaL(1 unit in the last place) produces a non-negligible price drift.
Condition (2) is why the attacker had to prepare the pool first: they pushed the price outside every LP’s range (driving baseL to zero) so that only reinvestL plus their own tiny test position contributed to L. With L near 75 quintillion units (a small number for an AMM math context), the deltaL rounding error became large enough to flip nextSqrtP across the target.
When the attacker submitted specifiedAmount = reachAmount − 1 (literally: one wei less than the crossing threshold), the math returned:
sqrtP_target (tick 111,310) = 20,693,058,119,558,072,255,662,180,724,088
nextSqrtP = 20,693,058,119,558,072,255,665,971,001,964
^^^^^^^^^^^^^^^^^^^^^
nextSqrtP > target!
A difference of 3,790,277,876 in the last 10 digits — laughably small in absolute terms, catastrophically wrong as an invariant. The function returned this nextSqrtP to the swap loop with the “did not cross” status.
3.3 What the swap loop did with the bad result
In Kyber Elastic’s Pool.sol, the swap-loop epilogue looked roughly like this [verify exact lines against Pool.sol in kybernetwork/ks-elastic-sc]:
if (swapData.sqrtP != swapData.nextSqrtP) {
if (swapData.sqrtP != swapData.startSqrtP) {
swapData.currentTick = TickMath.getTickAtSqrtRatio(swapData.sqrtP);
}
break; // exit the loop — DO NOT cross tick, DO NOT update baseL
}swapData.sqrtP is now 20,693,058,119,558,072,255,665,971,001,964 — which TickMath.getTickAtSqrtRatio rounds to tick 111,310 (the very tick the function decided not to cross). The pool’s currentTick is written as 111,310, but the baseL accumulator is never updated with liquidityNet[111,310].
The pool’s state machine is now in a configuration that the math says is unreachable:
| Variable | Value | Invariant violation |
|---|---|---|
currentTick | 111,310 | should be 111,309 if loop “didn’t cross” |
sqrtP | slightly above sqrtP(111,310) | should be ≤ sqrtP_target |
baseL | unchanged (positions at tick 111,310 NOT added) | should include liquidityNet[111,310] if currentTick ≥ 111,310 |
The pool is now lying about its own liquidity at tick 111,310. The positions whose lower endpoint is 111,310 should be active but aren’t. The next swap that crosses 111,310 from above will re-add those same positions to baseL — doubling them.
3.4 The double-counting payload
The attacker’s second swap (the reverse direction) is the payload. It enters the swap loop with the inconsistent state, and at the very first tick boundary crossing:
// Inside _updateLiquidityAndCrossTick
baseL = baseL + liquidityNet[111,310]; // ← adding positions that were
// already "supposed to be" activeBut because the first swap’s epilogue had set currentTick = 111,310 without doing this addition, the positions at tick 111,310 are now added a second time on the way back across. The pool’s baseL is now too large by 2 · |liquidityNet[111,310]|. The reverse swap pays out against this inflated liquidity, releasing far more output than the attacker put in.
That’s the “double liquidity” trick in one sentence: a forward swap that quietly crosses a tick without updating baseL, followed by a reverse swap that crosses it back and adds the positions a second time. The pool ends with positions counted twice; the attacker ends with the difference in their wallet.
3.5 The trust assumption that failed
Kyber Elastic’s swap logic assumed:
“If
computeSwapStepsays we didn’t reach the target, thensqrtP_new < sqrtP_target. Therefore it is safe to skip the tick-crossing update and exit the loop.”
A single rounding mistake broke that assumption. The fix is one line:
require(sqrtP_new < sqrtP_target, "BUG: nextSqrtP overshot target");…or, equivalently, fix the rounding direction so the overshoot is mathematically impossible. Defense in depth would have done both.
4. The Attack — Six Steps, $48M
4.1 The cast of pools
The primary exploiter ran the same six-step recipe in parallel across many pools on many chains; the canonical reference target is the WETH/wstETH and WETH/frxETH pools on Ethereum. Numbers below are from the frxETH/WETH pool transaction — the cleanest worked example.
4.2 Step 1 — Flash loan
attacker.flashLoan(AAVE_v3, WETH = 2000)
The 2000 WETH is working capital for steps 2–3 (manipulating the pool price). The attack does not depend on AAVE specifically — any flash-loan source works; flash loans are simply free capital to position the pool, not a structural part of the exploit.
4.3 Step 2 — Push price out of every LP’s range
attacker.swap(WETH → frxETH, amount = 6.8496 WETH)
This raises sqrtP to 20,282,409,603,651,670,423,947,251,286,016 (tick 110,909) — a price corner where no real LP has liquidity. After this step, baseL ≈ 0 and only reinvestL participates in pricing. This is essential for step 5 to work: with a small L, the rounding error in deltaL becomes large enough (in price space) to push nextSqrtP across the next tick boundary.
4.4 Step 3 — Mint and partially burn a precisely-sized LP position
attacker.mint(range = [110909, 111310], frxETH = 0.006948, WETH = 0.1078)
// adds liquidity 89,631,297,100,385,708,499
attacker.burn(range = [110909, 111310],
amount = 14,938,549,516,730,950,591)
// removes part of the just-minted liquidity
// Net liquidity in attacker's position:
// 89,631,297,100,385,708,499 − 14,938,549,516,730,950,591
// = 74,692,747,583,654,757,908
The attacker is engineering an exact total active liquidity inside [110,909, 111,310]. The number 74,692,747,583,654,757,908 is the glitch liquidity — the specific magnitude at which reachAmount and the rounded nextSqrtP produce the overshoot. This is brute-search-precomputed off-chain; it is not divinable by inspection. [verify exact value — BlockSec and Antchain analyses agree to ~12 sig-figs]
4.5 Step 4 — The deceptive swap
attacker.swap(WETH → frxETH, amount = 387,170,294,533,119,999,999)
// = 387.170 WETH minus 1 wei
Now the trap fires. The pool’s calcReachAmount returns:
reachAmount_with_reinvestment = 387,170,294,533,120,000,000 (387.170 WETH exactly)
reachAmount_without_reinvestment = 387,160,697,969,657,129,472 (387.160 WETH)
The attacker’s specifiedAmount = 387,170,294,533,119,999,999 is one wei less than reachAmount_with_reinvestment — so the “did not cross” branch fires. But the rounded nextSqrtP lands at 20,693,058,119,558,072,255,665,971,001,964, just above sqrtP(111,310) of 20,693,058,119,558,072,255,662,180,724,088. TickMath.getTickAtSqrtRatio(nextSqrtP) returns 111,310.
Pool state after step 4:
currentTick = 111,310sqrtP > sqrtP(111,310)(the lie)baseLunchanged — positions at tick 111,310 not added.
Etherscan note: the attacker labeled this transaction Step 2, finding liquidity required. [verify label phrasing — multiple sources reproduce this]
4.6 Step 5 — The exploit swap
attacker.swap(frxETH → WETH, amount = 5,868,809 wei frxETH)
// approximately 0.00586 frxETH
A tiny input. The reverse-direction swap enters the loop with the inconsistent state, hits tick 111,310 going down, and _updateLiquidityAndCrossTick does:
baseL = baseL + |liquidityNet[111,310]|…which adds the positions that should have been added during step 4 but weren’t. With baseL now (approximately) doubled, the swap continues across into the price range [110,909, 111,310] where the attacker’s own liquidity is concentrated. The pool pays out an order of magnitude more WETH than the input frxETH should have purchased:
Input: 5,868,809 wei frxETH (≈ 0.005868 frxETH)
Output: 396,244,493,223,555,299,358 WETH (≈ 396.244 WETH)
Compare to step 4: 387.170 WETH in, 6.371 frxETH out. The reverse trade returns 396.244 WETH for 0.005868 frxETH — a net gain of roughly 9 WETH per round-trip plus essentially all the frxETH back. Multiply by the size of each round-trip and by the number of pools and chains: $48M.
4.7 Step 6 — Repay and exit
attacker.repay(AAVE_v3, WETH = 2000)
attacker.withdraw(profit ≈ 6.364 WETH + 1.117 frxETH per round-trip)
The flash loan is repaid, and the per-trade net profit is sweepable. The attacker repeated this loop across pools on Ethereum, Arbitrum, Optimism, Polygon, Avalanche, BSC, Base, Scroll, Linea, Polygon zkEVM, and Fantom. By the time monitoring caught it (less than 2 hours into the campaign), the multi-chain drain totaled **~5.82M and ~$565k siphoned by two front-running MEV bots that observed the exploit calldata in the mempool and replayed it.
4.8 Why the front-runners only got pennies
MEV bots could see what the attacker was doing but not which pools and what amounts would still produce a profitable round-trip. The exploit depended on precise pre-positioning (steps 2 and 3) of each victim pool. Bots that copied the trade calldata on un-prepared pools either reverted or made very small profit. Two bots stumbled onto pools that were still in an exploitable state and netted 565k respectively. KyberSwap eventually recovered ~$5.17M from these bots through negotiation (10% bounty deal). The primary exploiter never returned a cent.
5. The Attacker’s On-Chain Message
What sets KyberSwap Elastic apart from every other AMM hack in the historical record is what came after the drain.
5.1 The first message (November 25–26, 2023)
The attacker — calling themselves “Kyber Director” — sent an on-chain message via Ethereum input data:
“Dear Kyberswap Developers, Employees, DAO members, and LPs, Negotiations will start in a few hours when I am fully rested.”
This was the first signal the attacker intended a “negotiation” rather than an immediate launder-and-vanish. Kyber’s executives publicly offered a 10% bounty (~43M) on November 24.
5.2 The “Be nice” message (November 28–29)
After the Kyber team paired the bounty offer with public threats of legal action, the attacker fired back:
“I said I was willing to negotiate. In return, I have received (mostly) threats, deadlines, and general unfriendliness from the executive team.”
“…I plan to release a statement around a potential treaty with KyberSwap on November 30 — but won’t do it if hostilities continue.”
This was, by hacker standards, a moderate-toned message. The next one was not.
5.3 The “treaty” — November 30, 2023
On Thursday November 30, the attacker posted a long on-chain “ransom letter” titled “The Treaty,” demanding effectively complete corporate takeover:
- Full executive control of Kyber Network, including the right to hire, fire, and direct the team
- Full authority over the KyberDAO governance
- All KNC tokens held by the company
- All company equity and shares
- Access to all internal documents and IP
- A December 10 deadline
Quoted lines (preserved across multiple journalist transcriptions):
“You will be wished well in your future endeavors. You haven’t done anything wrong…. Simply bad luck.”
“It is understandable many employees will want to leave regardless. Non-executive staff will receive doubled salaries; departing employees will receive 12-month severance.”
“Under this treaty, your tokens will no longer be worthless.”
“Under my management, Kyber will undergo a complete makeover.”
The letter included a contact handle: Telegram @Kyber_Director. It was the first publicly known attempt to use a smart-contract exploit as leverage for hostile corporate takeover. KyberSwap did not accept the “treaty.”
5.4 Aftermath of the negotiation
- The primary exploiter never returned funds.
- Funds were eventually bridged to Ethereum and laundered via Tornado Cash.
- Front-running bot exploiters (Exploiters 2 and 3) settled at the 10% bounty rate; ~$5.17M was returned.
- KyberSwap pursued the white-hat recovery of ~$706k of additional locked funds through community researchers.
6. Reproduction in Foundry (Simplified)
A full reproduction of the bug requires re-deploying the entire Kyber Elastic core (~4,000 lines of Solidity) and Mainnet-forking against a frxETH/WETH-shaped pool. That’s too heavy for a lab. Below is a stripped model that captures the shape of the math bug: a computeSwapStep-style function whose rounding direction lets nextSqrtP overshoot sqrtPTarget, and a calling loop that trusts the result.
6.1 Vulnerable swap math (one tick step)
// src/MiniCLMM.sol
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.20;
/// @notice A toy concentrated-liquidity step, modeled on KyberSwap Elastic's
/// SwapMath.computeSwapStep. The bug: `deltaL` is rounded DOWN where it should
/// be rounded UP, allowing `nextSqrtP` to overshoot `sqrtPTarget` in the
/// "did not cross" branch.
library MiniSwapMath {
/// @dev Toy: assume 1-D move, swap-up direction only (sqrtP rising).
/// `L` is total active liquidity (baseL + reinvestL).
/// Returns (nextSqrtP, inUsed, deltaL).
function computeSwapStep(
uint256 sqrtP,
uint256 sqrtPTarget,
uint256 L,
uint256 specifiedAmount,
bool buggy
) internal pure returns (uint256 nextSqrtP, uint256 inUsed, uint256 deltaL) {
// Maximum input to reach the boundary (toy formula, omits fee path)
uint256 reachAmount = (L * (sqrtPTarget - sqrtP)) / sqrtPTarget;
if (specifiedAmount >= reachAmount) {
// Crossing branch: clamp to target
return (sqrtPTarget, reachAmount, 0);
}
// "Did not cross" branch — vulnerable rounding lives here
uint256 fee = 30; // 30 bps toy fee
uint256 feeAmount = (specifiedAmount * fee) / 10000;
if (buggy) {
// Rounded DOWN (mulDivFloor) — the bug
deltaL = (feeAmount * L) / sqrtP;
} else {
// Rounded UP (mulDivCeiling) — the fix
deltaL = (feeAmount * L + sqrtP - 1) / sqrtP;
}
uint256 Lprime = L + deltaL;
// nextSqrtP from invariant: specifiedAmount = Lprime * (1/sqrtP - 1/nextSqrtP)
// Toy algebra (rounded the way the buggy direction wants):
nextSqrtP = (Lprime * sqrtP) / (Lprime - specifiedAmount);
inUsed = specifiedAmount;
}
}6.2 Test demonstrating overshoot
// test/MiniCLMM.t.sol
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.20;
import "forge-std/Test.sol";
import "../src/MiniCLMM.sol";
contract MiniCLMMTest is Test {
using MiniSwapMath for *;
function test_buggyRoundingOvershoots() public {
// Toy pool state: low active liquidity, swap right up to the boundary.
uint256 sqrtP = 1e18;
uint256 sqrtPTarget = 2e18;
uint256 L = 1_000e18;
// reachAmount in this toy model: L * (sqrtPTarget - sqrtP) / sqrtPTarget
// = 1000e18 * 1e18 / 2e18 = 500e18
uint256 specifiedAmount = 500e18 - 1; // one wei short of crossing
(uint256 next_bug, , ) = MiniSwapMath.computeSwapStep(
sqrtP, sqrtPTarget, L, specifiedAmount, /*buggy=*/true);
(uint256 next_fix, , ) = MiniSwapMath.computeSwapStep(
sqrtP, sqrtPTarget, L, specifiedAmount, /*buggy=*/false);
emit log_named_uint("nextSqrtP (buggy round-down)", next_bug);
emit log_named_uint("nextSqrtP (fixed round-up)", next_fix);
emit log_named_uint("sqrtPTarget", sqrtPTarget);
// The whole point: with buggy rounding, nextSqrtP > target
// even though the function returned in the "did not cross" branch.
assertGt(next_bug, sqrtPTarget,
"BUG demonstration: nextSqrtP overshot the boundary");
assertLt(next_fix, sqrtPTarget,
"FIX: nextSqrtP correctly stays below the boundary");
}
}6.3 Expected behavior
- The buggy invocation returns
nextSqrtP > sqrtPTarget(overshoot — invariant broken). - The fixed invocation returns
nextSqrtP < sqrtPTarget(invariant preserved). - The test passes, demonstrating the bug at a single-step level.
A full multi-step extension would wire computeSwapStep into a swap loop that updates currentTick and baseL, mint two positions whose lower endpoint is the boundary tick, and show that a forward + reverse swap round-trips strictly more value out than in. The numerics in §4 give the recipe; reproducing them at full scale is left as a lab exercise.
6.4 The one-line fix
// AT THE END OF computeSwapStep (paranoid invariant guard):
require(
crossing ? nextSqrtP == sqrtPTarget : nextSqrtP < sqrtPTarget,
"SwapMath: invariant violated"
);…or, more fundamentally, change mulDivFloor to mulDivCeiling on the deltaL line as the in-code comment originally intended. KyberSwap’s actual patch did both: corrected the rounding and added defensive checks before tick boundary crossings.
6.5 Stretch lab: fuzz the invariant
function testFuzz_neverOvershoot(
uint256 sqrtP,
uint256 sqrtPTarget,
uint256 L,
uint256 specifiedAmount
) public {
sqrtP = bound(sqrtP, 1e15, 1e30);
sqrtPTarget = bound(sqrtPTarget, sqrtP + 1, sqrtP * 4);
L = bound(L, 1e10, 1e30);
specifiedAmount = bound(specifiedAmount, 1, type(uint128).max);
(uint256 nxt, , ) = MiniSwapMath.computeSwapStep(
sqrtP, sqrtPTarget, L, specifiedAmount, /*buggy=*/true);
// INVARIANT: nextSqrtP must NEVER exceed target.
assertLe(nxt, sqrtPTarget, "INVARIANT VIOLATED");
}With buggy=true, fuzzing finds counter-examples in seconds. With buggy=false, the invariant holds. This invariant test, run on the real SwapMath.computeSwapStep with Foundry fuzz or Echidna, would have caught the KyberSwap Elastic bug pre-launch. That fact is the lesson of this case study.
7. Aftermath
7.1 First 48 hours — drain, detection, pause
- Nov 22, 22:00 UTC (approximate) — primary exploiter begins multi-chain drain.
- Nov 22–23 (~2 hour window) — most damage done.
- Nov 23, early UTC — KyberSwap announces the exploit, pauses Elastic pools and farms across all chains. Most LP withdrawals halted; LPs in unaffected pools rescued via emergency withdraw paths.
- Nov 23–24 — KyberSwap publishes a 10% bounty offer.
- Nov 24 — front-running bot exploiters identified; ~$5.17M recovered after negotiation.
7.2 The negotiation theater
As described in §5: the attacker performed the most elaborate on-chain ransom dialogue in history, ultimately demanding executive takeover of the company. Kyber refused. Funds were eventually bridged and laundered via Tornado Cash. No primary recovery.
7.3 Treasury Grant Program
KyberSwap created a compensation framework for affected LPs:
- Category 1, 2, 4 (unrecovered):
- Option A: 60% in stablecoins, 3-month vest.
- Option B: 100% in stablecoins, 12-month vest.
- Category 3 (mostly-recovered pools): proportional distribution of recovered funds.
Funded from KyberSwap treasury — significant dilution of the company’s runway.
7.4 Layoffs and shutdown
- December 2023 — CEO Victor Tran announced 50% layoffs to extend runway.
- March 26, 2024 — KyberSwap Elastic was officially deprecated and shut down. LPs were directed to withdraw any residual liquidity. The product that lost $48M did not survive its post-mortem.
- KyberSwap as a company pivoted to focus on the aggregator product (KyberSwap Aggregator) and KyberSwap Classic, which were unaffected. The Elastic codebase is now archival.
7.5 Industry consequences
- Concentrated-liquidity audits doubled in price, with audit firms specifically pricing in for “tick math invariant fuzzing” as a separate workstream.
- Echidna / Foundry invariant suites became table stakes for any CLMM. Several audit firms (Trail of Bits, Spearbit) published CLMM-specific invariant catalogs after the incident.
- Sherlock and Code4rena adjusted their contest scoping to require explicit math-edge-case bounty pools for CLMM forks.
- The “audited by N firms” claim lost weight as a security signal. Kyber Elastic was reviewed by ChainSecurity twice and by a 207-watson Sherlock contest. None caught the bug. The phrase “audited ≠ secure” was repeated by every responsible communicator in the wake.
- The on-chain ransom-letter pattern, while not new (Wormhole’s attacker also negotiated, see Case-Wormhole-2022; Euler’s did, see Case-Euler-Finance-2023), reached its theatrical peak here. Subsequent attackers (Penpie, Munchables) imitated the format.
8. Lessons for Auditors
8.1 Math near boundaries is the auditor’s frontier
The KyberSwap bug is not “subtle” in the sense of being hidden in obscure code paths. It lives in SwapMath.computeSwapStep — the single most-reviewed function in any CLMM, surrounded by inline comments that literally describe the correct rounding direction. Auditors read this function. They missed the bug.
Why? Because human reviewers verify what the function does, not whether the function’s output satisfies the system’s invariants under adversarial input. The output of computeSwapStep was correct to within 10 digits — under most inputs, it was exactly correct. Only with deliberately-chosen (sqrtP, sqrtPTarget, L, specifiedAmount) quadruples did the rounding error propagate into a state-machine invariant violation.
The frontier for auditing CLMMs is not “read every line” — it’s “declare every invariant and fuzz every boundary.” Specifically:
| Invariant | Test |
|---|---|
nextSqrtP ≤ sqrtPTarget (swap-up, not crossing) | Property-based test, vm.assume valid inputs |
nextSqrtP ≥ sqrtPTarget (swap-down, not crossing) | Same |
crossing ⇔ (nextSqrtP == sqrtPTarget) | Same |
roundtrip: swap(X, Y) then swap(Y, X) never profits | Differential property test |
total user output ≤ total user input + accumulated fees | Pool-level invariant |
Run them with Foundry fuzz, Echidna, or Halmos. Run them for hours in CI. KyberSwap’s bug shows up in seconds.
8.2 Concentrated liquidity is math-heavy — fuzz it
The Uniswap V3 paper is ~12 pages of math. The implementation is ~3,000 lines of fixed-point arithmetic. Every fork inherits the math but reimplements the implementation. Each reimplementation is a new audit surface; each new feature (like Kyber’s reinvestment liquidity) is a new edge case.
The general rule for forks of math-heavy primitives:
A clean-room reimplementation of a sound design is a new design. The fact that the original (Uniswap V3) is unexploited tells you nothing about the safety of your port. Treat the math library as untrusted until you have replayed Uniswap V3’s invariant suite against your code and added new invariants for your additions.
KyberSwap Elastic was the textbook fail of this rule. Reinvestment liquidity changed the deltaL computation. The change required a new rounding decision. The new rounding decision was wrong.
8.3 Invariants must hold across every tick crossing
Concentrated-liquidity pools have billions of internal state transitions per year (one per tick crossing per swap, across all pools). Even a 1-in-a-billion edge case in tick-crossing math will be triggered routinely.
The invariant baseL ≥ 0 (or, in fixed-point, baseL stays consistent with the sum of liquidityNet entries up to currentTick) is non-negotiable. There must be:
- A test that simulates 10,000 random swap sequences and asserts liquidity consistency at every step.
- A property test that asserts
baseL == sum(liquidityNet[t] for all initialized t ≤ currentTick)at the end of every swap. - A guard inside the swap loop that re-checks this assertion in debug builds and reverts in production if violated.
Kyber had none of these. The fix added the guard. You should add it before the fix.
8.4 Audited ≠ secure — even for top firms
The KyberSwap Elastic codebase was audited by:
- ChainSecurity — initial pre-launch (2021)
- ChainSecurity — re-audit (May 2023) following the white-hat disclosure of a different but related double-add bug
- Sherlock audit contest — July–September 2023, 207 watson participants
That is more security review than 90% of DeFi protocols ever receive. The bug still shipped.
The lesson is not “audits don’t work.” The lesson is:
Audits find what auditors are looking for. They are very good at finding access-control bugs, reentrancy, oracle staleness, and arithmetic underflow. They are bad at finding multi-step state-machine invariant violations that only manifest under adversarially-precomputed inputs. For math-heavy protocols, fuzzing the invariants is not a substitute for audit; it is a complement that audits cannot replace.
Sherlock contests, in particular, optimize for width of review (many watsons, many findings) rather than depth (one watson, deep fuzz harness). For a bug like KyberSwap’s, depth wins.
8.5 The “audit firm clean” doesn’t transfer to forks
Uniswap V3’s tick math has been battle-tested for billions of dollars and millions of swaps without a math bug in this class. KyberSwap inherited the design but reimplemented the code, with a feature addition (reinvestment liquidity) that the original auditor never reviewed. The audit history of the original does not transfer.
When auditing a fork:
- Diff the fork against the source.
- For every diff line, write at least one property test.
- For every new feature, write at least three property tests.
- Replay the original’s invariant suite (if it exists) against the fork.
8.6 The auditor’s checklist for CLMM forks (post-Kyber)
Use this on any concentrated-liquidity audit:
- Identify every rounding decision in
SwapMath/TickMath. For each, prove the direction is correct for the invariant it serves. - Fuzz
computeSwapStepfor invariantnextSqrtP ≤ sqrtPTarget(and the mirror for swap-down) with at least 1M iterations. - Fuzz the full swap loop for invariant
baseLconsistency at every tick crossing. - Fuzz round-trip property: forward swap + reverse swap is never profitable.
- Verify guard rails: every “did not cross” branch is followed by a
require(nextSqrtP != sqrtPTarget)or equivalent. - Verify that any added feature (fee compounding, reinvestment, custom hooks) does not introduce new rounding decisions without explicit justification.
- Re-derive
reachAmountsymbolically, including any reinvestment terms. Confirm thatspecifiedAmount < reachAmountstrictly implies the non-crossing branch is safe.
9. What You Would Have Caught
If KyberSwap Elastic landed in your inbox for audit in October 2021 — or for re-audit in May 2023 — here is what a 2025-trained auditor should reflexively do.
9.1 The 60-second triage
You open SwapMath.sol. You see computeSwapStep. You see the comment:
// deltaL should be rounded UP so that nextSqrtP is rounded DOWNThis is the load-bearing comment of the entire AMM. Your first reflex must be: does the code on the next line match this comment?
deltaL = FullMath.mulDivFloor(...) // ROUNDS DOWN, not UP — comment liesThis is a 60-second finding if you read the comment with skepticism and check the function name (mulDivFloor ≠ mulDivCeiling). The hardest part is knowing to look. The easy part is seeing the discrepancy.
9.2 The 5-minute deep-dive
Even if you missed the comment-vs-code discrepancy on first read, an invariant-aware reviewer asks:
“What is the postcondition of
computeSwapStepin the ‘did not cross’ branch? Where is it asserted?”
The expected postcondition is nextSqrtP < sqrtPTarget (for swap-up). Searching the codebase for any require or assert enforcing this: nothing. The contract trusts the function’s return value implicitly. This alone should be a “medium severity, harden invariants” finding — even before you’ve identified the rounding bug.
9.3 The 60-minute Foundry harness
Within an hour, an auditor with a Foundry mindset should be able to write the invariant test from §6.5:
function testFuzz_computeSwapStep_invariant(
uint160 sqrtP, uint160 sqrtPTarget, uint128 L, uint256 amount
) public {
// ... bound inputs ...
(uint160 nxt, , ) = SwapMath.computeSwapStep(/*real signature*/);
assertLe(nxt, sqrtPTarget, "INVARIANT");
}Run with forge test --fuzz-runs 100000. Foundry finds a counter-example. Submit as CRITICAL. Estimated total review effort: two hours.
9.4 The 60-second verdict
“
SwapMath.computeSwapStepis the central invariant-bearing function of the pool. Its in-code comment specifiesdeltaLmust be rounded up so thatnextSqrtPis rounded down. The implementation usesmulDivFloor(rounded down) — directly contradicting the comment. In the ‘did not cross’ branch, this allowsnextSqrtPto exceedsqrtPTarget, and the caller does not validate the invariant. Combined with the lack of anassert(nextSqrtP < sqrtPTarget)in the swap loop epilogue, this allows a swap to leave the pool in a state wherecurrentTickhas been crossed butbaseLhas not been updated. A subsequent swap in the opposite direction will double-count the positions at the crossed tick, draining the pool. Severity: critical; full pool drain possible. Patch: changemulDivFloor→mulDivCeilingand addrequire(nextSqrtP < sqrtPTarget, 'invariant violated')at the end of the non-crossing branch. PoC: 30-line Foundry fuzz test, attached.”
That single paragraph, in May 2023, prevents $48M in losses, saves 2,367 LPs from being wiped, and Elastic does not shut down. The cost of producing it: two hours of an auditor who knows the right invariants to look for.
9.5 What this teaches about audit methodology
Three takeaways for the auditor’s playbook:
-
Comments are findings. When a comment specifies a postcondition or a direction of rounding, the very next thing to do is verify the code matches. Comments-as-findings is a real audit signal, not a curiosity. The DAO had
// be nice, and get his rewards(a trust-assumption finding). KyberSwap had// deltaL rounded UP so nextSqrtP rounded DOWN(a correctness finding). Read them. -
Math-heavy code requires invariant-first review. Do not start by reading line by line. Start by writing down the contract’s invariants (formally or in English) and then search for where they could be violated. KyberSwap’s bug fell out of one invariant:
nextSqrtP < sqrtPTargetin the non-crossing branch. -
Fuzz is not optional for AMM math. Foundry, Echidna, Halmos. Run them. They find this class of bug fast. Audit firms that don’t include fuzz harnesses in their CLMM deliverables are charging full price for half the methodology.
10. References
Primary post-mortems and protocol communications
- KyberSwap — “Post Mortem: KyberSwap Elastic Exploit November 2023” (official, Feb 7, 2024): https://blog.kyberswap.com/post-mortem-kyberswap-elastic-exploit/
- KyberSwap Docs — “Elastic Legacy” (deprecation notice): https://docs.kyberswap.com/reference/legacy/elastic-legacy
- KyberSwap Docs — Audits page: https://docs.kyberswap.com/reference/legacy/audits
Technical deep dives
- BlockSec — “Yet Another Tragedy of Precision Loss: An In-Depth Analysis of the KyberSwap Incident”: https://blocksec.com/blog/yet-another-tragedy-of-precision-loss-an-in-depth-analysis-of-the-kyber-swap-incident-1
- BlockSec — “#3: KyberSwap Incident: Masterful Exploitation of Rounding Errors with Exceedingly Subtle Calculations”: https://blocksec.com/blog/kyberswap-incident-masterful-exploitation-of-rounding-errors-with-exceedingly-subtle-calculations
- SlowMist — “A Deep Dive Into the KyberSwap Hack”: https://slowmist.medium.com/a-deep-dive-into-the-kyberswap-hack-3e13f3305d3a
- AntChain Open Labs — “KyberSwap Attack Analysis: Unveiling the Most Sophisticated Cyber Heist in History”: https://antchainopenlabs.github.io/2023/11/28/KyberSwap-Attack-Analysis-Unveiling-the-Most-Sophisticated-Cyber-Heist-in-History/
- ZAN (Medium) — “KyberSwap Attack Analysis”: https://medium.com/@zan.top/kyberswap-attack-analysis-unveiling-the-most-sophisticated-cyber-heist-in-history-5332d2a644b9
- QuillAudits — “Decoding KyberSwap’s $47M Exploit”: https://www.quillaudits.com/blog/hack-analysis/kyberswap-hack
- AuditOne — “KyberSwap Exploit: A Comprehensive Breakdown”: https://www.auditone.io/blog-posts/kyberswap-exploit-a-comprehensive-breakdown
- Halborn — “Explained: The KyberSwap Hack (November 2023)”: https://www.halborn.com/blog/post/explained-the-kyberswap-hack-november-2023
- CertiK — “KyberSwap Elastic” technical write-up: https://www.certik.com/resources/blog/kyberswap-elastic
The earlier (April 2023) white-hat disclosure of a related bug
- 100proof — “Saving $100M at risk in KyberSwap Elastic” (white-hat post-mortem of the prior double-add bug, disclosed April 17, 2023): https://100proof.org/kyberswap-post-mortem.html
- one-hundred-proof/kyberswap-exploit (GitHub PoC, the white-hat finding): https://github.com/one-hundred-proof/kyberswap-exploit
- ChainSecurity — KyberSwap Elastic audit page (post-April-2023 re-audit): https://www.chainsecurity.com/security-audit/kyberswap-elastic
News coverage of the attack and on-chain negotiation
- rekt.news — “KyberSwap — REKT”: https://rekt.news/kyberswap-rekt
- CoinDesk — “KyberSwap DEX Hacked for $48 Million, Attacker Teases Negotiations” (Nov 23, 2023): https://www.coindesk.com/tech/2023/11/23/kyberswap-dex-hacked-for-48-million-attacker-teases-negotiations
- CoinDesk — “KyberSwap Offers 10% Bounty to Attacker” (Nov 24, 2023): https://www.coindesk.com/business/2023/11/24/kyberswap-offers-10-bounty-to-attacker-who-made-off-with-50m
- Cointelegraph — “KyberSwap DEX hacker sends an on-chain message: Be nice, or else” (Nov 28, 2023): https://cointelegraph.com/news/kyberswap-dex-exploiter-bounty-negotiation-message-on-chain
- The Block — “KyberSwap hacker demands full control in bizarre on-chain message” (Nov 30, 2023): https://www.theblock.co/post/265429/kyber-hacker-control-message
- Decrypt — “KyberSwap Hacker Demands Company Control in Unhinged On-Chain Ransom Letter” (Nov 30, 2023): https://decrypt.co/208147/kyberswap-hacker-demands-company-control-unhinged-on-chain-ransom-letter
- CryptoSlate — “KyberSwap hacker opens door for negotiations after $45 million exploit”: https://cryptoslate.com/kyberswap-hacker-opens-door-for-negotiations-after-45-million-exploit/
Corporate aftermath
- crypto.news — “KyberSwap laid off 50% of workforce after $54m Elastic exploit” (Dec 2023): https://crypto.news/kyberswap-laid-off-50-of-workforce-after-54m-elastic-exploit/
- Cointelegraph — “KyberSwap hacker bridges $2.5M in stolen funds to Ethereum”: https://cointelegraph.com/news/kyberswap-hacker-bridges-stolen-funds
Source code
- kybernetwork/ks-elastic-sc (KyberSwap Elastic core contracts): https://github.com/KyberNetwork/ks-elastic-sc
- kybernetwork/ks-elastic-sc/blob/main/contracts/libraries/SwapMath.sol (the vulnerable file): https://github.com/KyberNetwork/ks-elastic-sc/blob/main/contracts/libraries/SwapMath.sol [verify URL — repository may have moved post-deprecation]
On-chain forensics — key addresses and transactions
- Attacker EOA:
0x50275E0B7261559cE1644014d4b78D4AA63Be836 - Attack contract:
0xaF2Acf3D4ab78e4c702256D214a3189A874CDC13 - Sample attack tx (Ethereum, frxETH/WETH):
0x485e08dc2b6a4b3aeadcb89c3d18a37666dc7d9424961a2091d6b3696792f0f3 - KyberSwap Elastic vulnerable contract (Ethereum sample):
0xfd7b111aa83b9b6f547e617c7601efd997f64703
Sherlock audit contest (pre-attack)
- sherlock-audit/2023-07-kyber-swap: https://github.com/sherlock-audit/2023-07-kyber-swap
Last updated: 2026-05-16 See also: Tuan-08-DeFi-Security-AMM-Lending-Vault · Tuan-09-Oracle-MEV-Economic-Attack · Case-The-DAO-Reentrancy-2016 · Case-Euler-Finance-2023 · Case-Wormhole-2022 · Case-Nomad-Bridge-2022 · Case-Parity-Multisig-2017 · audit-checklist-master · Roadmap · References