--- name: hunt-jwt-crypto description: "Hunt JWT cryptographic failures — alg:none signature-stripping and RS256→HS256 key-confusion that let an attacker forge a token for any identity (e.g. an admin) without knowing a secret. Use when the app authenticates with a JSON Web Token (an `eyJ...` Bearer token in the Authorization header, a cookie, or a login response). This skill OWNS JWT signature/crypto forgery (alg:none, key confusion, kid/jku header injection); hunt-ato covers JWT as one ATO path, hunt-auth-bypass covers SSO/SAML token trust, hunt-api-misconfig covers non-crypto JWT handling. Critical when a forged token grants access to another user's data or an admin-only endpoint." sources: hackerone_public --- # HUNT-JWT-CRYPTO — Forgeable JSON Web Tokens (A04 Cryptographic Failures) ## What actually pays A JWT is `header.payload.signature`, each base64url. The signature is the only thing stopping you from editing the payload (your identity/role) and replaying it. It pays **High/Critical** when the verifier can be tricked into accepting a token you forged — so you become another user or an admin without their secret. Two classic, generic verifier flaws: - **`alg:none`** — the verifier trusts the token's own `alg` header. Set `alg:"none"`, drop the signature, edit the payload (e.g. `role:"admin"`, another user's `id`/`email`). A broken verifier skips signature checking. - **RS256 → HS256 key confusion** — the token is signed RS256 (asymmetric). The RSA **public** key is, by definition, public. If the verifier lets you choose HS256, it will use that public key as the HMAC *secret* — which you also know. Sign an edited payload with HS256 using the public key and it validates. ## Recon — is this app JWT-based? ``` Login/token responses containing "token":"eyJ..." or Set-Cookie: token=eyJ... Authorization: Bearer eyJ... on authenticated requests A JWKS / public-key endpoint: /.well-known/jwks.json, /jwks, public-key in the JS bundle ``` Decode the header (base64url the first segment). `"alg":"RS256"` → try key confusion. Any alg → always try `alg:none` first; it's free. ## Forging the token (never hand-encode base64 — use a JWT tool) Use a purpose-built tool so encoding/signing is correct: **jwt_tool** (`jwt_tool -T` to tamper interactively, `-X a` for alg:none, `-X k -pk public.pem` for key confusion), Burp's **JWT Editor** extension, or a few lines of **PyJWT**. Each forge below is the concept plus the claim to edit. **alg:none — become admin / another user** ``` header: {"alg":"none","typ":"JWT"} payload: {"data":{"id":1,"email":"admin@target.example","role":"admin"}} signature: (empty — keep the trailing dot: header.payload. ) ``` Some verifiers reject lowercase `none` but accept `None`/`NONE`/`nOnE` — try case variants. **RS256 → HS256 key confusion — once you have the RSA public key** ``` 1. Obtain the server's RSA public key as PEM. Sources: /jwks.json or /.well-known/jwks.json (convert the JWK to PEM), a public-key file in the JS bundle, or recover it from two captured tokens (e.g. jwt_tool / rsa_sign2n). 2. Re-sign an EDITED payload with HS256, using that PEM as the HMAC secret: jwt_tool -X k -pk public.pem payload edit: {"sub":"administrator"} (or role:"admin" / another user's id) ``` **kid header injection — verifier loads the HMAC key from a FILE named by `kid`** ``` header: {"alg":"HS256","kid":"../../../../../../../dev/null"} secret: "" (contents of /dev/null = empty string → sign HS256 with an empty secret) payload: {"sub":"administrator"} ``` Traverse out of the keys directory first. `kid` can also carry SQLi / command injection / SSRF if the key lookup hits a DB / shell / URL — same idea: `kid` is attacker-controlled and reaches a dangerous sink. **jku / x5u header injection (RS256) — verifier fetches the public key from a URL in the token** ``` 1. Host a JWKS containing a public key you control, on a server the verifier can reach. 2. Set the token's `jku` (or `x5u`) header to that URL and sign the edited payload with YOUR matching private key. 3. If the verifier allowlists jku hosts, chain an open-redirect or SSRF-reachable path on the target's OWN domain so the fetch resolves to your JWKS. ``` Match the `payload` shape to a REAL token from the app (decode one first) — keep its claim names, only change identity/role. A payload the app can't parse fails for the wrong reason and wastes the attempt. ## Drive to the ADMIN objective — do not stop at a working forge A forge that loads YOUR own `/my-account` is NOT the goal — it just proves the forge mechanism works. The objective is almost always **admin** (reach an admin-only page and perform an admin action, e.g. delete a user). Once any forge is accepted, IMMEDIATELY escalate — change identity to admin AND aim at the admin endpoint. Do not keep re-forging `/my-account` or re-logging-in; that is drift. Fixed escalation sequence (run it in order, do not loop on earlier steps): 1. Forge admin identity and hit the admin page (try these claim names — match a decoded real token: `sub`, `role`, `isAdmin`, `username`), e.g. an HS256 token with `kid` pointed at `/dev/null` and an empty secret, payload `{"sub":"administrator"}`, sent to `GET /admin`. 2. When `/admin` returns 200 (you'll see admin controls / a delete link), perform the admin action with the SAME forged token — a typical one is deleting a target user account, e.g. `GET /admin/delete?username=` (some apps use `POST /admin/delete` — read the admin page for the exact form/verb). A 401 on `/admin` means the forge/claim is wrong — change ONE thing (the kid depth, the claim name/value, or alg) and retry `/admin`. Never retreat to a bare unauthenticated `GET /admin` (no token) — that always 401s and wastes effort. ## Proof of impact Point the forged token at a protected/admin endpoint and prove you read data you should not: an account/user listing (multiple users' emails), another user's object, or a completed admin action (the deleted-user confirmation). Reading the admin user list or performing the admin action with a forged token IS the exploit. A 200 that returns only your own data, or a 401, is not proof. ## Validation discipline - Decode and confirm the token you sent actually carries the edited claims. - The win is **cross-identity data access**, not merely a 200. Show the foreign user data (e.g. other users' emails) in the response. - `alg:none` rejected (401) just means that flaw is patched — try key confusion before concluding the app is safe.