
This is the challenge I'm proudest of from upCTF, and the one that gave me the most grief to build. It went through the whole competition unsolved the intended way. It was "solved", but by brute force, powered by the current favourite hammer, an AI agent just spamming attempts until it stumbled into enough wins. Any CTF player knows that grinding random guesses until the scoreboard ticks over was never the point. So let's walk through what the challenge actually was, and the path I meant for people to find.
At its core, the app is a tiny "crossing" game. Each turn there is exactly one safe move, either cross or wait, and the server decides it deterministically:
function getCorrectMove(userId, numberOfPlays) {
const input = `${userId}:${numberOfPlays}:${GAME_SEED}`;
const hash = crypto.createHmac('sha256', GAME_SEED).update(input).digest('hex');
const lastChar = parseInt(hash.slice(-1), 16);
return (lastChar % 2 === 0) ? 'cross' : 'wait';
}
Pick the safe move and your score goes up; pick wrong and it resets to zero. Reach the target score and /api/me finally hands you the flag. The move depends on a secret GAME_SEED, so you cannot just compute it yourself.
Since a wrong guess only resets your score, nothing stops you from trying again forever. And "solve" is generous here: what actually happened is someone pointed an agent at the play endpoint and had it mash cross on every single turn, never once trying wait, no logic, no oracle, nothing. Just the same button, over and over, resetting every time the seed disagreed, until eventually enough real cross calls happened to line up back to back and the score crossed the target by pure chance. It works. It is also not clever, not interesting, and genuinely not funny to watch happen to your own challenge. The whole design exists specifically to make that kind of guessing hopeless, so let's get to the part that actually breaks it on purpose.
The interesting piece is an admin bot. You hand it a URL, and it opens that URL in a real browser carrying the admin's session cookie. Think of it as a privileged user you can point at things. It is exposed as a "hint" feature: send it somewhere that triggers the admin's play, and it will tell you whether the admin's move was safe.
But the bot is picky, and its guards are the whole challenge:
const finalUrl = page.url();
if (!finalUrl.startsWith('http://127.0.0.1:6969')) {
await page.close();
return "Are you a Hacker, little Orange?";
}
So even though you choose the starting URL, you cannot simply park the bot on a site you control and read something back. Wherever the journey goes, it has to land back on the challenge origin, or the bot throws you out.
Before the fun part, it is worth saying what does not work. There is no obvious injection, no authentication bypass, no permissive CORS, and no way to read the admin's responses across origins directly. The Same-Origin Policy does its job: you cannot read the body or the status code of a cross-origin response. And the final-URL guard means you cannot exfiltrate anything by leaving the bot on your own page. On paper, the admin's secret stays secret.
One subtlety first: despite the name, /api/adminPlay is not checking some separate hidden admin account. The route is gated by the admin cookie, so only the bot can reach it, but the game state it actually reads is yours, since the bot carries your own session cookie along for the ride too. So this endpoint quietly answers "would cross be safe for my own next move, right now", for free, without spending a play and without risking the reset. That is the real reason this oracle matters: it lets you sidestep the game's whole anti-bruteforce design, which otherwise punishes every wrong guess by zeroing your score.
Here is the key observation. The endpoint answers in one of two ways:
cross was the safe move, it returns 200 with a small JSON body.302 redirect to the game-over page.That difference, 200 versus 302, is one bit of the secret we are not supposed to see. We cannot read it. But we do control the bot's browser, and we can make it navigate a chain of redirects that we build, as long as the chain ends back on the challenge origin so the final-URL guard is happy.
And browsers have a hard ceiling on how many redirects they will follow in a single navigation. For Chromium specifically, that number is a very exact twenty, no more, before it gives up with a net::ERR_TOO_MANY_REDIRECTS error. That ceiling is the oracle.
The idea: send the bot to a redirector we control. Its first request already burns one redirect just getting into the chain, then it bounces off itself eighteen more times, then sends it at the admin play endpoint as the last hop. That lands you at exactly twenty, right at Chromium's limit.
200, the chain finishes just under the limit. The bot survives, its final URL is still on the challenge origin, and everything is fine.302, that redirect is one hop too many. The browser trips its "too many redirects" limit and the navigation dies.So "the bot survived" versus "the bot errored out" maps exactly onto 200 versus 302, which maps onto the admin's hidden move. We turned a status code we are forbidden to read into a survive-or-die signal we can observe, and unlike the rationed hint, we can do it as many times as we like.

The redirector is tiny:
# /r?n=0&total=18
# Climb from n to total, bouncing off ourselves each time, then hand
# the browser to the real target as the final hop.
if n < total:
self.send_header('Location', f"/r?n={n+1}&total={total}")
else:
# last hop -> the admin play endpoint (back on 127.0.0.1:6969)
# endpoint 200 -> chain finishes under the limit -> bot survives
# endpoint 302 -> one hop too many -> "too many redirects", bot dies
self.send_header('Location', target_url)
A 200 lands right under the browser's ceiling, a 302 pushes one past it, and you have a clean, repeatable leak with zero ambiguity: it is always exactly twenty or twenty-one, never in between. Here's the bot actually hitting that wall mid-run:
This whole trick, learning a secret you are not allowed to read by watching how the browser behaves around it, redirect counts, errors, timing, is a family of bugs called cross-site leaks (XS-Leaks). They are subtle, genuinely under-appreciated, and I think beautiful, which is exactly why I built a challenge around one.