CERN

Two Aztec functions share a 4-byte selector, so redirecting a callback to the contract itself mints its private notes to me.

2026.09.09 NNS CTF 2026 161 pts Blockchain
FLAG NNS{7He_4714s_tr16g3r_kep7_0n3_v3rY_Pr1Vat3_d1PH0toN_3V3nt_H1DD3N_insiDe_7H3_ceNtR4l_Readout_57R3aM}

Challenge

Three pre-deployed Aztec (Noir) private contracts on a live node: AtlasTrigger (with private note types), DetectorInterface, FlagEmitter. “CERN produces far more collision data than it can keep. Make sure yours can survive the ATLAS Trigger.” A solve.test.ts (aztec.js/vitest) scaffold is provided. Win = drive FlagEmitter.emit_flag(you) → requires is_captured(atlas, you) == true.

Win path

emit_flag(you) is reachable only via AtlasTrigger.archive_higgs_candidate(you, bunch) (guarded only_self + emit allowlist), enqueued when commit_higgs_candidate succeeds. That requires the caller (researcher = msg_sender = you) to own all of:

  • DetectorSessionNote(stage=EVENT_BOUND, class=EVENT_CENTRAL) (from submit_diphoton_event)
  • BeamConditionNote (from attest_beam_conditions)
  • EventHeaderNote, HltDecisionNote, 3× ReadoutFragmentNote (sources 0x41 LAr, 0x43 Inner, 0x46 L1-topo)

plus public state l1_mass_squared[you][bunch]!=0 and attested_luminosity[you] >= 1000.

The event-builder notes (EventHeader/HltDecision/ReadoutFragment) are minted only by reconstruct_central_block, guarded by assert(msg_sender == this_address) and normally reached via dispatch_central_readout with frame_owner = this_address → notes owned by the contract, unspendable by you. Dead end by design.

Vulnerability — function-selector collision

submit_diphoton_event dispatches the EVENT_CENTRAL callback to the attacker-controlled readout_processor (set in register_detector_configuration):

DetectorInterface::at(readout_processor).calibrate_candidate_cluster(researcher, bunch, mass2, fingerprint)

calibrate_candidate_cluster and reconstruct_central_block share the same argument types ((Field),Field,u128,Field) and therefore the same 4-byte function selector 0x2bc3d14e (verified via FunctionSelector.fromNameAndParameters). Setting readout_processor = AtlasTrigger's own address redirects the callback into AtlasTrigger.reconstruct_central_block(frame_owner = researcher = you, …). Because the call is AtlasTrigger→AtlasTrigger, the msg_sender == this_address guard passes, and frame_owner = youall event-builder notes are minted owned by you. Only the CENTRAL route’s selector collides (the other three differ), so the event must be EVENT_CENTRAL. The random() note nonces are PXE-supplied, so they’re readable client-side. stream_tag = self.context.selector() = 0x2bc3d14e.

Exploit (8 steps, against the pre-deployed AtlasTrigger — no helper contract)

  1. advance_trigger_menu(47) until trigger_menu_position(47) % 3 == 2 (menu is global/shared — read then top up; retry on races).
  2. register_detector_configuration(cfg=1, readout_processor = ATLAS_ADDR) ← AtlasTrigger’s own address 0x0e4b203f…2481c2.
  3. activate_detector_configuration(1)
  4. attest_beam_conditions(1, 0, 1000) → luminosity 1000.
  5. submit_diphoton_event(1, bunch=2, e1=61,px1=0,py1=0,pz1=61, e2=61,px2=0,py2=0,pz2=(FIELD_PRIME-61)) — back-to-back massless photons along z: total_pz=0 → EVENT_CENTRAL; mass²=122²=14884 ∈ [14400,16900]. Sets the L1 record and (via the selector collision) mints header/decision/fragment notes owned by you. (bunch=2 because bunch=1 had been consumed by an earlier attempt on the same live instance.)
  6. event_fingerprint = poseidon2([you,1,bunch,61,0,0,61,61,0,0,(FIELD_PRIME-61),14884]); call query_readout_fragment(you,bunch,event_fingerprint,i).simulate() for i=0..6, read fragment_nonce of the notes with source_id 0x41/0x43/0x46lar_nonce, inner_nonce, l1topo_nonce.
  7. commit_higgs_candidate(1, bunch, 14884, event_fingerprint, lar_nonce, inner_nonce, l1topo_nonce, stream_tag=0x2bc3d14e) → enqueues archive_higgs_candidateemit_flag(you).
  8. is_captured(atlas, you) flips to true; the Web UI reveals the flag.

Tooling notes (Docker-free Aztec on WSL2)

  • nvm → Node 22; npm i -g yarn; yarn install.
  • bash -i <(curl -s https://install.aztec.network) with VERSION=4.2.0-aztecnr-rc.2aztec, nargo 1.0.0-beta.18, bb.js (bundled native amd64-linux prover; no separate bb CLI). yarn ccc compiles the 3 contracts + codegen artifacts.
  • EmbeddedWallet (@aztec/wallets/embedded) connected directly to the remote node RPC; register the schnorr account from the given secret key / salt=0 / signing key; register the two deployed contracts via node.getContract(address) + local artifact.
  • Gotchas: this SDK’s .send() already awaits mining (don’t chain .wait()); the live node needs realProofs → set proverEnabled: true (≈5–11s/tx with the native prover).

Deliverables in blockchain/cern/cern/: src/ts/solve_full.ts (standalone capture script), src/ts/solve.test.ts, compiled src/artifacts/*.ts.

#aztec#noir#selector-collision