Platform: gaslightCTF
Challenge: messageboard
Category: Web
Difficulty: Medium
Tags: sqli order-by-injection blind boolean-oracle postgres bun
Solved by 0xAdham.
messageboard is gaslightCTF's social stories app — post a short public update, share something with your close friends, scroll the feed. The dev hardened the query layer properly: every user-supplied value is run through an alphanumeric-only filter() before it touches SQL, and sql.unsafe is the only query helper in the codebase. No parameters, no ORM — just "trust the whitelist." The flag sits in admin.close_friends, behind a list you're not on. The gaslight is the whitelist itself: they forgot that alphanumerics are exactly enough to name a column.
TL;DR
This challenge chains two steps:
- ORDER BY injection → turn the public feed's sort order into a blind boolean oracle on
secret - Binary-search the oracle → recover admin's 16-hex-char password, log in as admin, read
close_friends
Chapter 1: Reading the Source
The Stack
Stack (from the handout): Bun.serve() routes + Postgres via Bun.sql. The query helper is a single export:
Raw string execution, no parameters, every query is a template literal. That's the attack surface.
The Schema
Seed notes: admin.close_friends = FLAG, admin.close_friends_list = ['alice','carol','dave'], and admin has a public story. That last part matters.
The Feed Endpoint
GET /api/stories has two query branches — one for the public feed, one for close friends:
column and order both come from query params, both pass through filter(), and both land directly in the ORDER BY clause — no quotes, no parameterization. That's our injection point.
The "Sanitizer"
Pure [A-Za-z0-9]. Applied to every input that reaches SQL — name, password, friends[], column, order. No quote ever gets through, so classic string SQLi is dead everywhere a value sits inside '...'. The session name only ever comes from a filtered signup or a DB-stored name, so that's clean too.
The gaslight: the devs thought "no quote = no SQLi." They were right about string contexts. They forgot about identifier contexts.
Chapter 2: The Bug — Alphanumeric Is Enough in ORDER BY
column and order don't live inside quote marks — they're in an identifier/expression context. In SQL, column names are alphanumeric. [A-Za-z0-9] is exactly the charset you need to name a real column.
Confirm injection:
The 500 on boguscol is the proof. The SQL error means our value hit the parser — not a sanitizer. We have injection.
What We Can (and Can't) Do
The whitelist shapes the exploit pretty tightly:
| Blocked char | Rules out |
|---|---|
_ | Can't name close_friends, close_friends_list, close_friends_expiry |
spaces, (, ), ,, quotes | No subqueries, no CASE, no UNION, no stacked queries |
The only underscore-free columns in users are name, secret, and public.
The flag column (close_friends) is only reachable by its alias story or positional 3 in the close-friends branch — but admin never appears in that result set for a non-friend. Dead end for a direct read.
So the injection can't print the flag. But it can do something else: ORDER BY secret reorders the public feed (which does include admin, because admin has a public story) by each row's secret value. The JSON response order equals the sort order equals a boolean comparison oracle on secrets.
Chapter 3: Blind-Extracting admin's Secret
admin.secret is generated as crypto.getRandomValues(8 bytes).toHex() — 16 lowercase hex characters.
The Oracle
Register pivot accounts with a secret (password) I choose, give each one a public story so it appears in the feed, then ask:
In
ORDER BY secret ASC, does admin sort after my pivot?
For a known prefix P and candidate digit d, a pivot with secret = P + d satisfies (lexicographic order): admin.secret > P+d ⟺ admin.secret[i] >= d.
So: admin sorts after the pivot ⟺ the next unknown character ≥ d — a clean binary search.
The Script
4 comparisons per character × 16 characters = ~64 pivot registrations. Each one posts a public story and fires a single sort query. The feed position does the rest.
Result:
Chapter 4: Reading the Flag
With the secret in hand, log in as admin:
The close-friends query has an OR name = '${session}' branch, so admin sees admin's own close-friends story without needing to be on anyone's list:
Flag
Mitigations
Allowlist column and order against a fixed set
The root cause is treating column and order as free-form alphanumeric input rather than as a finite enumeration of known-safe values. The fix:
This collapses the attack surface to zero — there's no valid column left that can leak anything sensitive, and no way to inject an arbitrary identifier.
Parameterize everything else
sql.unsafe with string interpolation is dangerous by default. Use parameterized queries for every value, and reserve identifiers (column names, table names) for the allowlist approach above — those can't be parameterized at the driver level anyway, so allowlisting is the correct control.
Don't expose sort order as a side channel
Even with the injection closed, sorting a feed by a private column (secret) and reflecting the result order is a side-channel leak. If secret is a password, it has no business being a sortable column on a public endpoint. The public feed should expose only columns safe to order by — name and public here, not secret.
0xAdham
