[THUGS(red)]

Second Stamp

A Move package version gate using <= instead of == lets a stale, broken valuation function poison a vault's share price, draining it in a single programmable transaction.

second-stamp-solution.md 19 KiB

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


cp "/mnt/c/Users/<you>/Downloads/solve-second-stamp3.sh" ~/
cd ~ && sed -i 's/\r$//' solve-second-stamp3.sh && chmod +x solve-second-stamp3.sh

export BASE=http://<instance-host>:<port>
./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:


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:


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:


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


// 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:


const TRAVEL_COUNTER_POSITION_KEY: vector<u8> = 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:


let amount_a = mul_div_floor(pool.reserve_a.value(), numerator, denominator);

deposit prices your shares off last_aum:


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<PALE_WAX>, Coin<GOLD_FLECK>) 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-calls:

| # | 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


./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


./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<T> 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 |

Something wrong with this page?

Wrong details, a stolen writeup, or something that should not be published here — tell a moderator. This does not go to the author.