401 Authorized Beta: HKCERT CTF 2026 writeup (three broken guards)
CTF or authorized-lab work only: no real-world targets, credentials or findings. Original in GrantZero604/security-writeups on GitHub, CC BY 4.0.
TL;DR. Three bugs from one contest, none of which needed a clever payload. A one-time-code gate returns its continuation token on the failure path, so a deliberately wrong code opens the door. A roulette wheel publishes its own generator’s parameters and calls it a fairness guarantee, so four spins in that run pin the state and the rest are predictable. And a login filter that blocks quotes in the username never sees one, because the database driver produces the quote at a later stage than the filter inspects.
→ Read the interactive edition — three broken guards you can poke at in the browser, each with a hint ladder before the explanation. This page is the static fallback.
The idea in plain terms
Section titled “The idea in plain terms”Three locks, each built to check the wrong thing. The first hands you the key whether or not you get the code right. The second prints the recipe for its own randomness next to every result. The third refuses quote marks in your username, then lets the database library add one after the check has already run. Each one is a guard looking in the wrong place at the wrong moment.
The target
Section titled “The target”The contest was a single challenge, a downloadable Unity game plus the web infrastructure behind it, scored by in-game achievement: about 135 of them, each priced inversely to how many teams held it. Final rank 116 of 1,117 scored teams, 59th in the International category, with 1,640.02 points across 28 achievements. I played it together with an AI agent, which did much of the hands-on work.

