Three broken guards
A gate that hands out its own key, a casino that publishes its own generator, and a filter that never sees the quote it is looking for.
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.
How to read this page. Each section gives you a broken guard before it explains anything. Poke at it, form a theory, then open the hints one at a time. The explanation comes after — because a mechanism you worked out yourself is a mechanism you keep.
Three guards, in this order:
- The gate that hands out its own key: a one-time code you never need to guess.
- The casino that publishes its own generator: randomness you can read.
- The filter that never sees the quote: a check that runs too early.
Each one: try it, open the hints if you are stuck, then read why. Everything runs in your browser against a model of the bug; nothing contacts a live service, and hosts and secrets are removed from the captured transcripts.
The contest was a single challenge: a downloadable Unity game plus the web infrastructure behind it. I played it together with an AI agent, which did much of the hands-on work. These three bugs came out of it, and none of them needed a clever payload. Each one is a guard that was written to check the wrong thing. It is a common shape for real findings, and one that signature-based tools tend to miss, because nothing about the traffic looks malformed.
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 gate that hands out its own key
A service protects an action behind a one-time code. You have no code. You have five attempts, and the code is six digits.
Before you touch anything: what is the win condition you are looking for? Write it down. The natural reading is “guess the code”, and that reading cost us a day.
Hint 1
A wrong code is not nothing. The server still has to answer you, and an answer has a body as well as a verdict. Read the body.
Hint 2
Two fields disagree with each other. One says you failed. The other one is not shaped like a failure at all.
Hint 3
The next endpoint in the flow takes a token. You were just handed a token. Nothing checked whether you earned it.
Check your answer
The win condition is a token that /api/finish accepts, not the code. Anything that hands you a valid token wins, including a response to a wrong code.
What the developer assumed
There are two code paths here, success and denial, and they were written by someone who was thinking about the verdict. The verdict is correct: a wrong code produces success:false and a denied message. That is the part that got tested.
But both paths return through the same response builder, and that builder attaches the continuation token unconditionally. The token is not a reward for passing; it is just part of the shape of the response. So the denial path hands out the thing the success path was supposed to be guarding.
4) POST /api/verify-otp {"code":"123456"} <- deliberately WRONG
-> 200 {"success":false,
"error":{"code":401,"message":"denied"},
"result":{"token":"xxxxxxxx...xxxx",
"verdict":"ZGVuaWVk"}} <- base64 "denied", and the token anyway
5) POST /api/finish {"token":"30f2f2...de36"}
-> 200 {"result":{"stage":"granted"}}
We submitted exactly two codes during the whole contest, both deliberately wrong. Five attempts against a million codes is a 1-in-200,000 chance, and guessing was never the bug.
Why the controls had to run first
A result like this is worth nothing until you have shown that the token is what caused the grant. Two controls, both run on a session created seconds earlier — before anything was mutated:
| Control | Response | What it rules out |
|---|---|---|
POST /api/finish with a same-shaped random token (64 hex chars) | 401 | that /finish accepts anything |
GET /api/dashboard before the grant | 401 | that the session was already open |
The order matters as much as the controls themselves. A control run against state your own testing has already changed proves nothing at all. These ran on a session minted seconds earlier, which is why they are evidence rather than decoration. The server confirmed the grant from its own side too: after it, the session's remaining lifetime jumped from about 290 seconds to 1,796.
What sent me the wrong way first
Every request to this flow, on four different routes, 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 is not one. The first call sets an HttpOnly cookie, and early attempts read the response body without carrying the cookie jar into the next request — so every follow-up arrived with no session at all and hit the catch-all handler.
Here, “expired” meant “you have no session”, not “your session is too old”. Carry the cookie jar and the same endpoints become talkative.
Reach for this when
Any multi-step gate — OTP, password reset, payment confirmation, email change. Submit a deliberately wrong answer and read the entire failure response, not the verdict. If the failure body carries a token, an id, a redirect or a next-step URL, the denial path and the success path share machinery, and the gate may be ornamental. And if every route answers identically, suspect a dropped cookie before you suspect a timer.
The fix
// broken: one builder, token attached unconditionally
return respond({ success: ok, error: ok ? null : DENIED, result: { token: session.token } });
// fixed: the denial path cannot reach the token
if (!ok) return respond({ success: false, error: DENIED, result: {} });
session.stage = 'verified';
return respond({ success: true, result: { token: session.token } });
The casino that publishes its own generator
An in-game roulette wheel attaches a fairness code to every result, the way real gambling sites publish a commitment you can verify after the fact. 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=53
A commitment is a promise you cannot read until afterwards. Look at that string again. Is it a commitment?
Hint 1
Each field in that fairness string is a number the generator needs in order to run. Not a hash of them. The numbers themselves.
Hint 2
LCG16 means a 16-bit linear congruential generator: the next state is s' = (s × a + c) mod 65536, and the winning number is s' mod 37, because the wheel has 37 pockets. You have a and c. The only unknown is s.
Hint 3
65,536 is a small number. You do not need to be clever — you can try every one of them offline and keep the ones that match what you have already seen.
Check your answer
No. A commitment is opaque until afterwards, usually a hash of the seed. This string is the generator itself: multiplier, increment and modulus. Anyone can read them and run the generator.
What the developer assumed
Provably-fair gambling works like this: before the spin, the server publishes a hash of its seed. After the spin, it reveals the seed, and you check that the hash matches. The hash commits the server to a value it cannot change afterwards, while telling you nothing in advance.
Here the string is not a hash of anything. It is the generator, in clear: multiplier 0x6255 (25173), increment 0x3619 (13849), modulus 65,536, and the winning number is s' mod 37. The developer published the recipe and called it a receipt.
A 16-bit state has only 65,536 possibilities, so you brute-force them offline against observed spins and watch the candidate set collapse. In the real run it collapsed: 1,771 → 48 → 4 → 1 — four spins were enough in that run. 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. The first figure is 1,772 instead of 1,771 whenever the first number drawn is 0 to 8. Press Observe one spin above until the count reaches one. Then 12 consecutive exact predictions, every one a hit.
A note if you check the arithmetic: the result line near the top of this section is shown for its format, and it does not line up with the recorded collapse. A first spin of win=7 leaves 1,772 states, not 1,771, because 65,536 = 37 × 1,771 + 9, so numbers 0–8 each have one extra state.
The detail that makes this a method rather than a trick
The prediction script deliberately does not hardcode the parameters. It parses them out of the first fairness code it sees:
mt = re.match(r"LCG(\d+)/0x([0-9a-fA-F]+)/0x([0-9a-fA-F]+)/MOD(\d+)", res.fairness_code)
bits = int(mt.group(1))
params = (int(mt.group(2),16), int(mt.group(3),16), 1 << bits, int(mt.group(4)))
If the server had rotated its constants mid-session, a hardcoded script would have silently mispredicted and I would have blamed the theory. A parsing script re-derives them and keeps working. Hardcoding a value you were handed turns a change in the target into a wrong answer instead of an error.
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 % 37
Feed it the observed numbers one at a time and print len(candidates(observed)): you will see the same collapse the widget shows. When one state is left, you know every spin that follows.
The negative, with its method attached
The same work produced a negative worth recording, because it is the kind usually asserted rather than tested: the blackjack deck is not drawn from the same stream. The LCG output was generated from the pinned seed and tested against three fully known decks, under four Fisher-Yates variants × stream skips 0–5 — 24 combinations, no match. Blind to any other shuffle algorithm or a per-hand reseed.
The blackjack events publish no fairness code, so there were no parameters to read and the test had to guess the shuffle. An absence claim is only worth what its method is worth, so the method goes inside the claim.
Reach for this when
Anything labeled “provably fair”, “verifiable”, or carrying a seed, nonce or fairness code in a response. Ask one question: is this a commitment, or is it the generator? A commitment is opaque before the event. If you can read parameters out of it, the randomness is a public function and the state space is usually small enough to search.
The fix
// broken: the generator's parameters are the receipt
emit(`LCG16/${hex(a)}/${hex(c)}/MOD37`);
// fixed: commit before, reveal after
const serverSeed = randomBytes(32);
emit({ commit: sha256(serverSeed) }); // before the spin
// ... after the round closes:
emit({ reveal: serverSeed, clientSeed, nonce }); // player verifies sha256(reveal) === commit
// and the result is derived from HMAC(serverSeed, clientSeed || nonce), not from a 16-bit LCG
The filter that never sees the quote
Here the model below is a visualization rather than something you drive: it steps through the mechanism rather than letting you build inputs.
A deprecated auth service was still mounted on the challenge's live site, and its login handler had been posted verbatim in that site's own forum, which made reading the forum 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})
%(password)s is a named placeholder. When execute() runs, the driver replaces every %(password)s it finds anywhere in the string with the escaped password. That is the fact the whole bug turns on.
Validation, from a later reply in the same thread: 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 username cannot contain a quote. The query needs a quote to break out of. Where else could a quote come from?
Hint 1
Count the substitutions. The f-string is one. Is it the only one?
Hint 2
cursor.execute(sql, args) in PyMySQL performs query % escape_args(args) — a second %-substitution over a string the f-string has already finished building.
Hint 3
The filter inspects the username before the first substitution. Which stage runs last, and what does the driver's escaping wrap its value in?
Check your answer
The database driver. Its %-substitution runs after the filter has finished and wraps the value it inserts in quotes, so the quote appears at a stage the filter never inspects.
What the developer assumed
The developer saw a parameterized query and stopped worrying. There is a parameterized argument here — %(password)s is escaped and interpolated by the driver, which is the normal, intended path. The mistake is that the two mechanisms run over the same string, one after the other, and the validation only observes the first one.
Order of operations:
- 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 runs
query % escape_args(args)over the whole string — including the part that came from the username — and substitutes the password, escaped and wrapped in quotes.
So a %(password)s placed inside the username gets 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.
The 64-character cap per field did not stop the attack either: because the substitution rejoins text from both fields into one string, the usable length is larger than either field. The details of that are left out here on purpose.
The probes, with the false control in the same run
| Probe | Response |
|---|---|
| plain wrong credentials | 401 invalid_credentials |
| the two-part 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 whole 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. Without that row, a 200 could be anything.
Reach for this when
You see an f-string or + concatenation into SQL and parameterized arguments on the same execute() call. The presence of real parameterization is what makes this invisible in review: it looks safe. Any validator that runs before a later substitution stage is blind to what that stage introduces — and the same shape appears in template engines, log formatters and shell builders, anywhere two formatting passes run over one string. Without the source: compare the response to a plain username with the response to one that contains the driver’s placeholder syntax, such as %(password)s. If the two are handled differently, a second formatting pass is running over your input.
The fix
# broken: two substitution stages over one string
sql = f"... WHERE username = '{username}' AND password = %(password)s"
cursor.execute(sql, {"password": password})
# fixed: one stage, every value bound
sql = ("SELECT username, password FROM users "
"WHERE username = %(username)s AND password = %(password)s")
cursor.execute(sql, {"username": username, "password": password})
And the lesson that outlives the bug: a filter protects the stage it runs in. If a later stage can introduce the character you filtered, you did not filter it.
The rest of the contest
The qualifier had more in it than these three guards. The static edition covers the rest, each as one short lesson:
- An HTML sanitizer beaten by
String.replace: the replacement string is a small template language, so a later stage can copy in markup the escaping never saw. - Classical ciphers and a damaged disk image: a search tool's silence is only worth what you tested it on.
- Trusting the client: a rule the game enforced only on your own machine was not a rule.
Two more, a cloud request-forgery chain and a room with a file-read bug and a memory bug, are named there and not written out, because the detail would be a working attack chain rather than a lesson.
Does the model match the recorded behavior?
Every widget on this page is a model I wrote, and a model that agrees with its own author proves nothing. The OTP rows check the model against the transcript I recorded; the published-parameter row takes its expected values from the captured fairness string rather than from the model's own constants; the rest check properties of the generator and of the walkthrough. Nothing here contacts a server. If a row disagrees, the model is wrong and this page is saying so.
Three rules this contest paid for
The bugs above are each worth one lesson. These three are worth more, because they are about method and they repeat everywhere.
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 at all. Run every search tool once on something you know is there. Plain
grepmisses .NET string literals, which are UTF-16, until you usestrings -el. Then search for the flag prefix in UTF-16, base64 and hex, and count which writing systems appear in each file.Controls go on fresh state. A control run against state your own testing has already mutated can confirm a guard that is actually broken. The OTP controls ran on a session minted seconds earlier, before anything changed.
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. The site sat behind one rate limiter keyed on source IP; with several agents behind one IP, request budget was the scarcest resource in the contest.
The two that never fired
Often the most useful part of a record. Both of these were complete, correct, self-tested work that scored nothing.
An RSA break on the in-game vault. The primes turned out to be 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. Two independent implementations, both self-testing with negative controls, both correct. Blocked permanently on needing 1,000 units of in-game gold that only a boss drops, and nobody on the team ever beat that boss. Check the precondition before you build the exploit.
Arbitrary JavaScript in an admin view. Confirmed executing in the admin view. The challenge wanted something exfiltrated; that needed an external listener, and the last hop was never built. Decide the exfiltration channel before you spend hours proving execution.
What each one would mean on a real system
Each of the three is a full authentication or integrity failure, not a hygiene issue:
- 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 predicts 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.
How you would fix all three
- 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 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.