Nexus — HackTheBox Machine Writeup
nexus.htb hosts a corporate site whose footer leaks an email; subdomain fuzzing reveals a Gitea instance and a Krayin CRM. A public docker-setup repo hides a live DB password in its .env commit history.
The leaked password logs into Krayin CRM as the admin user. Its TinyMCE upload endpoint accepts a PHP file disguised as a JPEG, giving an unauthenticated-by-misconfig webshell and command execution as www-data.
Password reuse yields SSH as jones. A root systemd timer flattens Gitea template repos with os.path.join() and unsanitized ls-tree paths; hand-crafted git tree objects containing a literal '..' directory write /etc/sudoers.d/pwn as root.
Challenge Overview
Nexus is a masterclass in "deleted ≠ gone" and "trusting input shape". The first credential comes from git history, not from any server. The second comes from a .env read through an RCE that the first credential unlocked. And root comes from abusing a root-run sync script with a git tree object that no normal git add would ever let you create. Every stage reuses something that should have been rotated, sanitized, or anchored.
Beginner note: this box rewards keeping a strict credential ledger. Three different passwords appear (Gitea history → Krayin login, production .env → SSH, Gitea web → template repo) and the first two are reused across services. Write every secret down as you find it. We explore systematic post-exploitation credential harvesting in our reference guide: Linux Privilege Escalation — Enumeration.
Enumeration & Web Application Recon
A full port scan shows a minimal attack surface:
The root domain redirects to http://nexus.htb/, so virtual hosts are in play. Adding all three hostnames up front:
::Email harvesting
The corporate homepage leaks two contact emails in its HTML — one of which will be a username later:
::Subdomain enumeration
Virtual-host fuzzing with ffuf against the top-5000 subdomain list:
Two interesting hits:
| Subdomain | Behavior |
|---|---|
git.nexus.htb | HTTP 200 — Gitea instance |
billing.nexus.htb | HTTP 302 → /admin/login — Krayin CRM |
billing.nexus.htb 302s to its login page, so a naive -fc 302 filter hides it from the results. If a vhost 302s on purpose, enumerate with -mc all or check the redirect target before filtering it out.
Gitea — Credential Leak from Git History
git.nexus.htb is Gitea with two visible users, jones and admin. The admin user owns a public repository named krayin-docker-setup — the deployment scaffolding for the CRM on billing.nexus.htb.
The current .env in the repo shows a scrubbed database password:
But the repo has two commits, and the earlier one still contains the real value. Git history never forgets:
This is the "credential leak from git history" pattern: secrets that were removed from a file still live in the objects of every commit that ever contained them. We detailed similar git metadata & repository history harvesting vectors in our writeup for HackTheBox Busqueda. git log -p -- <file> is the fastest way to diff a file across its whole life.
The APP_URL in the same .env points at http://billing.nexus.htb — confirming this password belongs to the CRM. The password N27xh!!2ucY04 is also the login for j.matthew@nexus.htb.
Foothold — Krayin CRM RCE (TinyMCE File Upload)
::Logging in
http://billing.nexus.htb/admin/login is a Krayin CRM admin login. The Laravel form carries a _token CSRF field and sets an XSRF-TOKEN cookie; both are needed for the authenticated upload. Logging in with the leaked credentials:
Result: 302 → /admin/dashboard and a krayin_crm_session cookie — we are in as the CRM administrator.
::The upload endpoint
The TinyMCE editor backend exposes POST /admin/tinymce/upload. It is meant for pasting images into rich-text fields, but it performs no extension or MIME validation on the server side. Uploading a PHP file with a image/jpeg content type sails through and lands in the web-accessible storage/tinymce/ directory, executable by the PHP interpreter.
This is a classic "upload filter bypass by omission": the client sends type=image/jpeg in the multipart body, the server checks nothing server-side, and the filename extension .php is preserved in the stored path. We cover PHP file upload filter bypasses, web application auditing, and dynamic sinks in depth in our article: Source Code Review: Unearthing Critical Flaws in PHP.
The response returns the stored location:
::Command execution
Arbitrary command execution as www-data. From here the web root's environment file is the natural next stop.
Lateral Movement — www-data → jones
The Krayin deployment lives at /var/www/krayin, and its production .env holds a different database password than the one in the Gitea repo — this one was never scrubbed:
The .env also reveals APP_DEBUG=true — Laravel's debug toolbar is enabled and leaks stack traces, queries, and paths on every page. A separate finding, but it underscores how much this box leaks by default.
Testing that password against SSH: the box reuses it for the local jones account, who happens to own the Gitea account of the same name.
Privilege Escalation — Root Timer + Git Tree-Object Traversal
::The root-run template sync
A systemd timer fires every minute:
The script is readable and its core loop is dangerously naive:
For every template repo, it runs git ls-tree -r HEAD and writes each blob to os.path.join(stage_path, filepath). Three failures compound:
- ▹
filepathcomes straight from git output — no sanitization. - ▹
os.path.joinhappily resolves..— it does not confine the path tostage_path. - ▹
os.makedirscreates any intermediate directory, including..components, so the whole ancestor chain materializes on disk.
If the writer had used os.path.realpath(target).startswith(os.path.realpath(stage_path)) or pathlib.Path.resolve().is_relative_to(...), the traversal would have died here. Path traversal flaws stemming from missing normalization are discussed extensively in CVE-2021-43798 Grafana Directory Traversal Deep Dive. As written, any .. in a repo path escapes the staging directory with root privileges.
So the only question is: how do we get a .. path into a git tree?
::The git tree-object trick
Git 2.35+ refuses to index filenames containing .. — git add and even manual index edits reject them. But the tree objects stored in .git/objects are just raw binary structures: "<mode> <name>\0<20-byte SHA>" entries. Nothing validates the name when you write the object by hand. A tree can contain an entry whose name is literally .., and git ls-tree -r will flatten it into a path like ../../../../../../etc/sudoers.d/pwn.
The check is in the index-write path, not in the object format. Hand-rolled tree objects bypass every git add safeguard because they never go through the index. This is the same class of attack as crafting zip entries with ../ paths — but for git's object database. For more Linux privilege escalation techniques involving custom binary crafts and root task hijacking, see Linux Privilege Escalation — Exploitation.
::Building the malicious template repo
Since template-sync.py syncs any repo flagged template=true, the plan is:
- ▹Create a template repo
pwn-templateasjones(the passwordy27xb3ha!!74GbRis reused for Gitea too). - ▹Hand-craft a commit whose tree contains a literal
..directory chain resolving toetc/sudoers.d/pwn. - ▹Force-push it; within 60 seconds the root timer flattens the tree and writes the sudoers rule.
The payload the tree will carry:
The builder — pure Python with hashlib + zlib, no index involved:
::Verifying the flattened tree
Git itself confirms the traversal path appears in ls-tree -r output — exactly what the root script will parse:
Creating the repo via the Gitea API (as jones) and force-pushing:
The --force is required because the crafted commit is written directly to refs/heads/main without a normal working-tree commit — the remote may consider it a non-fast-forward.
::Waiting for the timer
Within one 60-second cycle the root timer picks up the repo, flattens the tree, and logs the traversal write:
The proof in sudo -l:
Verification & Proof of Work
Two independent confirmations that the timer (not something else) wrote the file:
- ▹File provenance —
/etc/sudoers.d/pwnappears in the exact 60s window after the push, with root ownership, at the same momenttemplate-sync.logrecordssynced: ../../../../../../etc/sudoers.d/pwn. - ▹Content correctness —
sudo -lnow emits(ALL) NOPASSWD: ALLwith no password prompt, andsudo -n idreturnsuid=0(root), proving sudo parsed the dropped file (a malformed sudoers entry would cause a sudo error, not silent success).
Autosolve
The entire chain — history leak, CRM login, webshell upload, RCE, .env harvest, SSH, crafted tree objects, push, and timer wait — fits in one standalone script. The standalone Python script htb_nexus_solve.py runs the entire exploit in five automated stages and reads every credential dynamically off the live target.
Run the full solver with python3 autosolve/htb_nexus_solve.py <TARGET_IP>. It requires requests, paramiko, and the system git binary. The script is completely idempotent: template repos are scrubbed and re-initialized via the Gitea API before pushing, and sudoers polling accommodates the timer's 60-second interval. View all automated vault scripts in our /autosolve directory.
References
- ▹Gitea API — Repository Endpoints
- ▹HackTricks — File Upload Bypass Techniques
- ▹Git Internals — Tree & Blob Object Specification
- ▹GTFOBins — Sudo Security & Misconfigurations
- ▹Source Code Review: Unearthing Critical Flaws in PHP
- ▹CVE-2021-43798 Grafana Directory Traversal Deep Dive
- ▹Linux Privilege Escalation — Exploitation
- ▹Linux Privilege Escalation — Enumeration
- ▹HackTheBox Busqueda Writeup

Red Team Consultant · Penetration Tester · Bug Bounty Hunter
Offensive security professional with 250+ vulnerabilities reported across 50+ organizations including Atlassian, Vimeo, and AT&T. Sharing research, tools, and field notes.