Final standing, from the organizers’ public scoreboard, captured 8 November 2026.
Pricing by rarity means points decay: my score peaked a little above 2,000 on the first afternoon and slid to 1,640 as more teams solved the same achievements. The table shows the spread. “Classic Injection”, the SQL bug below, was solved by 69 teams and paid 99.10 points. “Instruction-Following Deviation”, solved by 597, paid 5.40.
All 28 achievements with solve counts and points
Flags were not submitted to the contest platform. Each one was a promo code, redeemed on the game’s own website, which unlocked the matching achievement. Finding that out cost real time: we had five flags in hand and nowhere to put them.
One more thing shaped everything after it: the game client shipped server code. The server’s own implementation of the vault’s key scheme sat inside the downloaded build as dead code, and one bundled challenge shipped the hashes of its own answers. Reading what the client was given turned guesses into checks.
What makes these three worth writing up is that none of them look like attacks on the wire. There is no malformed request and no signature for a scanner to match. Each was found by reading what the server said rather than by sending it something exotic.
The gate that hands out its own key
Section titled “The gate that hands out its own key”A service protects an action behind a six-digit one-time code. Five attempts. No code in hand.
The natural reading is “guess the code”, and that reading cost us a day. We submitted exactly two codes during the whole contest, both deliberately wrong, because guessing was never the bug.
Every request to this flow came back 401 with the same message:
{"code":401,"message":"OTP expired"}That reads like a timing problem, and a timing problem invites you to go faster. It was not one. The first call sets an HttpOnly cookie, and early attempts dropped it, so every follow-up arrived with no session at all. Here, “expired” meant “you have no session”, not “your session is too old”.
Carry the cookie and the endpoints become talkative:
1) POST /api/otp {} -> 201 otp_sid=xxxxxxxx... stage=pending attempts_left=5
2) CONTROL POST /api/finish {"token":"ffff...ff"} (64 f's, same shape) -> 401 {"error":{"message":"OTP expired"},"result":{"token":""}}
3) CONTROL GET /api/dashboard -> 401 same body
4) POST /api/verify-otp {"code":"123456"} <- deliberately WRONG -> 200 {"success":false, "error":{"code":401,"message":"denied"}, "result":{"token":"xxxxxxxx...xxxx","verdict":"ZGVuaWVk"}}
5) POST /api/finish {"token":"xxxxxxxx...xxxx"} -> 200 {"result":{"stage":"granted"}}There are two code paths, success and denial, written by someone thinking about the verdict. The verdict is correct, and that is the part that got tested. But both paths return through the same response builder, and it attaches the continuation token unconditionally. The token is not a reward for passing; it is just part of the response’s shape. So the denial path hands out the thing the success path was guarding.
Steps 2 and 3 are the controls, and they ran on a session minted seconds earlier, before anything was mutated. A control run against state your own testing has already changed proves nothing. A same-shaped random token is rejected and the dashboard is shut, so the grant in step 5 came from the leaked token and nothing else. After the grant, the session’s remaining lifetime jumps from about 290 seconds to 1,796.
Take it to a real target: on any multi-step gate (OTP, password reset, payment confirmation), submit a deliberately wrong answer and read the whole failure response, not the verdict. If it carries a token, an id or a next-step URL, the two paths share machinery and the gate may be ornamental.
The casino that publishes its own generator
Section titled “The casino that publishes its own generator”The in-game roulette attaches a fairness code to every result, the way real gambling sites publish a commitment you can verify afterwards. One result line, to show the format:
RESULT seq=1 win=7 fair=LCG16/0x6255/0x3619/MOD37 sel=7 bet=4 stake=1 payout=36 chips=53Except it is not a commitment. A commitment is opaque before the event, typically a hash of the seed, revealed afterwards so you can check it. This string is the generator itself, in clear: a 16-bit linear congruential generator, with the winning number taken as s' mod 37.
s' = (s * 25173 + 13849) mod 65536winning_number = s' mod 37A 16-bit state has only 65,536 possibilities, so you try them all offline against the spins you watched and the candidate set collapses. In our run it went 1,771 → 48 → 4 → 1, four spins. How many it takes depends on the numbers drawn: across all 65,536 starting states, 3 spins for about 1 in 20, 4 for about 3 in 4, 5 for about 1 in 5, never more. Then 12 consecutive exact predictions, every one a hit. (The result line above is only there for its format; it is not the run that collapsed. A first spin of 7 leaves 1,772 states, because 65,536 = 37 × 1,771 + 9.)
The prediction script read the parameters out of the first fairness code it saw instead of hardcoding them. Had the server rotated its constants, a hardcoded script would have mispredicted silently and we would have blamed the theory. Hardcoding a value you were handed turns a change in the target into a wrong answer rather than an error.
Write it yourself
Section titled “Write it yourself”The whole attack is one loop. Try every possible starting state, replay the spins you watched, and keep the states that would have produced exactly those numbers:
def candidates(observed, a=25173, c=13849, m=65536, pockets=37): """Every state the generator could be in after the spins you saw.""" survivors = [] for seed in range(m): # 65,536 tries: instant on any laptop s, ok = seed, True for win in observed: s = (s * a + c) % m # one spin advances the state if s % pockets != win: # this seed predicts a different number ok = False break if ok: survivors.append(s) # the state right after the last spin return survivors
# one survivor left? then the next spin is (s * a + c) % m % 37Feed it the observed numbers one at a time and print len(candidates(observed)). When one state is left, you know every spin that follows.
One negative, with its method attached: the blackjack deck is not drawn from the same stream. We generated the LCG output from the pinned seed and tested it against three fully known decks under four Fisher-Yates variants and six stream skips, 24 combinations, no match. That is blind to any other shuffle or a per-hand reseed, and blackjack publishes no fairness code, so the shuffle had to be guessed.
Take it to a real target: for anything labeled “provably fair” or carrying a seed, nonce or fairness code, ask one question. Is this a commitment, or is it the generator? If you can read parameters out of it, the randomness is a public function.
The filter that never sees the quote
Section titled “The filter that never sees the quote”A deprecated auth service still mounted on the live site held this handler, leaked verbatim in a forum post on the site itself. Reading the site’s own forum was the most productive recon of the contest.
sql = f"SELECT username, password FROM users WHERE username = '{username}' AND password = %(password)s"cursor.execute(sql, {"password": password})A later reply in the same thread described the validation: both fields must be strings of at most 64 characters, and a ' or \ in the username returns invalid_username. So the obvious injection point is filtered.
The developer saw a parameterized query and stopped worrying. There is a parameterized argument here, and it is the normal, intended path. The mistake is that two substitution mechanisms run over the same string, one after the other, and the validation only observes the first.
- The filter inspects the username. No quote, no backslash. It passes.
- The f-string pastes the username into the query text. Whatever it contained is now part of the SQL.
- PyMySQL’s
cursor.executeperformsquery % escape_args(args)over the whole string, the part that came from the username included, and substitutes the password escaped and wrapped in quotes.
So a %(password)s placed inside the username is substituted a second time, and the library produces the quote. The filter never sees one, because at the moment it looked, there wasn’t one.
| probe | response |
|---|---|
| plain wrong credentials | 401 invalid_credentials |
| the payload, with a condition that is false | 401 invalid_credentials — the control |
| the same payload, with a condition that is true | 200, logged in as admin |
The false control is the reason this is a finding rather than a guess. Same shape, same length, same fields, same run; only the truth value of the injected condition differs, and only one of them logs in.
Take it to a real target: when you see an f-string or + concatenation into SQL and parameterized arguments on the same execute() call, the real parameterization is what makes it look safe in review. A filter protects the stage it runs in. If a later stage can introduce the character you filtered, you did not filter it. The same shape appears in template engines, log formatters and shell builders, anywhere two formatting passes run over one string.
The rest of the contest
Section titled “The rest of the contest”The qualifier had more in it than the three guards. One line each, because each teaches a single idea:
- An HTML sanitizer beaten by
String.replace. The replacement argument is a small template language ($&means “the text that matched”), so it copies unescaped parts of the page into the output after the escaping has run. It was only exploitable on the second render, which an offline replica of the real sink showed and my reasoning had not. Test the output after every later stage. - Two ciphers and a disk image. Flags hidden in hieroglyph and cuneiform substitution ciphers fell to the known flag prefix as a crib, one helped by a pangram and the other by dictionary word patterns. The disk image’s traps were a timestamp read in the wrong timezone and a one-bit-per-pixel image. A tool’s silence is a claim, not a fact.
- Trusting the client. A room allow-list that only the client enforced, and a mailbox service that answered for any user id. The control for the second was my own id returning my own data. I did not read other teams’ mail. Authorization checked only in the client is not authorization.
- Two I am only naming. A chat-bot URL fetcher with a string-matching guard, and a path filter plus an unbounded read in one room. The lessons are to resolve names before checking them, and to apply a rewriting filter until nothing changes. I leave out the steps: one was a working attack chain against a cloud account.
Dead ends: the two that never fired
Section titled “Dead ends: the two that never fired”Often the most useful part of a record: complete, correct, self-tested work that scored nothing.
An RSA break on the in-game vault. The primes were a pure function of data the server itself broadcasts, so a candidate prime could be checked for free: if it divides the public modulus, it is right. Both implementations were correct, and it was blocked permanently on 1,000 units of in-game gold that only a boss drops. Nobody on the team beat that boss. Check the precondition before you build the exploit.
Arbitrary JavaScript in an admin view. Confirmed executing, but the challenge wanted something exfiltrated, and that needed an external listener nobody built. Decide the exfiltration channel before you spend hours proving execution.
Flags were promo codes redeemed in the game, each unlocking an achievement that carried the points. The flag strings are withheld here.
Impact
Section titled “Impact”On a real system each of these is a full authentication or integrity failure:
- The gate: anyone who can reach the verification endpoint completes the protected action without the code, so the second factor is decoration.
- The wheel: a player who reads one result can predict every later one, which turns a game of chance into a withdrawal mechanism.
- The filter: an unauthenticated visitor logs in as an administrator, which is the whole application.
What bounds them: the first two need the flow or the fairness string to be reachable, and the third needs the source (here, a leaked handler) to find in the first place.
Remediation
Section titled “Remediation”- Build the denial response separately from the success response, with no token in it, and have the final endpoint check that the session reached the verified stage rather than only that a token matches.
- Bind every value in one parameter stage:
WHERE username = %(username)s AND password = %(password)s, so no caller-supplied text is ever part of the query string that the driver then substitutes over. - Publish a commitment, not the generator:
sha256(serverSeed)before the round, reveal the seed after it, and derive results from an HMAC over the server seed, a client seed and a nonce instead of a 16-bit LCG.
Takeaways
Section titled “Takeaways”- Validate the oracle before trusting its silence. An earlier pass recorded “no flag-shaped string in the shipped client” from an ASCII regex. Two flags were sitting in files written in Egyptian hieroglyphs and cuneiform, and the search had only ever been tested where the flag was ASCII, so its silence carried no information. Search instead for the flag prefix in UTF-16, base64 and hex, and count which writing systems appear in each file. Run every search tool once on something you know is there.
grepmisses .NET string literals, which are UTF-16, until you usestrings -el. - Controls go on fresh state. A control run against state your own testing has already mutated can confirm a guard that is actually broken.
- Build against a replica. A local database running the real handler, and an offline copy of a JavaScript sink, meant zero wasted requests against a shared rate limiter and let payloads be iterated hundreds of times offline. With several agents behind one IP, request budget was the scarcest resource in the contest.
Live host names, full flag strings, cloud account identifiers and recovered credentials are removed throughout. Published after the qualifier closed. All testing was against the contest’s own infrastructure during the event, or against local copies. Thanks to the HKCERT organizers for building it.
grantzero · HKCERT CTF 2026 · “401 Authorized Beta”