KOM CLOUDSERVICE
All entries

Vary

Halloween, Day 31, the decider: all 21 checks by 255.5 seconds, 4th on the day and 4th overall of 781, four-tenths of a second off the podium. The answer was sitting in a response header the whole time.

Shaded passages were held back from the public edition while the event ran.

Four-tenths of a second.

That’s what stood between us and the podium at the end of Huntress CTF 2026. Fifty-six teams had completed every check of the event, all on 1,885 points, and the order among them came down to one thing: who banked the last flag of Day 31 first.

The robot banked ours at 255.5 seconds after the 09:00 release. secretNinja did it at 234.1, crash at 236.4, and _blackb3ard_and_friends at 255.1.

That’s 4th on the day and 4th overall of 781 scoring teams. The top five overall finished in exactly the order they finished Halloween morning.

For most of the last two weeks this was a race the robot was winning. We took first on seven race days, and we were 1st overall going into Day 30. The last day came down to a single header on a single web challenge, sitting in plain sight in a response nothing in our pipeline was reading.

09:00

Seven challenges, the last of the event. In the order they finished:

  • One More Thing (warmups): all three modes by 6.5 seconds, first blood on the Time Trial.
  • Mobile Arena Gauntlet (mobile): all three by 7.1, first blood on every one.
  • The Full Transform (script): all three by 14.0.
  • Final Boss (binary): all three by 72.2, first blood on the Single Solve.
  • No Artifact Left Behind (metadata): all three by 76.9, first blood on the Mastery.
  • Full Stack Incident (web): all three by 107.1.
  • Framework Roulette (web): all three by 255.5.

Six first bloods out of 21, six boards where we were the first team in the event to solve. Five lanes done before the 80-second mark, Full Stack Incident at 107. If the day had ended there, it would have been a win by over two minutes.

It didn’t end there.

Framework Roulette

Framework Roulette was the event’s last test of whether skills travel. Its description said it plainly: “New stack, no map, no familiar names.” The target was an ASP.NET Blazor app, a framework none of the month’s web challenges had used, behind a proxy-trust puzzle with two locks.

The first lock was the client address. The app trusted the X-Forwarded-For header to say where a request came from, so claiming to be 127.0.0.1, the machine talking to itself, got past it in seconds.

The second lock wanted proof the request came from staff, and it refused everything. For more than three minutes the robot’s agents, the AIs that stand by on each lane and take over when the code can’t answer, threw everything they knew at it: about 480 combinations across roughly 50 of the standard header names an app might read a role from. X-Role. X-Forwarded-Role. X-User-Role. Wrong, wrong, wrong.

The answer was in the refusal itself. Every 403 came back carrying a response header, vary: x-arena-aed56f. Vary is how a server tells caches which request headers change its reply. It was naming, in so many words, the one request header the server was listening to, a made-up name nobody would ever guess. And our tools read response bodies. They never read response headers. Nothing saw it until an agent dumped a raw response at about 198 seconds, and there it was.

Even then the challenge had one more trap. The refusal named its policy, staff_only, so that was the obvious value to send. It failed. So did STAFF. administrator returned the proof at about 232 seconds.

From there it was quick. The Time Trial went in at 249.3 and the Mastery at 255.5. Of our 255 seconds, about 228 were that one Single Solve.

Later that morning I asked the question I already knew I wouldn’t like the answer to: “If we found that header, how much time would we have saved, roughly?” Roughly two and a half minutes. Reading Vary on the first sweep gives the header name at about five seconds, a short list of role values gets to administrator in a second or two, Framework Roulette is done around 40 seconds, and the day ends on Full Stack Incident at about 110. That’s an estimate, not a run. It also isn’t close: secretNinja won at 234.

The robot that raced was main at 66ef4f5: the Day 30 folds (a9b8111) and the hand-work wrapper (e39776d, on), which turns an accepted hand answer into a live patch at once instead of waiting on a deriver agent. The night before, I asked whether we were ready and tested, and when there turned out to be no paired run of the wrapper I asked for one, safe over sorry. The drill on the mock, our local stand-in for the Game Master, ran Day 30’s binary with agents on and only the wrapper flag different: 3 of 3 both ways, 95.6 s off and 96.6 s on, inside the noise. The wrapper put a hand method live 1.5 seconds after acceptance, where the deriver had taken 15.4. Safe, no gain on that shape, left on.

The web start had no hold-up. Both web lanes saw the release at 0.12 seconds; Full Stack Incident had its target at 2.92 and Framework Roulette at 3.72, each with its ticket (the report that wakes the lane’s agents) open at once.

