elementalsouls/hunt-jwt-crypto
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.
npx skills add https://github.com/elementalsouls/Claude-BugHunter --skill hunt-jwt-crypto
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. Setalg:"none", drop the signature, edit the payload (e.g. role:"admin",
another user's id/email). A broken verifier skips signature checking.
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.
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.
Use a purpose-built tool so encoding/signing is correct: jwt_tool
(jwt_tool <token> -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":"[email protected]","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 <token> -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.
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):
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.
/admin returns 200 (you'll see admin controls / a delete link), performthe admin action with the SAME forged token — a typical one is deleting a
target user account, e.g. GET /admin/delete?username=<victimuser> (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.
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.
user data (e.g. other users' emails) in the response.
alg:none rejected (401) just means that flaw is patched — try key confusionbefore concluding the app is safe.
Take elementalsouls/hunt-jwt-crypto from the repository into ~/.claude/skills for personal
use, or into .claude/skills inside a project.
The agent identifies a skill by the name field in its header. Two skills with the
same name cannot sit side by side — one of them will be ignored.