
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🤦



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.

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.

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.

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 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.

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.

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.

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.

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.

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.

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.

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 circuit used Kimchi, Mina's proof system, where equalities between cells are enforced by wiring them into permutation cycles.

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 , and
so anything placed in it is invisible to the bank's 64-bit ledger word. A withdrawal of
passed both the balance check and the daily limit as , and the vault paid out all .
Block Rehearsal: Mina block validation
An OCaml service linked against Mina's libraries that ran Mina's own validation on a submitted block diff.

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.

void: Cairo and Stwo
A proof-backed GCD oracle. Players submitted a modulus and a number below 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 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.


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.