# Second Stamp — Cyber Apocalypse 2026 | | | |---|---| | **Challenge** | Second Stamp | | **Platform** | HackTheBox — Cyber Apocalypse 2026 ("The Salt Crown") | | **Category** | Blockchain | | **Difficulty** | Hard | | **Target** | Sui localnet, Move 2024 edition, one instance per 600s window | | **Vulnerability class** | Package-upgrade version gating failure → vault share-price manipulation | | **Impact** | Complete drain of all four asset stores | | **Exploit** | One programmable transaction: `claim` + N × (poison AUM, deposit, withdraw) | | **Date** | July 2026 | > Companion document for `solve-second-stamp3.sh` — it explains what the vulnerability is, why > the exploit is shaped the way it is, and how to run and troubleshoot the script. This > document is self-contained. --- ## TL;DR The `sharehouse` package was published three times. Version 3 fixed a broken valuation function and started tracking a second liquidity pool. Version 1 was never retired, because the version gate uses `<=` instead of `==` against a counter that nothing ever increments. So v1 is still callable on the same shared objects. Its `refresh_aum` writes an understated `last_aum` — its bin selector is off by eight orders of magnitude and marks the LP position out-of-range, and it cannot see the second pool at all. Version 3 then trusts that number as the denominator when minting LP shares, while withdrawal pays out strictly pro-rata against the *real* reserves. Deposit through the poisoned share price, withdraw through the honest one, repeat. --- ## Quick start ```bash cp "/mnt/c/Users//Downloads/solve-second-stamp3.sh" ~/ cd ~ && sed -i 's/\r$//' solve-second-stamp3.sh && chmod +x solve-second-stamp3.sh export BASE=http://: ./solve-second-stamp3.sh --preview # print the PTB, send nothing ./solve-second-stamp3.sh --auto # try 2..6 rounds until solved ``` Prerequisites: `curl`, `jq`, `python3`, and the `sui` CLI on PATH. Get the CLI by resolving the real asset name from the releases API — `/latest/download/sui-ubuntu-x86_64.tgz` does not exist and `curl -L` will silently save the 404 page: ```bash TAG=$(curl -s "https://api.github.com/repos/MystenLabs/sui/releases?per_page=40" \ | jq -r '[.[] | select(.prerelease==false) | select(.tag_name|startswith("testnet-"))][0].tag_name') URL=$(curl -s "https://api.github.com/repos/MystenLabs/sui/releases/tags/$TAG" \ | jq -r '.assets[] | select(.name|test("ubuntu-x86_64\\.tgz$")) | .browser_download_url') curl -L -o sui.tgz "$URL" && file sui.tgz # must say: gzip compressed data mkdir -p ~/sui-bin && tar -xzf sui.tgz -C ~/sui-bin sudo install -m755 "$(find ~/sui-bin -type f -name sui -perm -u+x | head -1)" /usr/local/bin/sui ``` On WSL, keep the binary and the script under `~`, not `/mnt/c/...` — the exec bit does not stick on the Windows filesystem. --- ## Win condition `second_stamp::setup::is_solved` requires `claimed == true` and eight balances all below their residual limits, with `RESIDUAL_LIMIT_A = 2.5e16` and `RESIDUAL_LIMIT_B = 1e8`: ``` buffer_a/b, fee_a/b, old_counter_a/b, travel_counter_a/b ``` | Coin | Module | Decimals | Role | |---|---|---|---| | `PALE_WAX` | `claim_marks::pale_wax` | 18 | base asset ("A") | | `GOLD_FLECK` | `claim_marks::gold_fleck` | 6 | quote asset ("B") | | `CLAIM_MARK` | `claim_marks::claim_mark` | 9 | LP share token | Seeded state, confirmed live against the instance: | Store | Base (WAX) | Quote (GOLD) | |---|---|---| | `old_counter` reserves | 1e18 | 2.5e9 | | `travel_counter` reserves | **1.4e19** | 5.5e10 | | sharehouse buffer | 2e16 | 1e6 | | player wallet after `claim` | 1e15 | 1e6 | Both fee counters start and stay at zero, since `protocol_fee_bps` is 0. The buffer already starts below its limit. The binding constraint is `travel_counter`: 1.4e19 down to 2.5e16 means **99.82% of it has to be drained**. --- ## Findings ### 1. [Critical] Version gate admits every historical package forever `sharehouse::versioned`, identical in all three publishes: ```move const SUPPORTED_VERSION: u64 = 1; // 2 in v2, 3 in v3 public fun assert_supported(versioned: &Versioned) { assert!(versioned.version <= SUPPORTED_VERSION, EUnsupportedVersion); } ``` Two defects, and both are needed: 1. **Wrong comparison.** The canonical Sui pattern is `==`, so exactly one package version can touch a shared object. `<=` inverts the intent — it means "I accept this object as long as it is no *newer* than me", which never excludes an older package. 2. **The counter is never incremented.** `set_version` is `public(package)` and across all 43 `.move` files in the three packages, nothing calls it. There is no `migrate` entrypoint. `versioned::new` mints `version: 1` and `bootstrap` shares it as-is. Together: the shared `Versioned` sits at `1` permanently, so `1 <= 1`, `1 <= 2` and `1 <= 3` all hold. All three logic packages operate on the same shared `Sharehouse`, `GlobalConfig` and `Versioned` objects, indefinitely. Confirmed live — the script asserts `version == 1` before doing anything. This is the load-bearing bug. Nothing below is reachable without it. ### 2. [Critical] `quote_bin_from_price` compares values eight orders of magnitude apart `old_counter::pool`, reached only via `refresh_position_info_v1`, which only v1 and v2 call: ```move const QUOTE_RECIPROCAL_NUMERATOR: u128 = 100_000_000_000_000_000_000; // 1e20 const QUOTE_BIN_BOUNDARY_Q10: u128 = 10_100_000_000; // 1.01e10 public fun quote_bin_from_price(price_e6: u64): u32 { if (inverse_quote_q10(price_e6) >= QUOTE_BIN_BOUNDARY_Q10) 101 else 100 } ``` At the seeded oracle price of `2_500_000_000`: ``` price_to_quote_q10 = 2.5e9 / 1e8 = 25 inverse_quote_q10 = 1e20 / 25 = 4_000_000_000_000_000_000 4e18 >= 1.01e10 -> true -> bin 101 ``` The reciprocal is produced at roughly Q20 scale and compared against a Q10 boundary. Returning 100 would require `price_e6 > 9.9e17`. **This function returns 101 unconditionally.** The pool seeds `active_bin: 100`, `allowed_deviation_bins: 1`, and the position `lower_bin: 100, upper_bin: 100`. So bin 101: - **passes** `check_pool_price_deviation`, since `|101 - 100| = 1 <= 1` — by exactly one bin, the widest value that still validates; - **fails** the range test in `apply_accounting`, since `101 > upper_bin`, collapsing `accounted_a` from `principal + accrued_fee` (8.7e16) to `MAINTENANCE_MARGIN_A` (1e12) and `accounted_b` to `1`. An 87,000× write-down with no assertion failure anywhere. v3's `bounded_bin_from_price` compares against `REFERENCE_PRICE_E6 ± MAX_DEVIATION_E6` in the right units and returns 100. ### 3. [High] `base_value_in_quote` divides before multiplying ```move // v1 / v2 — truncates below 1e12 ((amount / BASE_TO_QUOTE_DECIMAL_FACTOR) as u128) * (price_e6 as u128) / QUOTE_PRICE_SCALE // v3 — correct mul_div_floor(amount as u128, price_e6 as u128, 1_000_000_000_000_000_000) ``` `BASE_TO_QUOTE_DECIMAL_FACTOR` is 1e12, the 18→6 decimal delta. v3 still defines the broken function but no longer calls it — dead code that made the version split easy to spot. ### 4. [High] Pre-upgrade accounting cannot see the dynamic-field position Sui upgrade compatibility forbids adding fields to an existing struct, so v3 attaches the second pool position as a dynamic object field: ```move const TRAVEL_COUNTER_POSITION_KEY: vector = b"TRAVEL_COUNTER_POSITION"; dynamic_object_field::add(&mut house.id, TRAVEL_COUNTER_POSITION_KEY, position); ``` v1 has no `travel_counter` dependency in its `Move.toml` at all. Its AUM sum is `old_counter + buffer`, full stop — it is structurally incapable of valuing a pool holding 1.4e19 / 5.5e10. ### 5. [Medium] Accounted liquidity is decoupled from withdrawable reserves `travel_counter::pool::new` seeds the full 1.4e19 / 5.5e10 of reserves but gives the position `liquidity: 1_000_000`, `sqrt_lower_q64: Q64`, `sqrt_upper_q64: 3 * Q64`. AUM values it at the hardcoded `sqrt_price = 2 * Q64`: ``` amount_a = mul_div(mul_div(1e6, 3Q64-2Q64, 3Q64), Q64, 2Q64) = 166_666 amount_b = mul_div(1e6, 2Q64-Q64, Q64) = 1_000_000 ``` Even v3 counts this pool as 166,666 base and 1,000,000 quote, while `remove_share` pays out pro-rata against `pool.reserve_a` / `reserve_b`. Valuation and entitlement are computed from two unrelated quantities — a solvency hole on its own, and the bulk of the loot here. --- ## Why the exploit works ### Two AUM figures for the same vault | | v1 `refresh_aum` | v3 `refresh_aum` | |---|---|---| | `old_counter` accounted base | 1e12 (collapsed) | 8.7e16 | | `old_counter` accounted quote | 1 | 2.175e8 | | `travel_counter` | **not visible** | 166,666 / 1e6 | | buffer | 2e16 / 1e6 | 2e16 / 1e6 | | valuation function | truncating | `mul_div` | | **`last_aum` written** | **~5.10e7** | **~4.87e8** | ### The asymmetry that pays out `withdraw` never reads AUM. Every leg is pro-rata against real balances: ```move let amount_a = mul_div_floor(pool.reserve_a.value(), numerator, denominator); ``` `deposit` prices your shares off `last_aum`: ```move let lp_amount = math::mul_div_floor(before_supply as u128, deposit_value_quote, denominator)... ``` Every LP token except the vault's own locked 6,000,000 is burned on withdrawal, so `before_supply` is 6,000,000 at the start of each round and cancels out entirely: ``` share = lp / (before_supply + lp) = (S·d/aum) / (S + S·d/aum) = d / (aum + d) ``` **Ownership fraction is `deposit_value / (deposit_value + poisoned_aum)`.** Nothing else matters — not the locked supply, not the pool sizes. Reaching the 99.82% needed to clear `travel_counter` means getting `deposit_value` to roughly 554× the poisoned AUM, which compounds quickly because each round's payout becomes the next round's deposit. Two effects push the round count up, which is why the script walks the count rather than assuming one: - Your deposit lands in `base_buffer`, which v1's AUM *does* count, so a large deposit raises the next round's poisoned AUM. - `collect_position_fees` / `collect_position_rewards` sweep the non-your-share remainder into the buffer. They pay once each and also nudge the buffer up. And one hard ceiling: `deposit_with_denominator` asserts `deposit_value_quote + denominator <= hard_cap`, with `hard_cap = 200_000_000_000`. That caps a single deposit at ~2e11 quote-equivalent and becomes binding on the later rounds. --- ## Exploit design ### Everything in one transaction `claim` returns `(Coin, Coin)` and `complete_withdraw` returns the same pair, so round *r+1* spends round *r*'s output **as PTB results**. Nothing is transferred to the wallet mid-chain: ``` claim ─┬─> c.0 (WAX) ──┐ └─> c.1 (GOLD) ─┤ ├─> round 1 ─> out1.0 / out1.1 ─┐ ├─> round 2 ─> out2.0 / out2.1 ─> … └─> … ─> transfer-objects to player ``` Each round is nine `--move-call`s: | # | Call | Package | Why | |---|---|---|---| | 1 | `accounting::refresh_aum` | **v1** | poison `last_aum` | | 2 | `accounting::deposit` | v3 | mint LP against the poisoned denominator | | 3 | `withdraw::new_withdraw_cert` | v3 | profile 3 — the only profile that reaches `travel_counter` | | 4 | `withdraw::process_old_counter` | v3 | pro-rata from `old_counter` reserves | | 5 | `withdraw::withdraw_travel_counter` | v3 | pro-rata from `travel_counter` reserves | | 6 | `withdraw::collect_position_fees` | v3 | two-pool variant | | 7 | `withdraw::collect_position_rewards` | v3 | two-pool variant | | 8 | `withdraw::process_buffer` | v3 | pro-rata from the buffer | | 9 | `withdraw::complete_withdraw` | v3 | consume the receipt, emit the coins | Three constraints the ordering encodes: - **Step 1 must be v1, not v3.** The entire exploit is the stale package writing a number the current package then trusts. - **Step 3 must be `new_withdraw_cert`, not `begin_withdraw`.** Only `withdrawal_profile == 3` unlocks `withdraw_travel_counter`, and `travel_counter` holds most of the value. - **Steps 6–7 must be the two-pool `collect_position_*` variants.** The single-pool `collect_fees` / `collect_rewards` set the same `fees_done` / `rewards_done` flags but skip the `travel_counter` side, and `EAlreadyProcessed` means you cannot call both. `process_buffer` asserts every other leg is done and `complete_withdraw` re-asserts it, so the sequence is not reorderable. ### Why one transaction rather than one per round Three reasons, in order of how much they forced the design: 1. **Coin discovery is not available.** `sui client objects --json` on 1.76 emits raw BCS (`data.Move.type_: "GasCoin"`, `contents: [bytes]`) with no usable type tag, so you cannot filter owned coins by type through the CLI. Chaining results sidesteps the problem instead of solving it. 2. **`WithdrawReceipt` has no `drop`.** It is a hot potato: created and consumed in the same transaction or the transaction does not build. One PTB satisfies this by construction. 3. **The window is 600 seconds.** One transaction per attempt leaves room to restart and retry a different round count. The cost is all-or-nothing: one aborted round reverts the whole chain. That is why `--auto` walks 2 → 6 rounds with a fresh instance each time, and why `--preview` exists. --- ## Runbook ### Verify before spending the window ```bash ./solve-second-stamp3.sh --preview ``` Prints the full PTB without sending. Check no argument is empty — an unset shell variable renders as `@`, which the CLI reads as `0x0` and fails with a baffling *"Object 0x0000…0000 not found"*. The script also prints the seeded state and asserts `Versioned.version == 1`. Expected: ``` version=1 last_aum=0 buffer A=20,000,000,000,000,000 (2.000e+16) B=1000000 old A=1,000,000,000,000,000,000 (1.000e+18) B=2500000000 travel A=14,000,000,000,000,000,000 (1.400e+19) B=55000000000 ``` Those are the `setup.move` constants exactly. Anything else means the read path is wrong, not the analysis. ### Run ```bash ./solve-second-stamp3.sh --auto ``` Per attempt: restart → wait for `ready` → import the player key → assert active address → build the PTB → send → dump state → `POST /api/instance/check`. ### Options | Flag | Default | Purpose | |---|---|---| | `--rounds N` | 3 | fixed round count, single attempt | | `--auto` | off | try 2, 3, 4, 5, 6 rounds until solved | | `--preview` | off | print the PTB, send nothing | | `--gas MIST` | 10000000000 | raise for high round counts | | `--base URL` | `$BASE` | instance API base | ### Troubleshooting | Symptom | Cause | Fix | |---|---|---| | `Object 0x0000…0000 not found` | an unset variable rendered as `@` | run `--preview`, find the empty argument | | `EHardCapExceeded` | `deposit_value + last_aum` crossed 2e11 | fewer rounds; `--auto` handles this | | `EStaleAccounting` | `refresh_aum` did not land before `deposit` | leg order changed; restore step 1 | | `EUnsupportedVersion` | v1 refused the object | `Versioned.version != 1`; re-check finding 1 | | `EAlreadyProcessed` | both single- and two-pool collect variants called | keep only `collect_position_*` | | `InsufficientGas` | 30+ command transaction | `--gas 30000000000` | | `EAlreadyClaimed` | `claim` already ran on this chain | restart; the script always does | | `bad interpreter: …^M` | CRLF from the Windows filesystem | `sed -i 's/\r$//' solve-second-stamp3.sh` | | a PTB *argument* error, not a Move abort | CLI may not pass a result as `&mut` | port the round to the TypeScript SDK | ### Environment notes Findings from getting this running, each of which cost an iteration: - **The challenge `rpcUrl` shares host:port with the storybook UI.** A raw JSON-RPC `POST` to `/` — and to `/rpc`, `/api/rpc`, `/json-rpc`, `/sui` — returns Caddy's Express 404 `Cannot POST /`. The Rust SDK reaches the node fine, so `sui client` is the only working transport. Confirm with `sui client chain-identifier`: a localnet id differs from testnet (`4c78adac`) and mainnet (`35834a8a`). - **`sui client object --json` on 1.76** puts Move fields flat under `.content`, not `.content.fields`; names the type key `objType`, not `type`; and serialises `Balance` as a bare string rather than `{value: …}`. - **`sui client` generates its own keypair on first run** and makes it active. Import the player key and verify `sui client active-address`, or every transaction signs as the wrong address and fails confusingly. - **Bash arithmetic overflows on these numbers.** `travel_counter.reserve_a` seeds at 1.4e19, above the signed-64-bit ceiling of 9.22e18 — exactly the value the solve check compares. All numeric comparison goes through `python3`. - **Every restart regenerates genesis**, so all object and package IDs rotate. The script re-reads them from `/api/instance` each run; never reuse IDs across restarts. --- ## Lessons & defences - **`<=` is never the right version comparison.** A version gate exists to make exactly one package authoritative over a shared object. Any comparison admitting a range admits every historical publish, and on Sui those publishes are immortal — you cannot delete an old package, only make it refuse to run. Equality plus an admin-gated `migrate` is the pattern. - **A version field nothing increments is decoration.** `assert_supported` appears on every sensitive entrypoint here, which reads as defence in depth. It is inert because `set_version` has no callers. Grepping for *writers* of a security-relevant field is worth as much as reading the checks. - **Fixed-point scale is a type the compiler does not enforce.** `4e18 >= 1.01e10` compiles cleanly and is nonsense. Where Q-notation appears in names (`_q10`, `_q64`, `_e6`), verify both operands agree, and test the branch boundary rather than one nominal value. - **A guard cannot validate a structurally constant input.** The deviation check passed at exactly its limit while the range check failed. Both were derived from the same broken selector, so neither could catch it. Range and deviation logic should not share a single unvalidated source. - **Divide last.** `(amount / 1e12) * price / 1e6` and `mul_div(amount, price, 1e18)` are the same formula and different functions. The first truncates a whole decimal range to zero. - **Valuation and entitlement must derive from the same quantity.** Pricing shares off `accounted_*` while paying out of `reserve_*` is a solvency hole even when every function is individually correct. Withdrawal here never consults AUM, which is what turns an accounting error into a drain. - **Upgrades that add state via dynamic fields raise the stakes on version gating.** The old package is not merely out of date, it is structurally blind to the new state and will confidently value it at zero. --- ## Tools used | Tool | Purpose | |---|---| | `diff -ru v1/ v3/` | isolate what each upgrade changed; the `math` and `refresh_position_info_*` splits fell straight out | | `grep -rn set_version` | prove the version counter has no writers — the finding that unlocks everything | | manual constant tracing | evaluate `quote_bin_from_price` and both AUM sums by hand at the seeded price | | `sui client ptb` | build the 30-command single-transaction drain | | `sui client object --json` + `jq` | read `last_aum`, `version` and pool reserves between attempts | | `python3` | 128-bit comparison against the residual limits | | `curl` + `/api/instance` | resolve per-instance IDs; `/api/instance/check` grades the solve |