Skip to main content

Command Palette

Search for a command to run...

BlueMove Was Not an Overflow Bug — How a Cross-Version Reserve Desync Drained 714,000 SUI

Updated
12 min readView as Markdown
BlueMove Was Not an Overflow Bug — How a Cross-Version Reserve Desync Drained 714,000 SUI
M
Smart contract security researcher.

On July 11, 2026, at 22:13 UTC, an attacker began draining liquidity pools on BlueMove DEX, a Sui-based AMM. Within 23 minutes, approximately 714,000 SUI — roughly $528,000 at the time — had crossed a Wormhole bridge as USDC. The contracts were immutable. The pools were empty. There was no way to patch or freeze.

BlueMove called it "a long-standing arithmetic overflow bug in the legacy AMM contract." Tyler Simpson of Quantum Void Labs called it a backdoor — a function shipped in a May 31 upgrade that set the stage for the drain.

Both accounts identify real pieces of the story. Neither captures the mechanism that made the drain possible.

I decompiled every module of both package versions, tested Move's integer behavior directly, and traced the attack transactions command by command. To the best of my knowledge, this is the first public analysis that identifies the actual exploit path: a reserve desynchronization between two callable versions of the same package. Both versions write reserve_x after every operation, but they write different values — V1 writes pool.token_x.value(), V-latest writes escrow.token_x.value(). When a V1 swap runs on a pool whose main liquidity lives in the escrow, reserve_x drops to the small pool.token_x balance, while the escrow retains the large one. The next V-latest mint divides by this deflated reserve_x, inflating LP tokens by the ratio between the two balances.


Why the overflow claim does not hold

Move's integer arithmetic does not wrap on overflow. Every overflow — addition, multiplication, or out-of-range downcast — aborts the transaction at runtime. This is not a compiler flag; it is how the Move VM executes arithmetic.

I tested this directly with Sui CLI v1.74.1:

#[test]
#[expected_failure]
fun test_u128_mul_overflow() {
    let a: u128 = 340282366920938463463374607431768211455; // u128::MAX
    let _result = a * 2; // aborts — does not wrap
}

#[test]
#[expected_failure]
fun test_u128_add_overflow() {
    let a: u128 = 340282366920938463463374607431768211455;
    let _result = a + 1; // aborts
}

#[test]
#[expected_failure]
fun test_u64_downcast() {
    let big: u128 = 18446744073709551616; // u64::MAX + 1
    let _small = (big as u64); // aborts — does not truncate
}

All three pass with expected_failure. Each operation aborts at runtime. An arithmetic overflow in Move is a denial-of-service, never a fund extraction vector.


The decompiled code

BlueMove never published source code. I decompiled all modules using Revela (v1.0.0) for bytecode v6, and sui move disassemble for bytecode v7 (unsupported by Revela). Critical paths were cross-verified between the two tools.

Three packages

On Sui, upgrading a package does not replace the original. Both versions remain callable on the same shared objects. BlueMove's Pool objects are shared, so any version can read and mutate them.

Label Package ID Bytecode
V1 0xb24b6789...145454d9 v6 — Original (2023). Decompiled with Revela.
V-mid 0x08cd3348...3244498f v6 — Intermediate. Introduced EscownCoins (the typo is in the deployed bytecode).
V-latest 0x35f3190a...0f656d7 v7 — May 31 upgrade. EscrowCoinsV2, add_liquidity_returns. This is the package the attacker called.

Two token stores, one reserve_x field

V1 stores tokens in pool.token_x / pool.token_y. V-latest stores tokens in EscrowCoinsV2, a dynamic object field on the Pool. Both versions share the Pool struct, including a single reserve_x: u64 field. Both versions overwrite reserve_x after every operation — but with values from different balances.


The reserve write: code proof

This is the linchpin. Each version's swap function ends with update(x, y, pool), which sets pool.reserve_x = x. The question is: what is x?

