Cetus $223M Case Research: The CLMM Shift Check That Minted Liquidity for One Unit
The Cetus exploit is a useful antidote to a lazy phrase I hear too often: "Move is safe." Move gives you strong object and resource semantics, but it does not prove your financial math is correct. Cetus lost roughly $223M because a helper that was supposed to prevent a fixed-point left-shift overflow checked the wrong boundary. The compiler did not save the protocol from a bad invariant.
This reconstruction is based on BlockSec's 2026 retrospective, Cyfrin's root-cause analysis, Verichains' decompiled Move breakdown, and Cetus/The Block reporting. I am treating the code snippets as representative of the published decompiled paths, not as a full source reproduction of every deployed module.
The takeaway: a CLMM position is only as honest as the delta math that prices it. Cetus credited pool-scale liquidity while charging a near-zero token amount, then let the attacker redeem that credited liquidity for real reserves.
Target architecture: concentrated liquidity and fixed-point deltas
Cetus is a concentrated-liquidity market maker on Sui. Like a Uniswap v3-style design, a position has:
- a lower and upper tick,
- a current square-root price,
- a liquidity amount
L, - token delta formulas that convert between
Land the actual token amounts owed.
For token A, the shape is:
deltaA = ceil((sqrtP_high - sqrtP_low) * L * 2^64 / (sqrtP_high * sqrtP_low))
That * 2^64 scaling step is where the bug lived. The intermediate product can exceed 192 bits before the left shift, so the helper must reject any value that would overflow 256 bits after << 64.
Root cause: the wrong overflow boundary
The vulnerable helper was checked_shlw(value: u256), used before shifting a 256-bit intermediate left by 64 bits. Published analyses agree the check compared against a huge mask equivalent to 0xffffffffffffffff << 192, while the actual safe boundary for value << 64 is 1 << 192.
Representative shape:
public fun checked_shlw(value: u256): (u256, bool) {
// Wrong: this rejects only values near the very top of u256.
// Safe shift-left-by-64 requires value < 2^192.
if (value > (0xffffffffffffffff << 192)) {
(0, true)
} else {
(value << 64, false)
}
}
The correct check is conceptually:
public fun checked_shlw(value: u256): (u256, bool) {
if (value >= (1 << 192)) {
(0, true)
} else {
(value << 64, false)
}
}
That looks like a small constant mistake. Economically it is catastrophic: values in [2^192, 0xffffffffffffffff << 192) should abort, but instead flow into the shift and lose their most significant bits. The numerator in the token-delta formula collapses to a tiny residue.
Vulnerable path: get_delta_a
The add-liquidity path asks: "for this much liquidity, how much token A must the user deposit?" The key function is published in analyses as get_delta_a.
public fun get_delta_a(
p0: u128,
p1: u128,
liquidity: u128,
round_up: bool
): u64 {
let diff = if (p0 > p1) { p0 - p1 } else { p1 - p0 };
if (diff == 0 || liquidity == 0) return 0;
let (prod, _) = full_mul(liquidity, diff);
let (shifted, overflow) = checked_shlw(prod);
assert!(!overflow, E_OVERFLOW);
div_round(shifted, full_mul(p0, p1), round_up) as u64
}
Under correct arithmetic, a pool-scale liquidity value with a narrow tick range should produce a very large required deposit. Under the bug, the shifted numerator wraps/truncates and the output can become 1 unit of token A.
That is the entire exploit primitive:
Protocol records: huge liquidity position
Attacker pays: near-zero token A
Later withdraws: real reserves backing that liquidity
Attack chain
The exploit was repeated across multiple pools. The flow is consistent:
- Acquire temporary balances. The attacker uses flash-swap style liquidity to get the assets needed to move the pool and enter the target path.
- Move the current price into a useful range. The attacker shifts the pool price so a narrow CLMM position can be opened in a range that hits the vulnerable
get_delta_abranch. - Open a narrow position. The attacker chooses lower/upper ticks such that the intermediate product crosses
2^192but not the incorrect mask. - Add enormous liquidity. The
checked_shlwbug undercharges the token A delta. The protocol accepts a minimal deposit while crediting large liquidity. - Remove liquidity. The attacker burns the credited liquidity and receives real token A/token B reserves.
- Repay temporary liquidity and repeat. The same primitive is applied across pools until liquidity is drained.
The representative attack shape looks like:
// 1. Move price and obtain temporary balance.
let (flash_a, flash_b, receipt) = pool.flash_swap_internal(...);
// 2. Open a position around the manipulated current tick.
let position = pool.open_position(&mut pool, lower_tick, upper_tick);
// 3. Add liquidity. get_delta_a() returns a tiny required amount because
// checked_shlw() failed to reject the unsafe intermediate.
pool.add_liquidity(
&mut pool,
position,
pool.total_liquidity,
max_a = 1,
max_b = flash_b
);
// 4. Remove credited liquidity for real reserves.
let (out_a, out_b) = pool.remove_liquidity(&mut pool, position, credited_liquidity);
pool.repay_flash_swap(receipt, out_a);
The important point is not that the attacker invented a new CLMM trick. It is that the protocol let "credited liquidity" and "paid token amount" diverge because the overflow guard lied.
Transaction trail
Public analyses cite multiple Sui transactions; Verichains walks one SCA/SUI transaction in detail:
- Example Cetus attack tx (Verichains):
https://suivision.xyz/txblock/ETCaBBiffASZ3oXBBcoM6VYd3NcTb5T1Sqo4xLECKZws?tab=Overview - Example transaction cited by BlockSec:
http://suiscan.xyz/mainnet/tx/DVMG3B2kocLEnVMDuQzTYRgjwuuFSfciawPvXXheB3x - Decompiled vulnerable module reference:
https://revela.verichains.io/sui/0x714a63a0dba6da4f017b42d5d0fb78867f18bcde904868e51d951a5a6f5b7f57?rpc=mainnet&module=math_u256
What I would change
- Delete custom overflow checks unless they are heavily property-tested. The correct invariant here is simple:
value < 2^192before<< 64. That should have a unit test at the boundary and fuzz tests across the boundary. - Property-test add/remove liquidity symmetry. For every tick range and liquidity value,
add_liquiditymust never credit more redeemable value than the deposited deltas can justify. - Add economic invariant tests, not just arithmetic tests. A CLMM test should ask: "Can I add liquidity for 1 unit and remove more than 1 unit of value?"
- Treat third-party math libraries as protocol-critical. Library reuse does not transfer responsibility. If the library feeds mint/redeem math, it is the protocol's risk surface.
- Monitor impossible liquidity deposits. A position that adds pool-scale liquidity for dust should trip a circuit breaker even if the arithmetic path returns success.
Confidence notes
- Firm: the root cause is the incorrect
checked_shlwthreshold before a<< 64fixed-point shift. - Firm: the exploit credited excessive CLMM liquidity while undercharging required token input, then removed liquidity for real reserves.
- Firm: the incident date and loss estimate (~$223M) are consistent across BlockSec, Cyfrin, The Block, and Cetus-linked reporting.
- Representative: the Move snippets are reconstructed from published decompiled snippets and analysis, not copied from a full audited source release.
Sources
- BlockSec — Cetus Incident: One Unchecked Shift Drains $223M:
https://blocksec.com/blog/blog-4cetus-incident-one-unchecked-shift-drains-223m-in-the-largest-defi-hack-of-2025 - Cyfrin — Inside the $223M Cetus Exploit:
https://www.cyfrin.io/blog/inside-the-223m-cetus-exploit-root-cause-and-impact-analysis - Verichains — Cetus Protocol Exploit Technical Breakdown:
https://blog.verichains.io/p/cetus-protocol-hacked-analysis - The Block — Cetus says open-source library flaw led to $223M exploit:
https://www.theblock.co/post/355795/sui-dex-cetus-open-source-library-flaw-smart-contract-223-million-usd-exploit
