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


STEPS
  1. Mint a token from the token endpoint and decode its claims - identity is anonymous
  2. Probe alg:none and a garbage signature - identical rejections confirm the algorithm is pinned
  3. Accept that the real surface is the HS256 key itself, not the algorithm
  4. Verify candidate secrets offline: HMAC-SHA256 over header.payload against the captured signature - no server traffic
  5. Run a real wordlist through a fast HMAC checker (or john's HMAC-SHA256 format) until one validates
  6. Flip identity to admin in 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. 1. Harvest a known-good token. get_token hands you one. Its signature was

produced by the key you're hunting.

  1. 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.

  1. 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.

  1. 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

Popular Posts