V1 swap → reserve_x = pool.token_x.value()

From V1's decompiled code (Revela, swap.move):

// V1 swap (lines 96, 122):
let (v2, v3) = token_balances<T0, T1>(arg2);
//  v2 = balance::value(&pool.token_x)    ← actual pool balance
//  v3 = balance::value(&pool.token_y)
...
update<T0, T1>(v2, v3, arg2);
//  pool.reserve_x = v2 = pool.token_x.value()

// V1 update (lines 80–83):
fun update<T0, T1>(arg0: u64, arg1: u64, arg2: &mut Pool<T0, T1>) {
    arg2.reserve_x = arg0;
    arg2.reserve_y = arg1;
}

This invariant holds immediately after every successful V1 swap because update() is always called with the current pool balances.

After every V1 swap: reserve_x == pool.token_x.value(). Always.

V-latest swap → reserve_x = escrow.token_x.value()

From V-latest's disassembly (escrow-exists branch, sui move disassemble):

// V-latest swap, escrow branch (lines 105–112, 269–272):
L105: CopyLoc[38](loc32: &mut EscrowCoinsV2)
L106: ImmBorrowFieldGeneric[11](EscrowCoinsV2.token_x)
L107: Call balance::value → loc18          ← escrow balance
L108: MoveLoc[38]
L109: ImmBorrowFieldGeneric[12](EscrowCoinsV2.token_y)
L110: Call balance::value → loc24          ← escrow balance
...
L269: MoveLoc[24](loc18)                   ← escrow.token_x.value()
L270: MoveLoc[30](loc24)                   ← escrow.token_y.value()
L271: MoveLoc[2](Pool)
L272: Call update                          ← reserve_x = escrow balance

After every V-latest swap (escrow branch): reserve_x == escrow.token_x.value().

This invariant holds only for the escrow branch. Therefore, whichever version executes last determines the meaning of reserve_x.

The consequence

reserve_x is a single field written by two functions that compute it from different balances. Whichever version runs last determines the value. If V-latest ran last, reserve_x tracks the escrow. If V1 ran last, reserve_x tracks pool.token_x. The two balances are independent — one can be orders of magnitude larger than the other.


The attack

Pool lifecycle

MovePump launchpad pools follow this lifecycle:

  1. Created through V-latest (create_and_freeze_poolunfreeze_and_add_liquidityadd_liquidity_direct). Initial liquidity goes into EscrowCoinsV2. pool.token_x stays at zero. reserve_x = escrow balance. LP tokens are minted to the pool creator.

  2. Traded through V-latest. Users swap via V-latest router. Tokens move in and out of the escrow. reserve_x tracks escrow balance. pool.token_x remains empty. Over weeks, the escrow accumulates a large SUI balance from trading.

  3. Traded through V1. External aggregators (e.g., OKX DEX Router, whose Move.toml references V1 package 0xb24b67...) route swaps through V1. V1 deposits input tokens into pool.token_x and sets reserve_x = pool.token_x.value() — a much smaller number than the escrow balance. This is the desync.

Step by step

Before attack:

escrow.token_x  = 350,000 SUI     (accumulated from V-latest trading)
pool.token_x    = 7,022 SUI       (accumulated from V1 aggregator swaps)
reserve_x       = 7,022            (set by last V1 swap = pool.token_x)
lp_supply       = 1,000            (from initial mint)

