Logo collage of Linux Tux, VirtualBox, Ladybird, Mina Protocol, Starcoin, Forgejo, CTFd, Cairo, Starknet, Wasmtime, ECOVACS, Raymarine, Milesight, bonsai-js, and Safe Exam Browser.
Some of the projects we found a zero-day in for NNS CTF 2026

From September 4 to 6 we hosted NNS CTF 2026, the second edition of our CTF. 887 teams played 80 challenges. 13 of those challenges were built on 18 zero-days in software people actually run: a Git forge, the CTF platform CTFd that the whole CTF scene uses, a robot vacuum, navigational devices for boats, a Linux kernel driver, two blockchains, a JavaScript engine and a WebAssembly runtime.

Of the 27 challenges I wrote, seven were zero-days. I cover a handful here and will publish deep dives on the ones that turned out to be really interesting cases, beginning with git gud and dyslexic.

Responsible disclosure is dead?

Every zero-day challenge shipped with the same request, asking players to NOT report the bug to the affected project during the CTF. We reported as many as we could before the event, but in some cases teams (sloppers) solved a zero-day challenge before we had even reported it. That left a window where they could snipe the bug themselves and collect a nice bounty.

Luna Tong, CEO of our sponsor Zellic, had already summed up the state of the disclosure window in four words. Since we were planning an event with so many zero-days, we could not bring ourselves to NOT print it on our merch as the ethos of the whole CTF.

responsible disclosure is dead🤦

Image 1 from @gf_256's postImage 2 from @gf_256's post
221.1K
NNS CTF 2026 merch with the text responsible disclosure is dead.
The rare NNS CTF 2026 merch we made for the event

Nine challenges worth knowing about

Not every planned zero-day made it to the event. Several of my challenges were scrapped because their bugs were patched in the weeks, and in one case days, before the CTF. These are some of the ones that survived.

git gud: Forgejo, CVSS 9.9

The latest version of Forgejo, open registration, flag at /flag.txt. The challenge was to find a critical RCE in one of the most widely used self hosted Git forges in the world.

The git gud challenge page on the archived NNS CTF 2026 site: boot2root, 0day, 201 points, 22 solves, with the note asking players to keep the zero-day private until patched.

Forgejo deletes .git from a template repository and then expands variables in filenames, so a directory named .g${TEMPLATE_NAME} in a repo called it becomes .git after the check. Plant a config with hooksPath and the first commit runs your hook as the service user. Found by 0xLE, fixed six days after the CTF started, and now a critical CVE-2026-89094.

The CVE-2026-89094 record on cve.org: published 2026-09-10 by MITRE, CWE-1336, CVSS 3.1 score 9.9 Critical, Forgejo affected from 16.0.0 before 16.0.4.

dyslexic: VirtualBox shared folders in the Linux kernel

Co-authored with 0xLE, and found while we were researching the kernel for our other kernel CVEs. Players got an unprivileged shell and a root-owned flag at /challenge/flag.txt on a vboxsf mount. The driver's setattr never calls setattr_prepare(), so the shared VirtualBox folder never checks permissions. Anyone can chmod the file and read it out. At the time of writing, mainline still has the bug. Full writeup.

The dyslexic challenge page on the archived NNS CTF 2026 site: misc, 0day, beginner, 79 points, 109 solves, by hoover and 0xle. Description: I can't read. The flag is at /challenge/flag.txt.
The most solved zero-day of the event, and the only one tagged beginner, it is really a oneliner!

fork: Starcoin and Block-STM

A critical chain halt vulnerability in Starcoin. The challenge ran the same latest release that Starcoin mainnet runs, and the flag was handed out the moment a player forked the network, hence the challenge name. Sent against mainnet, the same transaction would have caused a hard fork. Fortunately, nobody did.

The fork challenge page: blockchain, 0day, starcoin, 141 points, 38 solves. A small Starcoin network with one sequential block producer and two Block-STM validators. Goal: make the validators permanently disagree with the producer and halt.
Break consensus in the latest Starcoin nodes from one funded account

The exploit, live on mainnet at the time, was to craft transactions where a speculative read went stale in a way validation did not catch. The validators committed a different state root from the producer, refused the producer's chain, and halted. A throughput setting turned into a permanent network split. It got zero solves in the Humans division.

