Cetus lost $223M to a shift overflow. Aftermath lost $1.14M to a negative fee. Bucket Protocol ships a decimal scaling bug today. All three passed audits. All three have the same root cause.
I've spent the last month reading exploit post-mortems, scanning production Sui contracts, and building lint rules to catch what I kept seeing. After the third time the same shape showed up, I stopped thinking of these as separate incidents.
They're the same bug wearing different clothes.
Exploit 1: Cetus — $223M (May 2025)
The largest Move exploit to date. A single function in a shared math library — checked_shlw in integer-mate — was supposed to prevent a 256-bit left shift from overflowing. It didn't.
The function checked whether the input was small enough to shift safely:
// BEFORE fix
assert!(n <= MAX_U256 >> 64, ERR_OVERFLOW);
n << 64
For inputs near 2^192, this check passes when it shouldn't. The shift wraps, producing a tiny number from a massive input. Cetus used this function in its liquidity delta calculation. The attacker opened a position with a crafted tick range, deposited approximately 1 token, and received a liquidity credit worth $223M. Then withdrew it.
Attacker: 0xe28b50cef1d633ea43d3296a3f6b67ff0312a5f1a99f0af753c85b8b5de8ff06
Fix: assert!(n < (1u256 << 192), ERR_OVERFLOW) — one comparison.
What the auditors saw: A math library everyone imports. checked_shlw — the name says "checked." It has an assert. It looks complete. You'd need to sit down with a pen and work out the 256-bit boundary arithmetic to realize the comparison is wrong. Under audit time pressure, that doesn't happen.
Exploit 2: Aftermath Finance — $1.14M (April 2026)
Different protocol, different operation, same disease.
Aftermath's perpetual futures exchange lets third-party integrators set their own fee rates. The fee type is signed fixed-point. The validation:
assert!(integrator_taker_fee <= max_taker_fee, E_INVALID);
Upper bound only. The attacker set max_taker_fee = 0, then passed a negative fee. Signed comparison: negative ≤ 0 is true. Check passes.
Downstream:
collateral_delta = filled_value - (taker_fee + negative_integrator_fee)
Subtracting a negative number adds. Phantom USDC appears in the account. The attacker deposited 100 USDC, withdrew 100,000+. Eleven times in 36 minutes.
Fix: assert!(integrator_taker_fee >= 0, E_INVALID) — one assert.
OtterSec audited Aftermath in November 2025. The bug shipped in August 2025. It survived a full audit by a top-tier Move firm.
Exploit 3: Bucket Protocol — Unpatched (found July 2026)
This one hasn't blown up yet. I found it while scanning Sui CDP protocols with a lint tool I built.
Bucket's compute_collateral_value_to_buck function calls mul_factor — which safely promotes to u128 internally — and then immediately multiplies the result by pow(10, decimal_diff) as raw u64:
let collateral_raw_value = mul_factor(collateral_amount, price, denominator);
// mul_factor used u128 internally. This line undoes that protection:
collateral_raw_value * pow(10, constants::buck_decimal() - collateral_decimal)
If collateral_decimal > buck_decimal(9) and the value is large enough, this overflows. The function is called by is_in_recovery_mode, is_liquidatable, is_healthy_bottle, and handle_redeem. An overflow here blocks liquidation, redemption, and health checks — the protocol's core safety functions stop working.
Current deployments use SUI (9 decimals = BUCK decimals), so pow(10, 0) = 1 — no overflow today. But create_bucket lets admins add new collateral types with arbitrary decimals, and there's no guard. The code is safe by accident, not by design.
Fix: u128 intermediate cast — PR submitted.
OtterSec audited Bucket in 2023 and found a related issue (OS-BKT-ADV-00, "Improper Conversion" — critical, resolved). The fix for that issue introduced compute_collateral_value_to_buck. The overflow in the fix is a new bug.
Four audit firms reviewed Bucket (OtterSec, MoveBit, Hashlock, Quantstamp). None caught this.
The Pattern
Three protocols. Three different operations (shift, subtraction, multiplication). Three different triggers (boundary input, negative value, decimal scaling). But strip away the surface and it's the same thing every time:
A math function that returns the wrong answer for inputs the developer didn't consider, protected by a check that looks complete but isn't.
| Cetus | Aftermath | Bucket | |
|---|---|---|---|
| The math | shift left 64 | subtract fee | multiply by 10^n |
| The check | n <= MAX >> 64 |
fee <= max |
(none) |
| What's missing | upper-bit mask | fee >= 0 |
u128 promotion |
| Why it passes review | "checked" in the name | assert present |
mul_factor already "safe" |
| The fix | 1 comparison | 1 assert | 1 cast |
| Audit coverage | library dependency | in-scope, missed | in-scope across 4 firms |
The pattern isn't "developers write bad math." The math is usually correct for the inputs the developer imagined. The pattern is: the boundary between correct and catastrophic is one comparison, and it's invisible to a reviewer scanning for logic bugs.
Why Audits Keep Missing This
I don't think this is an auditor quality problem. I think it's a structural mismatch between how audits work and how arithmetic bugs hide.
An auditor reads code and asks: "Does this logic do what it claims?" For business logic — access control, state transitions, economic flows — that question works. You can read the intent and check the implementation.
For arithmetic boundary bugs, the question doesn't work. checked_shlw does exactly what its name and comment say — it checks and shifts. The bug is in the arithmetic of the check itself, and you can't see it without computing the actual boundary. An auditor under time pressure sees a named function with an assert and moves on. That's rational behavior — the alternative is manually verifying every comparison in every math function, which no audit budget covers.
This is the gap static analysis fills. Not because tools are smarter than auditors — they're not. Because tools don't have time pressure. A lint rule that flags "u64 multiplication without u128 promotion" runs in milliseconds on every file. It catches the 5 sites out of 500 where the developer skipped the cast, and an auditor can spend their limited time on the hard problems instead.
What I Took Away
I've been building a lint tool for Sui Move (move-test-gen) and these three incidents shaped what it checks:
- MOV-002 exists because of Cetus — flag unchecked multiplications
- MOV-004 exists because of Bucket — flag unchecked downcasts
- The Aftermath analysis taught me that signed types need entry-point validation, not just conversion-boundary checks
But honestly, the bigger lesson isn't about any specific rule. It's that the math layer is where Move's safety guarantees end and where the money is. Move prevents reentrancy, enforces object ownership, and catches type errors at compile time. It doesn't prevent 2^192 << 64 from wrapping. Three protocols, three audits (seven total across them), $224M lost and counting.
The fix every time is one line. The cost of missing it is nine figures.
On-chain data verified via Sui RPC. Cetus attacker 0xe28b...ff06 confirmed at 2025-05-22 10:30:50 UTC. Aftermath attacker 0x1a65...d41e confirmed at 2026-04-29 08:55:50 UTC. Bucket PR references commit 0ad3cb5 (OtterSec audit scope).
References: Cyfrin (Cetus) · DarkNavy (Aftermath) · OtterSec Bucket audit
Top comments (0)