247CTF // Flag Authoriser - Weak JWT Signing Key
247CTF // Flag Authoriser - Weak JWT Signing Key
Category: JWT, credential recovery, config-as-vulnerability
Stack: Python, Flask-JWT-Extended 3.x, HS256
- Mint a token from the token endpoint and decode its claims - identity is
anonymous - Probe
alg:noneand a garbage signature - identical rejections confirm the algorithm is pinned - Accept that the real surface is the HS256 key itself, not the algorithm
- Verify candidate secrets offline: HMAC-SHA256 over
header.payloadagainst the captured signature - no server traffic - Run a real wordlist through a fast HMAC checker (or john's HMAC-SHA256 format) until one validates
- Flip
identitytoadminin the payload, re-sign with the recovered key, and replay
1. The target
A two-endpoint API: get_token mints an access token valid for 128 requests
(and rotates the admin password while it's at it), get_flag returns the flag
only to a token whose identity is admin. Every visitor is handed a valid
token - with identity anonymous.
The app pins the algorithm: an expired or invalid token bounces to a redirect
with a flash message, and alg: none tokens are rejected outright. The whole
security model reduces to one HS256 secret.
2. Why alg: none is a decoy here
Every JWT cheat sheet starts with the none-algorithm attack: strip the signature,
set alg: none, and hope the server skips verification. This app doesn't - the
decode pins algorithms=['HS256'], so the none token fails with the same
invalid-token redirect as a bad signature. Two probes (none token, garbage
signature) return indistinguishable rejections.
That's a deliberate lesson in reading the failure surface: **when the boring
attack and the clever attack fail identically, stop iterating on algorithms.**
The algorithm is pinned; the *key* is the attack surface.
3. The real bug: the secret is a dictionary word
HS256 is symmetric. Verification is HMAC-SHA256(header + "." + payload, key).
Anyone who knows the key can mint any token - including {"identity": "admin"}.
And jwt_secret is, by deployment fact, a human word (the kind that lives in
rockyou.txt), not 256 bits of entropy.
The discovery path is a dictionary attack, and the interesting part is that you
already hold everything needed to run it:
- 1. Harvest a known-good token.
get_tokenhands you one. Its signature was
produced by the key you're hunting.
- 2. Verify candidates offline. For each candidate
k:
HMAC-SHA256(k, msg) == base64url_decode(sig). No server round trip, no
rate limit, no lockout - the attack is embarrassingly parallel and invisible
to the target.
- 3. Feed a real wordlist. The practical tool is John the Ripper
(john --format=HMAC-SHA256 token.txt wordlist) or a 20-line Python loop.
Human-chosen secrets fall in seconds to minutes.
- 4. Forge and replay. Decode the anonymous token's payload, flip
identity
to admin, re-sign with the recovered key, send it as the access cookie.
get_flag answers.
The 128-request quota on tokens is irrelevant to the attack - logins don't
consume the admin password's rotation triggers, and key recovery happens
entirely offline.
4. The deeper mechanics worth internalizing
- Claim layout matters. Flask-JWT-Extended 3.x stores identity in an
identity claim; 4.x moves it to a sub claim with JSON wrapping. When
forging against an unknown version, decode a server-minted token first and
mirror its exact claim structure - a structurally perfect forgery with the
right signature but the wrong claim name is still a 401.
- The failure-redirect leaks nothing. The custom
invalid_token_loader
bounces to the flag page with a flash message - indistinguishable from
success-at-the-HTTP-layer. Distinguish by body content, not status or
redirect.
- Key rotation won't save you. Even a "new" secret, if it's another human
word, falls to the same offline attack. The fix is entropy, not rotation.
5. Defense
- Sign with >=256 bits from a CSPRNG, stored in a secret manager - never a
config string a human chose.
- Prefer asymmetric algorithms (RS256/EdDSA) when the verifier and signer are
different parties: the verifying side holds a public key, which removes the
offline-recovery attack entirely.
- Pin the algorithm at verify time (this app did) - but treat that as hygiene,
not defense: it closes confusion, not weak keys.
Hunting pattern: HS256 + "obtain a valid token" + any hint that secrets are
static across deployments -> immediately consider offline wordlist recovery.
The attack costs nothing and succeeds at the rate humans choose bad secrets.
Comments
Post a Comment