Starcoin executes blocks with Block-STM, the parallel execution engine that turns the fixed transaction order of a block from a curse for parallelism into a blessing. Its optimistic nature was fun to look at when I researched this protocol.

Title page of the Block-STM paper: Scaling Blockchain Execution by Turning Ordering Curse to a Performance Blessing, by Gelashvili, Spiegelman, Xiang, Danezis, Li, Malkhi, Xia and Zhou, with the abstract.

Threads execute transactions in parallel and record what each one read. Afterwards a validation task re-reads that read set. If an earlier transaction has since written to it, the transaction aborts, re-executes, and everything above it is re-validated.

Five stages of the Block-STM collaborative scheduler on a block of nine transactions: transactions 1 to 3 execute, 2 fails validation, 4 is suspended on a dependency, then execution and validation interleave until the block is done.

Dahlia Malkhi's walkthrough shows the same on ten transactions. Whatever the schedule, the result must equal a sequential run of the block. That is the whole security property, and the one that were possible to exploit on Starcoin.

Four stages of Block-STM on ten transactions: 1 to 4 processed and 2, 3, 4 aborted; then 2 re-processed with 3, 4, 6 suspended; then 3 re-processed with 4, 6, 9 suspended; then 4 re-processed and 6, 9 processed.

You would never hack a CTF: CTFd

Everybody knows CTFd, the beloved and most widely used CTF platform. For this challenge we spun up its latest version, and stealing all the flags took a chain of three zero-days. The first made you admin, the second let you write a template, and the third rendered it to execute code.

The You would never hack a CTF challenge page: boot2root, 0day, 253 points, 15 solves, with the animated You wouldn't steal a car anti-piracy meme and a note that the flag is at /flag.txt.

Omniscient: a robot vacuum

The goal of Omniscient was a zero-day RCE over the network on the ECOVACS DEEBOT X5 PRO OMNI robot vacuum, made possible by a command injection in its web interface that gave root on the device. The management handler base64-encodes most request fields before building a shell command, but drops the timestamp in raw. The challenge ran the vacuum's real firmware binaries under QEMU, so players attacked the vendor's actual code.

The Omniscient challenge page: boot2root, 0day, iot, 149 points, 35 solves. I got this ECOVACS DEEBOT X5 PRO OMNI vacuum for Christmas in 2024, running 24/7 in the NNS house with the latest firmware.

Raymarine navigation: a boat display

The target was Raymarine's Axiom chartplotter line, on the latest version of their firmware, released in July 2026. Same recipe as Omniscient, where we hosted the firmware's own BusyBox web server and CGI handlers running under QEMU.

Raymarine Axiom+ multifunction displays in 7, 9 and 12 inch sizes, showing chart, home screen and engine dashboard pages.

The vulnerability is in SoftwareUpgrade.sh, the CGI that installs updates. Its progress parameter is substituted straight into shell, so one GET request runs as root and we have RCE!

/cgi-bin/SoftwareUpgrade.sh?ipaddress=127.0.0.1&package=firmware.bin&progress=x[$(cp /root/flag.txt /mnt/tmp/httproot/SharedFiles/loot)]

The flag then sits in the display's shared files folder, served by the same web server.

The Raymarine navigation challenge page: boot2root, 0day, iot, 149 points, 35 solves. A marine navigation display from Raymarines should only navigate. Even with the latest July 2026 version, can you navigate your way to root?

Bank of NNS: Mina's proof system

Players withdrew from a bank by proving funds in zero knowledge. The bank only verified, and trusted the circuit's 64-bit range check on the amount.

The Bank of NNS challenge page: blockchain, 0day, mina, zk, 111 points, 56 solves. Every withdrawal is settled against a zero-knowledge proof of funds that you produce yourself. Empty the vault.
Empty the vault.

The circuit used Kimchi, Mina's proof system, where equalities between cells are enforced by wiring them into permutation cycles.

Kimchi circuit layout from David Wong's post: gate parameters on the left, seven IO registers and eight temporary registers per row on the right, with purple lines wiring cells across rows into equality cycles.
Registers and wiring in Kimchi

