Fork
A Move VM module cache that lives outside Block-STM's tracked read-set, raced into a permanent chain fork.
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 it — git 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
- Concurrency is off by default.
StarcoinVM::get_concurrency_level()defaults to1(sequential), and the call to enable it,StarcoinVM::set_concurrency_level_once(...), is commented out in stocknode/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 with8), which is precisely what makes the challenge’s Block-STM integration reachable at all. - 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 frameworkUpgradeEventorConfigChangeEvent<Version>(the governance stdlib-upgrade mechanism) — it does not check for an ordinary userPackage/module publish transaction. So nothing prevents Block-STM from running module-publishing and module-calling transactions speculatively/concurrently in the same block. - Per-task Move VM Loader module cache is not part of the tracked read-set. In
vm_wrapper.rs,ExecutorTask::init()builds oneStarcoinVM(which owns aMoveVmExt/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 byModuleIdinternally; once a worker has resolved moduleM, later transactions handled by the same worker get the cached bytecode directly, without going back through the trackedMVHashMapView::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
- Publish a Move module
owner::Counter { get() -> u64; store(signer) { move_to(&s, Val{v: get()}) } }from a funded account (user1). - 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, noUpgradeEvent). - Generate and fund ~20 throwaway accounts.
- In one shot: submit the upgrade (re-publish) transaction from
user1, then immediately fire all 20Counter::storecalls from the throwaway accounts, all racing into the same block, all hitting the producer/validators’ mempools together. - 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/stateflipsforked:trueand returns the flag.
Tooling
move-build.exe(from thestarcoin-srcclone) — plain Move source→bytecode compiler; no need for the heaviermpmCLI (which pulls inlibrocksdb-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 aSignedUserTransaction(transfer/entry/publishsubcommands) and prints hex fortxpool.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_secsis relative to chain genesis, not Unix time (head.timestamp/1000 gives the current chain-seconds) — using Unix time producesTRANSACTION_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).