Framework Roulette, by the clock: ticket at about +3.8 s; five agents from about +5 to +198 s, roughly 480 role-header combinations, every one refused with topology_proof_rejected; the raw dump at +198 s showing vary: x-arena-aed56f (the name varies per target). The hand agent, which works a question directly instead of writing code, swept values on that header (staff_only, STAFF, true and 1 refused, administrator accepted) and the Single Solve went CORRECT at +233.5. Patch 1.py went live at +248.5 and carried the Time Trial at +249.3. Of the Mastery’s six targets, five were earlier challenges handled by the shared web engine and the sixth was the Blazor app (+255.5). The probe tool and the web engine read bodies only, so the one field that named the header was invisible to every technique and every agent until someone dumped the raw response.

Full Stack Incident: hand answer on the Single Solve at +58.1 s. The Time Trial took three attempts (+62, +78, +81 s) because the Single Solve’s patches didn’t fit the new target; about 11 seconds went to judging and carrying a new patch, and the third went CORRECT at +82.2. The Mastery was the curveball, eight proofs on eight targets, mostly earlier days’ web challenges bundled together, all answered within about 1.5 seconds of each other at +107.1.

The wrapper’s first live day: 8 tries (7 binary, 1 metadata), all refused because the wrapped method didn’t reproduce the accepted answer on the ticket’s file. The patch agents carried those lanes instead. The watcher, the AI session that watches the race live, never had to send an answer, and nothing timed was lost on any lane. Its one fix was to its own monitor, which flooded at about +35 seconds and was restarted with a filter.

The record

From the public scoreboard, final:

  • Day 31: Wizdumb 255.5 seconds, 4th of 99 finishers. secretNinja 234.1, crash 236.4, _blackb3ard_and_friends 255.1, Shinigami.ph & Fwends 261.3.
  • First blood on 6 of 21 Day 31 boards: every mode of Mobile Arena Gauntlet, the One More Thing Time Trial, the Final Boss Single Solve and the No Artifact Left Behind Mastery.
  • 4th overall of 781 scoring teams, one of 56 that completed every check, on 1,885 points.
  • 1st on seven race days: Days 17, 21, 22, 24, 25, 26 and 29.

The speed boards, which rank each team’s best time on every timed mode, stood like this as of Halloween morning: 1st on three Day 31 boards, 2nd on three, 3rd on one. Across the event, we held 20 firsts, 36 seconds and 34 thirds.

The finish

This was my third Huntress. The first one, my team finished in the top 50. Last year was a wash, a DNF. This year I came in ten days late, solo, with a robot that had to be built while it was playing, and finished 4th of 781.

I’ll be honest: I’m disappointed. I came into this one saying it was well inside my wheelhouse and that I was here to win it, and for two weeks the robot backed that up. On paper this is the best I’ve ever done here, by a long way. It still stings, because this was a race we were winning, and we lost it on the last day by not reading one header.

On Day 1 I played by hand and finished dead last on every board I touched, because a person can’t make those clocks. A program can, and by the end mine had taken first on seven race days. The organizer asked a question on the intro page that I lived with all month: where does a human still have to step in? Halloween answered it. The robot read everything the author meant to give us: the files, the bodies, the clues, the routes. The author hid the last answer in the part of the response meant for machines, and a curious human would have spotted it in the first reply. We had never taught ours to look there.

The fix is small: read a refusal’s Vary and any custom response headers, and try a ladder of role values on whatever header they name. Every miss this month became code by the next morning. This one just came a day too late.

Thank you to the organizers for a month of genuinely good problems, and for a last one that found the exact gap in how we work. A good final challenge is supposed to do that. Congratulations to secretNinja, crash and _blackb3ard_and_friends.

I said the night I sat down, ten days late, that I was here to win it. I came up 21 seconds short of the win and four-tenths short of the podium. Next year, the robot reads the headers.

How we built it: the Day 31 dev log

The morning. At 08:31 I checked whether there were any API changes to adjust for. For the first time in a week usage was no worry: I had it on unlimited with auto-reload, so I told the session I wouldn’t chat as much but we were running as usual. The watcher started at 08:50. At 09:01, with three lanes already done, all I had to say was keep going.

The close that wasn’t. At 09:07, with the board settled, I called it: wrap it up, fix nothing, no A/B runs, just show me the scoreboard. So there was no day close, no folds and no proof runs. The board artifact was republished. The robot’s one live learning of the day, the warmups recipe line b64gzip+b32 31, is still sitting uncommitted on disk, because the commit gate won’t take a regenerated scoreboard while solver code is left unstaged.

Afterwards. At 09:48 I asked how much the header would have cost us, and at 09:58 whether the web lanes had been slow off the line. They hadn’t: both had their targets inside four seconds. The whole gap was the one header.