Kimchi's connect_64bit helper does three swaps where two close the cycle, so one cell of the range check ends up in no cycle at all. That cell carries weight 2762^{76}, and

276≡0(mod264)2^{76} \equiv 0 \pmod{2^{64}}

so anything placed in it is invisible to the bank's 64-bit ledger word. A withdrawal of

1000+2⋅276=1511157274518286468392721000 + 2 \cdot 2^{76} = 151115727451828646839272

passed both the balance check and the daily limit as 10001000, and the vault paid out all 1.5⋅10231.5 \cdot 10^{23}.

Block Rehearsal: Mina block validation

An OCaml service linked against Mina's libraries that ran Mina's own validation on a submitted block diff.

The Block Rehearsal challenge page: blockchain, 0day, mina, ocaml, 190 points, 24 solves. Ever wondered how Mina handles block proposals? No? Well, there is a time for everything.

Mina's completed-work check asks the scan state for as many jobs as the diff has work items and zips the two lists with List.zip_exn. Nothing checks the counts match. A diff with more completed work than pending jobs crashes the validator with a length-mismatch exception instead of a rejection.

Two scan state trees from the Mina docs. Leaves are base transaction jobs B7, B9 and B10 with work IDs, fees and the prover that submitted the completed work; merge jobs M9 and M10 above them combine pairs of proofs.
Completed SNARK work filling scan state jobs, which the validator zips against the submitted work

void: Cairo and Stwo

A proof-backed GCD oracle. Players submitted a modulus and a number below 2962^{96} that is coprime to it, so a modular inverse exists. Then they had to present a valid Stwo proof of a Cairo program that only completes when circuit_inverse reports failure.

The void challenge page: blockchain, 0day, 213 points, 20 solves. Welcome to the void. Here we can do the impossible.

The whole handout was six files. The Cairo program is the entire challenge:

use core::circuit::{
AddInputResultTrait, CircuitElement, CircuitInput, CircuitInputs, CircuitModulus,
EvalCircuitTrait, circuit_inverse, u96,
};
#[executable]
fn main(input: u96, modulus_limb: u96) -> (u96, u96) {
let value = CircuitElement::<CircuitInput<0>> {};
let inverse = circuit_inverse(value);
let modulus = TryInto::<_, CircuitModulus>::try_into([modulus_limb, 0, 0, 0]).unwrap();
match (inverse,).new_inputs().next([input, 0, 0, 0]).done().eval(modulus) {
Ok(_) => { panic!("input is invertible") },
Err(_) => {},
}
(input, modulus_limb)
}

The service wraps it. It checks the inputs, binds the proof's public program and output to the submission, and runs the Stwo verifier.

All of that held. What did not hold was the meaning of the failure branch: Cairo's inversion circuit would accept a "no inverse" result for inputs that had one, and the prover would happily prove it. A proof-backed error has to be as trustworthy as a proof-backed answer, and here it was not.

An independent arithmetic check says an inverse exists, while the accepted circuit result claims inversion failed. A sound proof must exclude this contradiction.An independent arithmetic check says an inverse exists, while the accepted circuit result claims inversion failed. A sound proof must exclude this contradiction.

Hosting a CTF on zero-days

It is fun. More fun than I expected. Every zero-day challenge is a real target with a real bug, and watching 887 teams poke at software they use every day is a different experience from watching them solve a puzzle you invented.

It also makes a point I keep coming back to, finding vulnerabilities in your favorite applications is not really that hard. None of the 18 bugs needed a fuzzing cluster or a month of reverse engineering. They needed someone to read the code of a feature closely and ask what happens when the assumption behind it is wrong. Forgejo deletes .git and then renames files. Kimchi wires three cells with three swaps. Starcoin validates reads that were already stale. Once you start looking, they are there.

The hard part was hoarding them. A zero-day challenge only works if the bug is still alive on the day of the CTF, and I sat on some of these for months. Several planned blockchain and ZK challenges died because upstream fixed the bug in the weeks before the event, one of them days before. The Mina, Cairo and Starcoin bugs above survived. All of them were still unpatched when the CTF opened.