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 — 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, - fails the range test in
apply_accounting, since101 > upper_bin, collapsing
the widest value that still validates;
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 collect_position_fees/collect_position_rewardssweep the non-your-share remainder into
the next round's poisoned AUM.
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
- Step 3 must be
new_withdraw_cert, notbegin_withdraw. Only - **Steps 6–7 must be the two-pool
collect_position_*variants.** The single-pool
current package then trusts.
withdrawal_profile == 3 unlocks withdraw_travel_counter, and travel_counter holds most
of the value.
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
rpcUrlshares host:port with the storybook UI. A raw JSON-RPCPOSTto sui client object --jsonon 1.76 puts Move fields flat under.content, notsui clientgenerates its own keypair on first run and makes it active. Import the- Bash arithmetic overflows on these numbers.
travel_counter.reserve_aseeds at 1.4e19, - Every restart regenerates genesis, so all object and package IDs rotate. The script
/ — 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).
.content.fields; names the type key objType, not type; and serialises Balance<T> as a
bare string rather than {value: …}.
player key and verify sui client active-address, or every transaction signs as the wrong
address and fails confusingly.
above the signed-64-bit ceiling of 9.22e18 — exactly the value the solve check compares. All
numeric comparison goes through python3.
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- A version field nothing increments is decoration.
assert_supportedappears on every - Fixed-point scale is a type the compiler does not enforce.
4e18 >= 1.01e10compiles - A guard cannot validate a structurally constant input. The deviation check passed at
- Divide last.
(amount / 1e12) * price / 1e6andmul_div(amount, price, 1e18)are the - Valuation and entitlement must derive from the same quantity. Pricing shares off
- Upgrades that add state via dynamic fields raise the stakes on version gating. The old
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.
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.
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.
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.
same formula and different functions. The first truncates a whole decimal range to zero.
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.
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.