Cetus Protocol Hack: $230M Lost to an Overflow in Tick Math
Cetus lost $230M on Sui in May 2025 because one overflow check in their liquidity math used the wrong bitmask. Attackers minted billions in fake liquidity for a single token. Here's the exact line that broke, and how to catch it in your own AMM before someone else does.
The bug in one sentence
Cetus used a left-shift helper called `checked_shlw` that was supposed to revert when shifting a U256 left by 64 bits would overflow. The overflow check used the wrong mask. So a carefully chosen liquidity value passed the guard, overflowed silently, and the protocol believed the attacker had supplied roughly 10^18 times more liquidity than they actually did.
One wrong constant. $230 million.
What Cetus does
Cetus is the largest concentrated-liquidity AMM on Sui, modeled closely on Uniswap V3. Concentrated liquidity means LPs deposit into a price range bounded by two ticks. Internally the pool tracks abstract `liquidity` units and converts between liquidity and token amounts using square-root price math:
```
amount0 = liquidity * (sqrtP_b - sqrtP_a) / (sqrtP_a * sqrtP_b)
amount1 = liquidity * (sqrtP_b - sqrtP_a)
```
These formulas live in fixed-point land. Sui's Move libraries don't ship with a full Q64.64 math suite, so Cetus wrote their own. That's where the rot was.
The vulnerable code
When a user opens a position, the pool computes how many tokens are required for the requested liquidity. The math involves multiplying a U256 by a U64, then shifting the high half of the result. Pseudocode of the broken helper:
```solidity
// Vulnerable: equivalent of Cetus's checked_shlw
function checkedShlw(uint256 n) internal pure returns (uint256, bool) {
// Intent: return (n << 64, overflow?)
// Bug: the overflow mask is wrong.
uint256 mask = 0xffffffffffffffff << 192; // WRONG
if (n & mask != 0) {
return (0, true); // overflow
}
return (n << 64, false);
}
```
The mask checks only the top 64 bits. Anything set in bits 192..255 trips the guard. But shifting left by 64 also loses bits 192..255 — and *anything in bits 128..192 will overflow too*. The guard misses a 64-bit window of overflow.
An attacker who picks a `liquidity` value with bits set in the unchecked window gets a wrapped, dramatically smaller result. The pool then asks them to deposit a microscopic number of tokens for what it thinks is enormous liquidity.
The fix
One line. The correct mask covers every bit that would be lost or wrapped by a left-shift of 64:
```solidity
function checkedShlw(uint256 n) internal pure returns (uint256, bool) {
uint256 mask = type(uint256).max << 192; // still wrong if shift != 64
// Better: check directly against the shift amount.
if (n > (type(uint256).max >> 64)) {
return (0, true); // overflow
}
return (n << 64, false);
}
```
The defensive pattern: never hand-roll a bitmask when you can express the overflow condition as a comparison against `type(T).max >> shift`. It reads obviously, and a fuzzer can crush it in minutes.
The attack, step by step
1. Attacker opens a position with tick range that maximises the rounding error.
2. Requests a liquidity amount with bits set in the unchecked window of `checked_shlw`.
3. Pool computes required token0/token1. Both wrap. The attacker effectively deposits 1 unit of token.
4. The pool now records a position with real, withdrawable liquidity worth millions.
5. Attacker burns the position. Out come the tokens. Repeat across pools.
6. Bridge the loot off Sui as fast as the bridges allow.
On-chain forensics showed the attacker iterated this across the most liquid pools — SUI/USDC, BUIDL pairs, and several memecoin pools — pulling about $230M before Cetus paused the contracts. Roughly $162M was eventually frozen on Sui through validator coordination, which is its own can of worms about chain neutrality, but that's a separate post.
Why testing missed it
Cetus had unit tests. Of course they did. Unit tests pick representative inputs. The broken mask works correctly for almost every value you'd think to write in a test — small liquidity, normal liquidity, even "big" liquidity like 2^100. The bug only triggers for values where specific high bits are set, which no human picks by hand.
This is the textbook case for **property-based fuzzing**. The invariant is trivial to state:
```
for all n: checkedShlw(n) overflow == (n > (U256_MAX >> 64))
```
A fuzzer running for a few seconds finds the counterexample. Foundry's `forge test --fuzz-runs` would have caught this on EVM. Sui Move has weaker tooling, but Move Prover can encode exactly this invariant and discharge it symbolically. Cetus either didn't write the property or didn't run the prover on this helper.
If you ship custom fixed-point math, every helper deserves:
A stated invariant in a comment above the function.
- A property test that encodes that invariant.
- A symbolic check if your platform supports it (Halmos, Certora, Move Prover).
Our [free AI audit](https://www.cryptohawking.com/audit) flags hand-rolled bitmask overflow checks specifically because of incidents like this — a static pattern, easy to grep for, almost always worth a second look.
The broader class: AMM math bugs
Cetus joins a long list of AMM math disasters:
**Uniswap V3 on chains with cheaper gas**: multiple forks introduced precision bugs when porting `FullMath.mulDiv`.
- **Kyber Elastic (Nov 2023, ~$48M)**: tick-crossing math allowed an attacker to manipulate the active tick.
- **BurgerSwap, SushiSwap MISO, countless V2 forks**: not overflow specifically, but the same pattern — DeFi devs writing math they don't fully understand.
Concentrated liquidity is brutal because the math has more moving parts than constant-product. Square roots, Q-format fixed point, tick spacing, fee accumulation, signed deltas — each one a place to lose a bit.
Hardening checklist for AMM developers
1. **Use audited libraries**. On EVM, fork Uniswap V3's `FullMath` and `SqrtPriceMath` verbatim. Don't re-derive.
2. **State invariants explicitly**. Every math helper has one. Write it as a comment, then as a test.
3. **Fuzz with adversarial bounds**. Cover the full input domain, not the "reasonable" subset.
4. **Cross-check with a reference implementation**. Run the same inputs through Python `decimal` or Rust `num-bigint` and assert equality.
5. **Differential test against the original**. If you're porting Uniswap V3 to a new chain, the forks that survive are the ones that ran 10,000 random inputs through both.
6. **Get a human to read the math**. A [manual audit](https://www.cryptohawking.com/audit/manual) catches the kind of off-by-one nobody's fuzzer covers because nobody wrote the right invariant. Three business days, $5,000, paid in ETH/SOL/USDT.
Takeaway
The Cetus exploit is a one-character bug in a helper that probably took less than five minutes to write. It cost a quarter of a billion dollars. AMM math is unforgiving — every helper is a load-bearing wall, and there is no "non-critical path" in a function that's called on every swap and every mint.
If you've written your own fixed-point library, audit it tonight. If you've forked someone else's and changed even one constant, audit it tonight. The attackers fuzz harder than you do.
FAQ
What exactly was wrong with Cetus's checked_shlw function?
The function was supposed to return an overflow flag when shifting a U256 left by 64 bits would lose information. The bitmask it used only checked the top 64 bits of the input, but a left-shift by 64 actually invalidates the top 64 bits AND any bits set in positions 128–192 (since they'd wrap). That left a 64-bit window where overflowing inputs slipped through silently, and the shift produced a wrapped result instead of reverting.
Why didn't unit tests catch this?
Unit tests cover values a human picks. Nobody picks a 256-bit integer with specific high bits set unless they're trying to break things. The bug was invisible to representative testing but trivial to find with property-based fuzzing: a single invariant like `overflow == (n > MAX >> 64)` would have caught it in seconds. The lesson isn't that Cetus was negligent — it's that hand-rolled math without property testing is unsafe by default.
Could Move Prover have caught this on Sui?
Yes. The overflow condition is a simple algebraic property that Move Prover can discharge symbolically. The team would have needed to write the `ensures` clause explicitly — the prover doesn't guess invariants. This is the same story as Certora on EVM: powerful tools that only work if you tell them what to check. Specs are the bottleneck, not solvers.
Does this same class of bug exist on Ethereum AMMs?
Conceptually yes, though Solidity 0.8+ reverts on arithmetic overflow by default, which catches naive cases. The danger is inside `unchecked { }` blocks and in low-level assembly math like Uniswap V3's `FullMath.mulDiv`. Several V3 forks have shipped subtle precision bugs when porting to chains with different word sizes or when 'optimizing' the original math. If you have unchecked blocks in your AMM, every one is a candidate for the same review.
What's the minimum testing bar for custom AMM math?
Three things. One: a stated invariant in a comment above every helper. Two: a fuzz test that encodes the invariant and runs at least 100,000 inputs. Three: differential testing against a reference implementation in a different language — Python `decimal`, Rust `num-bigint`, anything. If your math agrees with the reference across a million random inputs, you've eliminated 95% of this bug class. The remaining 5% is what manual review is for.