Block Rehearsal

Crashing a Mina validator through a zip_exn that fires before any proof is ever verified.

2026.09.08 NNS CTF 2026 195 pts Blockchain
FLAG NNS{ziP_3XN_tuRn5_eX7R4_sNaRK_w0rK_1Nto_4_N0d3_CR4sH!}

Challenge

A TCP (TLS) service (service.ml). It reads ONE Staged_ledger_diff.Stable.Latest.t, bin_prot-encoded, sent as lowercase hex. It decodes with bin_read_t (requiring the buffer be fully consumed — else “malformed proposal”), then runs Staged_ledger.apply on a fresh ephemeral ledger with get_completed_work:(fun _ -> None).

Win condition — the server prints validator panicked\n<FLAG> ONLY when apply raises Invalid_argument whose message starts with "length mismatch in zip_exn:" and the diff’s completed_works is non-empty (is_completed_work_pairing_panic + has_extra_completed_work). Every other exception → “validator error”; accept/reject → nothing.

The bug

Staged_ledger.apply calls check_completed_works as its first step, before any diff validation or proof verification:

List.zip_exn (Scan_state.k_work_pairs_for_new_diff scan_state ~k:work_count) completed_works

The server’s ledger/scan-state is freshly created (Scan_state.empty), so k_work_pairs_for_new_diff returns [] regardless of k. Zipping [] against a 1-element completed_works list raises Invalid_argument "length mismatch in zip_exn: 0 <> 1" — exactly the panic the server rewards. No valid proof is needed — the crash happens before any verification. So the whole task reduces to: submit a diff that (a) bin-decodes as the real type and (b) has exactly one (any) completed_work.

Why it’s hard: the serialization

Staged_ledger_diff.Stable.Latest.t is a deeply nested versioned type: diff = (Pre_diff_two, Pre_diff_one option), where Pre_diff_two.completed_works : Transaction_snark_work.Stable.t list, and each work carries proofs : Ledger_proof One_or_two — a full Pickles Proofs_verified_2.Stable.V3 proof. Hand-encoding this bin_prot layout by hand is extremely error-prone (a first attempt kept returning “malformed proposal”).

Robust solution — use Mina’s own derived serializer

Instead of hand-encoding, build a tiny OCaml program linking the real Mina libraries and let the library’s bin_writer_t produce the bytes (cannot have a layout bug):

  1. Build mina-src in an opam switch with OCaml 4.14.2. Gotcha on Ubuntu 24.04: the vendored Rust/C crate ocaml-boxroot-sys (via kimchi_bindings) has static int try_free_chunks() (empty param list) called with an argument — illegal under GCC 15’s default -std=gnu23. Fix: export CFLAGS=-std=gnu17 before dune build.
  2. Generator (gen.ml, added as a dune exe in the mina tree) constructs a real value:
    • diff = ({ completed_works = [one]; commands = []; coinbase = At_most_two.Zero; internal_command_statuses = [] }, None)
    • the one Transaction_snark_work.Stable.Latest.t uses Ledger_proof.For_tests.mk_dummy_proof (library dummy built from Mina_base.Proof.transaction_dummy, a real Proofs_verified_2 proof) with a Quickcheck-generated Snarked_ledger_state.t statement.
    • serialize: Bin_prot.Utils.bin_dump ~header:false Staged_ledger_diff.Stable.Latest.bin_writer_t value → hex.
  3. Send the ~11 KB payload over TLS (send.py). Server replied validator panicked + the flag on the first attempt.

Files (blockchain/block-rehearsal/)

  • blockchain_block-rehearsal/gen.ml — payload generator source
  • blockchain_block-rehearsal/gen_dune.txt — its dune stanza
  • blockchain_block-rehearsal/build_gen.sh — reproducible build (incl. the CFLAGS=-std=gnu17 fix)
  • blockchain_block-rehearsal/payload.hex — winning payload
  • send.py — TLS sender
#0day#mina#ocaml