Lab 08: JWT authentication bypass via algorithm confusion with no exposed key
1. Executive Summary
Vulnerability: JWT Signature Bypass (Algorithm Confusion) & Public Key Derivation.
Description: The application expects an asymmetric RS256 signature but fails to enforce this algorithm during verification. To exploit this (forcing the server to use its public key as an HS256 symmetric secret), the attacker needs the server’s public key. Although the server hides the key, the RSA modulus $n$ can be derived from two signed messages. Once the public key is derived offline, it is converted to a Base64 format and used to symmetrically sign a malicious JWT.
Impact: Full Account Takeover. The attacker derives the hidden public key, downgrades the cryptographic verification to a symmetric algorithm, and forges administrative tokens.
2. The Attack
Objective: Mathematically derive the hidden public key from two valid JWTs, then use it as an HMAC secret to forge an admin token.
- Reconnaissance (Collecting Signatures):
- I logged in as
wienerand captured thesessionJWT. - I logged out, logged back in, and captured a second
sessionJWT. I now had two distinct messages ($m_1, m_2$) and their corresponding RSA signatures ($s_1, s_2$).
- I logged in as
- Deriving the Public Key (The Math):
In RSA, verifying a signature involves the public exponent $e$ (usually 65537) and the modulus $n$:
\[s^e - m = k \cdot n\]- Given two signatures, we can calculate $s_1^e - m_1$ and $s_2^e - m_2$. Both results are multiples of the hidden modulus $n$. By finding their Greatest Common Divisor (GCD), we can extract $n$.
I automated this using PortSwigger’s Docker image:Bash
1
docker run --rm -it portswigger/sig2n <token1> <token2>
- The tool outputted a few mathematical candidates for the Base64-encoded X.509 public key, along with “tampered JWTs” to test which one was correct.
- Identifying the Correct Key:
- I sent a request to
/my-accountand replaced my session cookie with each tampered JWT provided by the script. - The key that returned a
200 OKresponse was the correct X.509 public key.
- I sent a request to
- Token Forgery and Exploitation:
- In Burp’s JWT Editor Keys tab, I created a New Symmetric Key.
- I replaced the
k(key) value with the Base64-encoded X.509 key I verified. - I sent the
GET /adminrequest to Repeater. - In the JWT Editor, I changed the
algheader toHS256and thesubclaim toadministrator. - I clicked Sign, selected my derived symmetric key, and checked Don’t modify header.
- I submitted the forged token, successfully accessing the admin panel and deleting the user
carlos.
3. Code Review
Vulnerability Analysis (Explanation):
The core vulnerability remains identical to Lab 07. The backend code provides the public key to the verification library but fails to lock the parser to the RS256 algorithm. The attacker’s ability to derive the public key offline is a known property of RSA and not a vulnerability in itself; the flaw is the algorithm confusion that allows that public key to be misused as a symmetric HMAC secret.
Java (Spring Boot / JJWT)
1
2
3
4
5
6
7
8
public Claims parseToken(String token, PublicKey serverPublicKey) {
// VULNERABLE: The parser relies entirely on the 'alg' header of the incoming token.
return Jwts.parserBuilder()
.setSigningKey(serverPublicKey)
.build()
.parseClaimsJws(token)
.getBody();
}
Technical Flow & Syntax Explanation:
setSigningKey(serverPublicKey): The developer correctly supplies the public key for verification.- Missing
requireAlgorithm: Because the algorithm is not pinned, the JJWT library reads the attacker’salg: HS256header. - The Confusion: The library dynamically switches to HMAC mode. It takes the byte representation of the
serverPublicKey(specifically, its X.509 encoding) and uses it as the shared secret for the symmetric SHA-256 hashing function. Since the attacker derived this exact X.509 byte string usingsig2nand used it to sign the token locally, the signatures match.
C# (ASP.NET Core / Jose-JWT)
1
2
3
4
5
6
7
8
9
public string ValidateToken(string token, string publicKeyPem)
{
var publicKeyBytes = Encoding.UTF8.GetBytes(publicKeyPem);
// VULNERABLE: The library infers the validation algorithm from the token itself.
string payload = JWT.Decode(token, publicKeyBytes);
return payload;
}
Technical Flow & Syntax Explanation:
Encoding.UTF8.GetBytes: The public key is serialized into a raw byte array.JWT.Decode(token, publicKeyBytes): TheDecodemethod looks at the token’s header. If it seesHS256, it applies the HMAC algorithm usingpublicKeyBytesas the secret. It ignores the developer’s intent that those bytes represent an RSA public key structure.
Mock PR Comment
The JWT verification implementation is vulnerable to Algorithm Confusion. An attacker can mathematically derive our public key from existing tokens and submit an HS256 token, forcing the library to use our public key as an HMAC secret.
Recommendation: Strictly enforce the RS256 algorithm during token validation. Any token specifying a different algorithm in its header must be explicitly rejected before cryptographic verification begins.
4. The Fix
Secure Java
1
2
3
4
5
6
7
8
9
public Claims parseToken(String token, PublicKey serverPublicKey) {
// SECURE: The parser is locked to RS256.
return Jwts.parserBuilder()
.setSigningKey(serverPublicKey)
.requireAlgorithm(SignatureAlgorithm.RS256)
.build()
.parseClaimsJws(token)
.getBody();
}
Technical Flow & Syntax Explanation:
requireAlgorithm(SignatureAlgorithm.RS256): This method acts as a strict gatekeeper. When the parser reads the header of the incoming token, it compares thealgvalue toRS256. If the attacker submitsHS256, the parser throws anUnsupportedJwtExceptionimmediately, preventing the cryptographic confusion from occurring.
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: The expected algorithm is hardcoded into the Decode method.
string payload = JWT.Decode(
token,
publicKeyBytes,
JwsAlgorithm.RS256
);
return payload;
}
Technical Flow & Syntax Explanation:
JwsAlgorithm.RS256: Passing the algorithm explicitly to theDecodemethod overrides the token’s header. The library is forced to execute an RSA verification routine. If the token was signed symmetrically, the RSA math will fail, and the token is safely rejected.
5. Automation
A Python script that assumes the attacker has already used the Docker sig2n tool to derive the correct X.509 Base64 key. It automates the forging and exploitation phase.
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(...): Identifies the standard chain used to parse and verify a JWT in the JJWT library.pattern-not-inside: This negative filter checks the builder chain for the.requireAlgorithm(...)method. If the method is absent, the rule triggers, flagging the code as vulnerable to algorithm switching based on client input.
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): Captures the two-parameter version of the decode function, which infers the algorithm from the token’s header.pattern-not: Suppresses the alert if the developer utilizes the secure, three-parameter overload that forces the library to use a strictly defined$ALGORITHM(likeJwsAlgorithm.RS256), ignoring the header completely.
