Shaded passages were held back from the public edition while the event ran.
Five points. That’s what the extra challenge on Day 30 was worth, out of 1,815. It turned out to be the most important five points of the month.
Here is why. Every team that has solved everything is tied on points, and the order among them is whoever banked their last flag first. On Friday morning the organizers dropped one extra challenge beside the usual seven: a plain, old-fashioned reverse engineering problem, Single Solve only. No Time Trial, no Mastery, none of the structured questions the robot is built to read. Download it, take it apart, find the flag.
It was called SHA1-Hulud, and it took us an hour and 48 minutes.
By the afternoon ten teams sat on 1,815 points, ordered exactly by when they solved it. We were the tenth to land it. Thursday we were 1st overall. Friday we were 10th. crash, who beat us in the morning race, was down at 23rd for not having it yet.
The morning race
The race itself went fine. Seven challenges, 21 checks, and the robot banked the last of them at 100.9 seconds after the 09:00 release. That’s 2nd on the day, seven seconds behind crash at 94.0, with PortKnockerz third at 230.2.
- Percent Percent (warmups): percent-encoding, applied twice. All three modes by 10.1 seconds.
- Layered Protections (mobile): an answer code behind two layers, one of them a rolling XOR. All three by 25.8.
- Spill the Beans (script): BeanShell and JSP scripts. All three by 27.7.
- Seeing Double (metadata): which files in a collection are byte-for-byte the same. All three by 33.6, first blood on every one.
- Campaign Trail (binary): programs from a whole malware campaign, configs and all. All three by 81.1, first blood on the Time Trial and Mastery.
- Queue the Confusion (web): a job queue that hands results to whoever asks. All three by 92.1.
- Pass the Identity (web): a server that believes whatever identity you say you’re speaking for. All three by 100.9.
Five first bloods out of 21, five boards where we were the first team in the event to solve. The four file lanes were done by 33.6 seconds, which is where they live now, and warmups and script got every mode right on the first try, with the code the robot had the night before.
The seven seconds went to the web lanes, and the robot’s own numbers say exactly where. Yesterday’s change worked: the robot now opens a web ticket (the short report it writes the instant it can’t answer, which wakes that lane’s AI agents) the moment the target is issued, not after its sweep of known techniques. The Single Solve tickets opened at +6.2 and +10.4 seconds, where the sweep would have ended at +12.1 and +22.8.
What didn’t work was the next step. Pass the Identity waited on the hand agent, the AI that answers a question directly instead of writing code, in every mode: ticket +6.2 to answer +44.6 on the Single Solve, +59.7 to +73.8 on the Time Trial, +86.9 to +99.6 on the Mastery’s fifth proof. The derivers, the agents meant to turn an accepted hand answer into a live patch, spawned 27 times and went live once. Binary paid the same tax, about 20 seconds on each of its Time Trial and Mastery.
The gap to crash was never the file lanes or the server. It was hand work that stayed hand work.
Wonder Wall
The prompt was one line. The author was “really upset” that a real-world worm crew had fumbled their payload’s name. “Anyway, here’s Wonder Wall!” The name plays on Shai-Hulud, the worm that tore through npm, and the wall turned out to be exactly the right word.
What came down was a Windows program written in Rust. It asks one thing, “What is the flag?”, and answers either “correct” or nothing useful. Every other challenge this month came with questions the robot knows how to read. This one came with a single box. So it was hand work from the first minute: me and the watcher session, the AI that watches the morning race live, working it directly. By 09:10 I had asked for every agent we could put on it.
Behind that one box is a wall of anti-analysis. The program carries an encrypted list of forty analysis tools it hunts for: debuggers, disassemblers, packet sniffers, process monitors. It checks whether it’s in a virtual machine. And here is the cruel part: it never tells you it caught you. No crash, no refusal, no warning. It quietly computes a different answer, and every answer it computes looks plausible right up until you try it.
At 09:17 there was exactly one solve on the board. Good. That meant it could be done.
We took it apart statically first, then with three helpers in parallel, and mapped the check. Knowing its shape didn’t hand us the answer, because the answer depends on what the program thinks of the machine it’s running on. So we ran it, contained and offline, under a debugger it couldn’t see. Every expected value came out as junk.
Then I offered up a real Windows machine: a spare VM on my own server, snapshotted and cut off from the network. I told the session I wasn’t worried about what the program might do to it. I ran it three times at the console. Its answers were junk too, just different junk.
Well into the second hour, still no flag, I put it as plainly as I could: “We must get this solve. So if we have to keep going, we keep going. If we don’t solve this, we can’t win at all.”
So we kept going. The next stretch was a hunt for which of the program’s environment checks was failing, fixing them one at a time and watching what changed. Two whole theories died to a single-bit flip that changed nothing, and those dead ends steered the hunt as much as any hit did. It came down to one small thing a real Windows machine does that our setup didn’t. Fix that, and the program finally printed “correct, the flag is”, and at 10:48 the Game Master said CORRECT.
Nine teams had beaten us to it. Integris had it at 09:08, eight minutes after the release. Hats off.
How it was actually solved
This is the full method, held back from the public edition while the challenge was still open to other players.
Triage first. The file was a 632 KB Rust PE32+, with the crate path embre\...\crypt\src\xor.rs and a section named .xsobf. Every API was resolved by hash (FNV-style over the lowercased UTF-16 module name), so strings gave nothing. The 40 tool names, the VM registry paths and both prompts were XOR-keystream encrypted in .xsobf, and we decoded that cipher by hand.
The name explains itself once you look inside. SHA-1’s initialization constants are inlined all over the binary, but none of its round constants are. It’s a custom compression function that hashes the program’s own .text and folds in a value from an int3 answered by the program’s own vectored exception handler. Set a software breakpoint and .text changes, so the hash changes, so every key changes.
Then the check itself. Ghidra headless overflowed its response buffer on the main function; with 200 MB it came to 20,650 lines. A brute-force helper tried 22 million short strings against the digests and matched nothing, which killed the “hash of chunks” theory. The real check lowercases and trims the input, adds an 8-byte length prefix, pads to 16-byte blocks, and runs a custom invertible 128-bit ARX cipher over it in two passes: forward and CBC-style with per-block keys from a running hash of earlier plaintext, then backward. The result is compared against expected data unpacked from a 16 KB blob. Two probe passes times eight candidate lengths gives 16 rounds, most of them decoys that decrypt to plausible taunts.
Running it was its own fight: wine inside our bwrap sandbox, with Linux gdb, and two traps. Wine re-execs itself, which wipes hardware breakpoints, so they had to be armed from inside the program after its first trap. And the program’s own int3 traps belong to its VEH, so re-injecting the SIGTRAP looped forever. The fix was to make gdb do the exception handler’s job itself, 53,572 traps per run. The first live dump of keys and compare bytes decrypted to junk. The Windows VM, with QEMU’s gdbstub attached at the hypervisor so the breakpoints lived outside the guest, gave real Windows keys and still junk, because the VM failed its own probes.
The breakthrough was one insight. The probe records are hashed as (expected, observed) pairs by difference, so raw hardware values cancel and only a mismatch matters. That made it a one-probe-at-a-time search. Firmware tables were one real cause: wine returns nothing for EnumSystemFirmwareTables('ACPI') and GetSystemFirmwareTable('RSMB'), so we fed it the real tables from the Proxmox host at both probe passes. A long list of suspects (CPUID hypervisor bits, RAM size, timing, SMBIOS content and more) each died to a flip that changed nothing. The last blocker was VirtualFree(NULL): real Windows sets last error 487, ERROR_INVALID_ADDRESS; wine sets 87, ERROR_INVALID_PARAMETER. A classic wine tell, checked in both passes. Forced to 487, round 4 decrypted to the start of a 38-character flag.
The program gave up the rest of the flag itself. Pass 1’s keys come from a running hash of earlier plaintext, so we fed the recovered prefix back in and let the program compute the next block’s key itself, twice. The full flag made it print “correct, the flag is” under wine, and only then did it go to the Game Master. The VM was rolled back to its snapshot afterwards.
The robot never touched any of this, for a simple reason. The start response carried answer_ids: ["flag"] and no questions at all. It landed on a spare lane, the standby track kept warm for a challenge with no lane of its own, built Day 26 and seeing its first live use. Zero questions meant no ticket and no agent. Day 31 was expected to carry no one-off, so the “flag box only” path was never built.
It was also the day the usage ran out. The weekly Claude counter hit 100% in the middle of the hunt, with the decider the next morning, and the heavy analysis moved onto Fable’s separate pool, which sat around 20%.
The record
From the public scoreboard, Day 30:
- Wizdumb 100.9 seconds, 2nd on the race of 72 finishers. crash 94.0, PortKnockerz 230.2.
- First blood on 5 of 21 boards: every mode of Seeing Double, and the Campaign Trail Time Trial and Mastery.
- SHA1-Hulud solved at 10:48, the 10th team of 22 solvers by the afternoon. First blood Integris at 09:08.
- 10th overall of 773, on 1,815 points, tied with the nine teams above us.
On the speed boards, which rank each team’s best time on every timed mode, we took 1st on two Day 30 boards: the Layered Protections Mastery at 1.456 seconds and the Queue the Confusion Mastery at 2.661. Across all 30 days we hold 19 firsts, 32 seconds and 33 thirds.
One day left
None of Friday’s order survives Saturday. Every tied team that finishes Day 31 stays tied on points, and the order resets to whoever banks their last Day 31 flag first.
We were 1st on Thursday by 81 seconds and 2nd on Friday by seven. Both races, we were right there. Tomorrow the robot gets one more morning to prove it. I said that afternoon we needed to be ready to finish strong. We are.
How we built it: the Day 30 dev log
Watching for the extra. The organizers had warned us an extra was coming. The plan on the table was to check the Day 30 list by hand at 09:01, and I pushed back: check right away, and treat it as just one more challenge on today’s list, not a new process. The release poller would see it if it came under Day 30, so the only new piece was a small background search in case it showed up somewhere else, set to start at 09:00:00 (I moved it from 09:00:15; challenges release on the minute). In the end the poller found it in the Day 30 list at 09:00:04 and parked it on a spare lane, where it sat with nothing to do.
The hunt. The watcher session did the hand work, launched helpers in parallel when I asked for more, and tested one theory at a time. About twenty minutes in I was asking for other tactics, and by 09:30 I was saying plainly we needed this one to win anything. The VM was my idea; I brought it back up at 10:08 and ran the probe at its console. After the solve I had the session write down the method, a reproduction kit and the blog material before compacting, so none of it would be lost.
The day close. Two parts. Part A (a9b8111) folded every lane’s live gaps into its permanent engine: the double URL-decode, the duplicate-file reader, the rolling-XOR codec, the HGRF record, Neko bytecode, and both web techniques. The proof on the mock, our local stand-in for the Game Master, went 12 of 12 on the four file lanes plus binary’s Single Solve, with zero tickets, and turned up one real finding: binary’s record of the morning still held the 09:00 grading with 8 unsolved, never refreshed after the hand answers made it CORRECT. Fill that in and binary went 3 of 3. I skipped web’s live proof; if the fixes passed their initial runs, they were fine.
Part B (e39776d) fixed the process that cost us the seven seconds: an accepted hand answer is wrapped into a patch at once and judged like any other patch, against every answer the grader has ever accepted, with the deriver kept as a fallback. The estimate was today’s web finish going from about 102 seconds to 77, an estimate and not a measurement, and it was the last B change before Day 31.
Usage. With the weekly counter gone, I raised the limit at 11:45 and told the session to finish the work. When it started building a flag-box path for the spare lanes, I stopped it: tomorrow was just the regular runs.