Fork

A Move VM module cache that lives outside Block-STM's tracked read-set, raced into a permanent chain fork.

2026.09.10 NNS CTF 2026 176 pts Blockchain
FLAG NNS{7H4Nk_g0D_tHe_1iv3_s74rCo1n_maiNn37_D0es_n07_ruN_17s_NoD3s_Wi7H_b10cK57M_4ND_c0ncurR3NcY_0V3R_1_bY_d3Fau1t}

Fork — NNS CTF 2026 (Blockchain, Starcoin / Block-STM)

Flag: NNS{7H4Nk_g0D_tHe_1iv3_s74rCo1n_maiNn37_D0es_n07_ruN_17s_NoD3s_Wi7H_b10cK57M_4ND_c0ncurR3NcY_0V3R_1_bY_d3Fau1t} Points: 176-ish · tags: 0day, starcoin

Challenge

A small Starcoin network: one producer (sequential execution, concurrency_level = 1) and two validators (Block-STM parallel executor, concurrency_level = 8), peered and normally agreeing. From one funded account, send transaction(s) that make the validators permanently disagree with the producer and halt — the moment the network forks, a Web UI (/api/state) flips forked:true and reveals the flag.

Wrong turn, then the fix

The initial (long) investigation cloned github.com/starcoinorg/starcoin at its default branch and analyzed that code — but the default branch checked out was dual-verse-dag, a new, unreleased dual-VM rewrite (has commons/parallel-executor, a GasTracker, vm2/*). The live nodes report starcoin 1.13.22, and probing every dual-VM RPC method (submit_hex_transaction2, state2.*, etc.) returned -32601 Method not found on every instance rotation — the deployed binary is genuinely single-VM. Hours were spent finding and “confirming” a real determinism bug in the dual-VM GasTracker, only to prove it wasn’t reachable through the deployed (different!) codebase. Lesson: always verify the analyzed source matches the exact deployed version/branch before trusting any conclusion from itgit tag/git branch --contains and comparing live RPC surface against the source would have caught this immediately.

Once corrected to fetch the actual v1.13.22 tag from GitHub, the real (and much simpler) target codebase appeared: Block-STM lives at vm/parallel-executor (plain Aptos-style engine, no gas tracker, no delayed fields) and Starcoin’s integration is at vm/vm-runtime/src/parallel_executor/{mod,vm_wrapper,storage_wrapper}.rs.

The vulnerability

  1. Concurrency is off by default. StarcoinVM::get_concurrency_level() defaults to 1 (sequential), and the call to enable it, StarcoinVM::set_concurrency_level_once(...), is commented out in stock node/src/node.rs — real Starcoin mainnet nodes never run Block-STM at all. The flag says as much. The challenge patches this on (validators call it with 8), which is precisely what makes the challenge’s Block-STM integration reachable at all.
  2. No safety net for user module republishing. StarcoinVM::should_restart_execution(output) forces a sequential fallback (SkipRest/BlockRestart) only when a transaction emits an on-chain framework UpgradeEvent or ConfigChangeEvent<Version> (the governance stdlib-upgrade mechanism) — it does not check for an ordinary user Package/module publish transaction. So nothing prevents Block-STM from running module-publishing and module-calling transactions speculatively/concurrently in the same block.
  3. Per-task Move VM Loader module cache is not part of the tracked read-set. In vm_wrapper.rs, ExecutorTask::init() builds one StarcoinVM (which owns a MoveVmExt/Loader) per rayon worker task, and that same VM instance is reused across every transaction the task processes for the whole block. The Move VM Loader caches deserialized modules by ModuleId internally; once a worker has resolved module M, later transactions handled by the same worker get the cached bytecode directly, without going back through the tracked MVHashMapView::read() resolver. That means a module upgrade’s write is correctly captured as a version bump in the multi-version map, but a worker that already cached the old module never re-reads it even after re-execution/validation — it just keeps serving the stale cached module. Sequential execution has no such cache-sharing-across-conflicting-writes issue, so it always sees the correct, up-to-date module. Net effect: some parallel workers can execute a call against a stale module version while the producer (sequential) and other workers correctly use the upgraded version → state-root divergence → fork.

Exploit

  1. Publish a Move module owner::Counter { get() -> u64; store(signer) { move_to(&s, Val{v: get()}) } } from a funded account (user1).
  2. Compile a new version of the same module with a different get() return value (a plain re-publish, which Starcoin’s default upgrade strategy allows arbitrarily — no governance flow, no UpgradeEvent).
  3. Generate and fund ~20 throwaway accounts.
  4. In one shot: submit the upgrade (re-publish) transaction from user1, then immediately fire all 20 Counter::store calls from the throwaway accounts, all racing into the same block, all hitting the producer/validators’ mempools together.
  5. With enough concurrent call transactions (a single call didn’t reliably race a Block-STM worker fast enough; ~20 in parallel did), some validator worker’s already-primed module cache serves the previous module version to a store() call whose sequential-order semantics say it should see the new version. Validators fall behind (stuck), producer keeps advancing → /api/state flips forked:true and returns the flag.

Tooling

  • move-build.exe (from the starcoin-src clone) — plain Move source→bytecode compiler; no need for the heavier mpm CLI (which pulls in librocksdb-sys/libclang).
  • cmd/ctf-signer (custom, built earlier for this session) — minimal Rust binary (deps: starcoin-vm-types, starcoin-crypto, bcs-ext) that builds+signs a SignedUserTransaction (transfer/entry/publish subcommands) and prints hex for txpool.submit_hex_transaction. Note: it’s a native Windows exe, so pass it Windows-style paths (wslpath -w ...) when invoked from WSL.
  • Address derivation confirmed empirically: address = SHA3-256(ed25519_pubkey || 0x00)[16:] (Starcoin’s 16-byte address = last half of the auth key).
  • Dev-chain expiration_timestamp_secs is relative to chain genesis, not Unix time (head.timestamp/1000 gives the current chain-seconds) — using Unix time produces TRANSACTION_EXPIRED.
  • Fork status: GET <web-ui>/api/state{forked, flag, producer:{n,hash}, validators:[{i,up,n,hash,behind,stuck}], blocks:[...]}.

Scripts saved under blockchain/fork/: fire3.py (the winning batch-race driver), live.sh/live2.sh (RPC helpers), FORK_INTEL.md (full investigation log, including the dead-end and the pivot).

#0day#starcoin#block-stm#concurrency