reserve_x and pool.token_x are equal (V1's update synchronizes them). But escrow.token_x is 50× larger — V1 doesn't see it, and the last operation was a V1 swap.

The numerical values below are representative rather than extracted from a single pool. They are chosen to satisfy the observed invariants (reserve_x == pool.token_x after a V1 swap) and to illustrate the ratio between the escrow balance and the pool balance that drives the LP inflation.

Step 1 — V-latest add_liquidity_returns(0.057 SUI, meme tokens):

The B20 branch runs (escrow exists). The deposit joins the escrow:

escrow.token_x += 0.057 → escrow_total = 350,000.057

V-latest's mint runs with lp_supply > 0 (the standard proportional branch — not the first-deposit branch):

LP_minted = (escrow_total − reserve_x) × old_supply ÷ reserve_x
          = (350,000.057 − 7,022) × 1,000 ÷ 7,022
          = 342,978.057 × 1,000 ÷ 7,022
          = 48,843

The attacker mints 48,843 LP — 48× the existing supply — because mint divides by reserve_x (7,022), not by the escrow balance (350,000) that the tokens actually sit in. No overflow. No underflow. The standard formula, fed a stale denominator.

The minted LP tokens are returned to the caller (this is what add_liquidity_returns does differently from add_liquidity), so they flow directly to the next command.

Step 2 — V1 remove_liquidity(48,843 LP):

V1's burn reads balance::value(&pool.token_x) — the actual SUI in the pool's direct balance, not the escrow:

total_supply  = 1,000 + 48,843 = 49,843
attacker_share = 48,843 ÷ 49,843 = 97.99%

withdrawal = pool.token_x.value() × attacker_lp ÷ total_supply
           = 7,022 × 48,843 ÷ 49,843
           = 6,881 SUI

V1 splits 6,881 SUI from pool.token_x and transfers it to the attacker.

Result: The attacker deposited 0.057 SUI (into the escrow) and withdrew 6,881 SUI (from pool.token_x). The stolen funds are the SUI that accumulated in pool.token_x from V1 aggregator swaps — real user trades routed through the old package.

The escrow's 350,000 SUI is not directly stolen in this step. It serves as the inflated numerator in the mint formula, making the attacker's LP share large enough to claim nearly all of pool.token_x.

The calculation above is illustrative. The attack transaction follows the same sequence of calls, while the exact balances differ from pool to pool.

This pattern repeated per pool. At ~1,966 SUI average across 363 pools, the total reaches approximately 714,000 SUI.


The attack transaction

Transaction 8pMKBovvrHzyRiaGjUPPFuFX6a92KkqSRitZ36zbD1qz — 49 programmable commands, 64 inputs. From the raw JSON, the command sequence repeats per pool:

SplitCoins  (prepare 0.057 SUI)
SplitCoins  (prepare meme token amount)
MoveCall    0x35f3190a... / router / add_liquidity_returns     ← V-latest
MoveCall    0xb24b6789... / router / remove_liquidity          ← V1

V-latest for deposit. V1 for withdrawal. Same Pool. Same transaction.

Pools drained in this single transaction include aaaMEME, SPANK, BSNAKE, JELLY, DGS, PCON, RUSHI, and others. On-chain balance changes for the aaaMEME pool: Add Liquidity +0.057 SUI, Remove Liquidity −7,022.21 SUI.

Attacker: 0xb29e7919...097335 (Blockaid: MALICIOUS)

Bridge wallet: 0xa74ee820...75065e

Exit: SUI → USDC across 11 DEXes → Wormhole deposit_for_burn → ~$528,000 USDC off Sui in 23 minutes.

Timeline

Date Event Transaction
May 31, 2026 V-latest upgrade 9wTz16...
June 3, 2026 UpgradeCap burned EugVQB...
July 11, 2026 Pools drained 8pMKBo... and others

Verdict

Claim Assessment Evidence
BlueMove: "arithmetic overflow" Does not hold. Move aborts on overflow. sui move test — three cases, all abort
BlueMove: "bug since 2023" Misleading. V1 alone is internally consistent. The vulnerability requires V-latest's escrow to create the reserve desync. V1 decompiled — no escrow, no split state
BlueMove: "UpgradeCap burned" Correct. Suiscan: Immutable
Simpson: "add_liquidity_returns is key" Correct. Returns LP to caller for same-TX burn. Attack TX command list
Simpson: "double-mint LP inflation" Imprecise. The LP formula is standard. The inflation comes from a desynced reserve_x denominator, not from minting twice. V1 vs V-latest reserve update paths
Simpson: "backdoor" Cannot be resolved from code.

The vulnerability class

The root cause is a shared mutable field written by incompatible update functions. Both V1 and V-latest write pool.reserve_x after every swap, but from different sources:

  • V1: reserve_x ← pool.token_x.value()

  • V-latest: reserve_x ← escrow.token_x.value()

When the two balances diverge (because tokens accumulate in different stores), whichever version wrote reserve_x last determines the denominator for LP minting. If the small balance wrote last, LP is inflated. If the large balance wrote last, LP is correctly priced.

This generalizes to any package upgrade that satisfies three conditions:

  1. Storage relocation: Version B stores assets in a new location (dynamic object field, separate struct, etc.)

  2. Shared accounting field: Both versions overwrite the same field (reserve_x) with values from their respective stores

  3. Old version remains callable: Version A's functions are not disabled after Version B deploys

The pattern is analogous to a schema migration without backward compatibility — except the old schema is not just readable but writable, and both schemas race on the same live accounting field.


What this analysis did not cover

  • Intent. The 40-day gap between the upgrade and the exploit is consistent with both a planted vulnerability and independent discovery. The code shows the mechanism; it does not show motive.

  • Complete transaction accounting. The four "pure sweep" transfers documented by Simpson total ~256,000 SUI; additional drain transactions account for the remaining ~458,000 SUI. Simpson counted 363 affected pools; BlueMove's statement said 389.

  • Whether V1 swaps preceding the attack were organic or attacker-triggered. The desync requires at least one V1 swap to set reserve_x = pool.token_x.value(). External aggregators referencing V1 would produce this naturally, but the attacker could also have triggered V1 swaps as setup within the same programmable transaction.

  • Pre-attack pool state. The numerical example uses representative values (escrow = 350,000, pool.token_x = 7,022, supply = 1,000) that are consistent with the on-chain balance changes (+0.057 / −7,022) and with the V1 update rule (reserve_x == pool.token_x). Exact pre-attack values for individual pools were not extracted.

  • The migrate_escrow_coins function in V-latest, which moves balances between the V-mid and V-latest escrow structs.


Methodology

  • Decompilation: Revela v1.0.0 on V1 and V-mid (bytecode v6). sui move disassemble on V-latest (bytecode v7).

  • Cross-verification: mint, burn, swap, add_liquidity_direct verified between decompiler output and raw disassembly. Register assignments for reserve_x writes traced instruction-by-instruction: V1 swap L96/L122, V-latest swap L105–112/L269–272, V-latest swap else-branch L319–326/L483–486.

  • Overflow testing: sui move test, Sui CLI v1.74.1. Three cases, all abort.

  • On-chain tracing: Suiscan Raw JSON for attack transaction. Package addresses extracted from MoveCall commands. Two distinct packages confirmed: 0x35f319... (add_liquidity_returns) and 0xb24b67... (remove_liquidity).

  • Mathematical verification: LP minting and burn withdrawal computed with representative values satisfying the V1 update invariant (reserve_x == pool.token_x). Result (attacker share 97.99%, withdrawal 6,881 SUI) is consistent with the on-chain balance changes for the aaaMEME pool.

The EscownCoins spelling (V-mid) and EscrowCoinsV2 (V-latest) are exactly as they appear in the deployed bytecode.


I build move-test-gen, an open-source coverage checker and security linter for Sui Move. This analysis started with the linter flagging BlueMove's overflow_add function — which turned out to be irrelevant. The finding came from decompiling the bytecode and tracing the reserve update paths across package versions.

Upgrading a storage layout without retiring the previous entry points creates two valid writers for a single accounting field. Once those writers disagree about what the field represents, every formula downstream — LP pricing, withdrawal calculation, K-invariant verification — computes a correct answer to the wrong question.