一篇发布极慢的分析笔记

0. 前言

这个漏洞在我看来是一个很经典的Web + 二进制的漏洞,在利用过程中,不仅需要二进制的基础来判断数据填充的长度,同样需要对Web方向内容的掌握。

由于之前对于二进制安全的学习都仅限于CTF,因此这是我第一次分析真实的二进制漏洞,会写的较为详细,希望能对同样在学习二进制的Web同学起到一些帮助。

整个漏洞利用的步骤可以分为两个步骤:

  1. 利用空字节写覆盖结构体字段
  2. 伪造fastcgi_params完成RCE

1. 寻找漏洞触发点

本地搭建好环境之后,gdb attach到php-fpm对应的进程上,当接受新请求并进行初始化时,会调用下述函数:

1
2
3
4
5
6
7
8
9
zend_first_try {
while (EXPECTED(fcgi_accept_request(request) >= 0)) {
char *primary_script = NULL;
request_body_fd = -1;
SG(server_context) = (void *) request;
init_request_info();

fpm_request_info();
......

跟入init_request_info,能发现init_request_info函数的作用是初始化request_info结构体,根据https://bugs.php.net/bug.php?id=78599的描述,结合github上的commit记录,不难分析出漏洞产生的大致位置:

而根据https://bugs.php.net/bug.php?id=78599上的描述能发现问题是出在path_info[0] = 0的后续操作上:

the value of path_info[0] is set to zero (https://github.com/php/php-src/blob/master/sapi/fpm/fpm/fpm_main.c#L1150); then FCGI_PUTENV is called. Using a carefully chosen length of the URL path and query string, an attacker can make path_info point precisely to the first byte of _fcgi_data_seg structure. Putting zero into it moves char* pos field backwards, and following FCGI_PUTENV overwrites some data (including other fast cgi variables) with the script path.

2. 利用空字节写覆盖结构体字段

空字节写漏洞产生的位置在/sapi/fpm/fpm/fpm_main.c的1222行,这里一并截取1222行附近的代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
char old;
FCGI_PUTENV(request, "ORIG_PATH_INFO", orig_path_info);
old = path_info[0];
path_info[0] = 0;
if (!orig_script_name ||
strcmp(orig_script_name, env_path_info) != 0) {
if (orig_script_name) {
FCGI_PUTENV(request, "ORIG_SCRIPT_NAME", orig_script_name);
}
SG(request_info).request_uri = FCGI_PUTENV(request, "SCRIPT_NAME", env_path_info);
} else {
SG(request_info).request_uri = orig_script_name;
}
path_info[0] = old;

2.1 path_info 从何而来

path_info变量的赋值是在/sapi/fpm/fpm/fpm_main.c的1206行开始的:

1
2
3
4
5
6
7
8
9
char *path_info;
if (apache_was_here) {
/* recall that PATH_INFO won't exist */
path_info = script_path_translated + ptlen;
tflag = (slen != 0 && (!orig_path_info || strcmp(orig_path_info, path_info) != 0));
} else {
path_info = env_path_info ? env_path_info + pilen - slen : NULL;
tflag = (orig_path_info != path_info);
}

由于apache_was_here这个变量在前面被设为了0,因此path_info的赋值语句实际上就是:

1
path_info = env_path_info ? env_path_info + pilen - slen : NULL;

env_path_info是从Fast CGI的PATH_INFO取过来的,而由于代入了%0a,在采取fastcgi_split_path_info ^(.+?\.php)(/.*)$;这样的Nginx配置项的情况下,fastcgi_split_path_info无法正确识别现在的url,因此会Path Info置空,所以env_path_info在进行取值时,同样会取到空值。变量传递关系如下所示:

1
FastCGI PATH_INFO  ->  env_path_info  ->  path_info

回到path_info的赋值,此时已知env_path_info这个指针指向的内容为空,则path_info的实际赋值就是:

1
path_info = env_path_info + pilen - slen

pilen、slen以及其他几个有关几个长度的变量的赋值流程如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
...
char *env_path_translated = FCGI_GETENV(request, "PATH_TRANSLATED");
...
script_path_translated = env_path_translated;
...
script_path_translated_len = strlen(script_path_translated)
...
char *pt = estrndup(script_path_translated, script_path_translated_len);
int len = script_path_translated_len;
...
int ptlen = strlen(pt);
int slen = len - ptlen;
int pilen = env_path_info ? strlen(env_path_info) : 0;
int tflag = 0;

简单来说,slen记录的是 index.php?之间到长度,因此slen的大小是我们可以控制的。这也意味着我们通过控制slen的大小,去控制pilen - slen的大小,进而控制path_info向低地址取。

2.2 空字节写

空字节写的漏洞代码部分为:

1
2
3
4
5
6
7
8
9
10
11
12
13
FCGI_PUTENV(request, "ORIG_PATH_INFO", orig_path_info);
old = path_info[0];
path_info[0] = 0;
if (!orig_script_name ||
strcmp(orig_script_name, env_path_info) != 0) {
if (orig_script_name) {
FCGI_PUTENV(request, "ORIG_SCRIPT_NAME", orig_script_name);
}
SG(request_info).request_uri = FCGI_PUTENV(request, "SCRIPT_NAME", env_path_info);
} else {
SG(request_info).request_uri = orig_script_name;
}
path_info[0] = old;

在第三行中对path_info指向的地址进行了空字节写,而最后又进行了恢复,因此漏洞利用部分只能通过SG(request_info).request_uri = FCGI_PUTENV(request, "SCRIPT_NAME", env_path_info);或者FCGI_PUTENV(request, "ORIG_SCRIPT_NAME", orig_script_name);来完成。

有关FCGI_PUTENV的函数组成如下面的代码所示:

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
#define FCGI_PUTENV(request, name, value) \
fcgi_quick_putenv(request, name, sizeof(name)-1, FCGI_HASH_FUNC(name, sizeof(name)-1), value)


....

char* fcgi_quick_putenv(fcgi_request *req, char* var, int var_len, unsigned int hash_value, char* val)
{
if (val == NULL) {
fcgi_hash_del(&req->env, hash_value, var, var_len);
return NULL;
} else {
return fcgi_hash_set(&req->env, hash_value, var, var_len, val, (unsigned int)strlen(val));
}
}
....

static char* fcgi_hash_set(fcgi_hash *h, unsigned int hash_value, char *var, unsigned int var_len, char *val, unsigned int val_len)
{
unsigned int idx = hash_value & FCGI_HASH_TABLE_MASK;
fcgi_hash_bucket *p = h->hash_table[idx];

while (UNEXPECTED(p != NULL)) {
if (UNEXPECTED(p->hash_value == hash_value) &&
p->var_len == var_len &&
memcmp(p->var, var, var_len) == 0) {

p->val_len = val_len;
p->val = fcgi_hash_strndup(h, val, val_len);
return p->val;
}
p = p->next;
}

if (UNEXPECTED(h->buckets->idx >= FCGI_HASH_TABLE_SIZE)) {
fcgi_hash_buckets *b = (fcgi_hash_buckets*)malloc(sizeof(fcgi_hash_buckets));
b->idx = 0;
b->next = h->buckets;
h->buckets = b;
}
p = h->buckets->data + h->buckets->idx;
h->buckets->idx++;
p->next = h->hash_table[idx];
h->hash_table[idx] = p;
p->list_next = h->list;
h->list = p;
p->hash_value = hash_value;
p->var_len = var_len;
p->var = fcgi_hash_strndup(h, var, var_len);
p->val_len = val_len;
p->val = fcgi_hash_strndup(h, val, val_len);
return p->val;
}

从代码里不难看出,FCGI_PUTENV实质上就是创建了一个新的fcgi_hash_bucket结构体。

接下来就会遇到另一个问题,往哪里写?我在调试这个漏洞的时候,一开始并未注意到需要覆盖pos字段的描述,因此在这里卡住了。

2.3 为何要覆盖 pos

在调试的过程中,通过对堆上数据的观察,不难发现各种PHP配置项是存储在堆上的,如图:

结合request.env.data的值可以发现pos实际存储的就是全局变量名的起始位置:

那么覆盖pos的原因就呼之欲出了:伪造全局变量。

3. 伪造fastcgi_params完成RCE

通过上一个小节的分析,可以知道漏洞需要通过FCGI_PUTENV进行触发,并且触发的过程会借用 path_info的空字节写来完成。那么具体该如何构造呢?

FastCGI本身是不支持http协议的,因此它在进行http协议数据的处理时,会首先将http header以及fastcgi_params的信息读取出来存储到request.env结构体中,如图所示:

屏幕快照 2019-11-02 下午11.43.49

这也就意味着我们可以通过覆盖这些header来伪造fastcgi_params,这也是https://github.com/neex/phuip-fpizdam这个exp能够RCE的原理。

3.1 找到正确的偏移

在实际覆盖的时候,我们是不知道具体会有哪些全局变量存放在env_path_info之前的,因此需要找到一个准确的偏移来进行覆盖。

在gdb中观察fcgi_data_seg这个结构体的pos与data的地址,可以发现,data字段与pos字段的距离为:

0x562435b51fd8 - 0x562435b51fc0 = 24

同时,来看一下fcgi_data_seg这个结构体是如何定义的:

1
2
3
4
5
6
typedef struct _fcgi_data_seg {
char *pos;
char *end;
struct _fcgi_data_seg *next;
char data[1];
} fcgi_data_seg;

刚好就是 8 + 8 + 8 = 24,这与我们通过gdb观察到的地址偏移是一致的。

因此我们只需要让PATH_INFO成为data的首部数据,那么pos与PATH_INFO之间的偏移也就确定下来了,fcgi_data_seg结构的pos、end、next字段占24个字节,并且PATH_INFO\x00这个字符串占了10个字节,因此PATH_INFO的值到pos的偏移为34字节。

接下来就要寻找能让PATH_INFO置于fcgi_data_seg.data首部的方法。

PATH_INFO等全局变量是存放在堆上的,那么当前堆的大小被耗尽时,必然会malloc一块新的空间用于存放全局变量,那么这个过程中可以将我们的PATH_INFO放到fcgi_data_seg.data的首部吗?

为了解决这个问题,就需要来看下它是如何对内存进行操作的。

在对fcgi_hash进行赋值时,会调用函数fcgi_hash_strndup:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
static inline char* fcgi_hash_strndup(fcgi_hash *h, char *str, unsigned int str_len)
{
char *ret;

if (UNEXPECTED(h->data->pos + str_len + 1 >= h->data->end)) {
unsigned int seg_size = (str_len + 1 > FCGI_HASH_SEG_SIZE) ? str_len + 1 : FCGI_HASH_SEG_SIZE;
fcgi_data_seg *p = (fcgi_data_seg*)malloc(sizeof(fcgi_data_seg) - 1 + seg_size);

p->pos = p->data;
p->end = p->pos + seg_size;
p->next = h->data;
h->data = p;
}
ret = h->data->pos;
memcpy(ret, str, str_len);
ret[str_len] = 0;
h->data->pos += str_len + 1;
return ret;
}

从这段代码中不难发现,在进行赋值时,首先会进行大小的校验,当剩余空间不足时,会进行malloc。并且如果赋值的数据小于FCGI_HASH_SEG_SIZE时,会直接malloc大小为sizeof(fcgi_data_seg) -1 + FCGI_HASH_SEG_SIZE的一块空间。其中FCGI_HASH_SRG_SIZE的大小为4096,实际malloc的大小还要考虑十六字节的对齐,也就是将4096 - 1 + 32字节对齐,即:4128。

而提交的QUERY_STRING同样会存储到这块malloc的数据上,如果不断加长QUERY_STRING的长度,那么必然会导致这块数据被用完,然后malloc新的块。如果控制QUERY_STRING的大小使得刚好符合h->data->pos + str_len + 1 >= h->data->end,那么在存储PATH_INFO时,会由于空间刚好被用完而导致malloc,而PATH_INFO则刚好会存储在该堆块的首部。

此时的内存布局就类似于:

那么如何准确观测到PATH_INFO位于fcgi_data_seg.data首部这个现象呢?这就是原exp的牛逼之处了。

我们已经知道34个字节可以偏移到pos的首字节,那如果偏移量少几个字节呢?

pos字段存储的是一个地址,如果偏移少了几个字节,那么这个地址将会被修改,pos字段的地址有六个字节,那么将中间的一个字节改为00则必然会导致地址异常,这就是能使我们观测到PATH_INFO位于fcgi_data_seg.data首部这个现象。

考虑到我们需要在读pos指向的地址时,能读到我们控制的值,我们可以将它地址的最低字节改为00,那么修改后的地址与原地址的差异在 0xff-0x00 之间,我们可以通过插入无意义的header来填充这个差异。

3.2 伪造PHP_VALUE

在各种fastcgi_params中,我们最为熟悉的应该就是PHP_VALUE了,通过对PHP_VALUE的设置,可以很容易达到RCE。

当我们能够找到准确的偏移后,接下来要思考的就是如何伪造PHP_VALUE。伪造PHP_VALUE实质上就是想在FastCGI取fastcgi_params时取到我们伪造的值,而FastCGI取params的方式也是导致这次RCE的一个重要原因。

从之前gdb的数据以及结构体的定义中,不难发现在fcgi_hash_bucket的定义中使用到了哈希表。在哈希表中进行插入、读取数据时,会有一个计算哈希值的过程,计算得到的哈希值将决定插入/读取的数据的具体位置。有关于哈希表的更具体的内容,可以参见如下链接的内容:《深入理解PHP内核》:哈希表(HashTable)

由于PHP是开源的,因此哈希计算函数也是已知的,在https://github.com/neex/phuip-fpizdam所展示的exp中,便构造了一个header:EBUT,该header可以使得HTTP_EBUT与PHP_VALUE的哈希值是一样的。当然,在取值时还会校验变量名的长度等因素,具体过程如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
static char *fcgi_hash_get(fcgi_hash *h, unsigned int hash_value, char *var, unsigned int var_len, unsigned int *val_len)
{
unsigned int idx = hash_value & FCGI_HASH_TABLE_MASK;
fcgi_hash_bucket *p = h->hash_table[idx];

while (p != NULL) {
if (p->hash_value == hash_value &&
p->var_len == var_len &&
memcmp(p->var, var, var_len) == 0) {
*val_len = p->val_len;
return p->val;
}
p = p->next;
}
return NULL;
}

由于在fcgi_hash_strndup中通过memcpy来实现的复制,并且 ret = h->data->pos;,因此ret实际上指向的是00结尾一个地址。完整的思路就是:通过path_info修改pos,使其指向HTTP_EBUT的地址,随后借助memcpy让PHP_VALUE\nsession.auto_start=1;;;HTTP_EBUT\nmamku tvoyu进行覆盖。

当读取PHP_VALUE时,会调用FCGI_GETENV:

1
2
3
4
5
6
7
8
ini = FCGI_GETENV(request, "PHP_VALUE");
if (ini) {
int mode = ZEND_INI_USER;
char *tmp;
spprintf(&tmp, 0, "%s\n", ini);
zend_parse_ini_string(tmp, 1, ZEND_INI_SCANNER_NORMAL, (zend_ini_parser_cb_t)fastcgi_ini_parser, &mode);
efree(tmp);
}

前面已经说到,会首先计算PHP_VALUE的哈希值,确定idx,再根据idx去查找,此时就会查找到之前HTTP_EBUT的bucket,随后读取我们伪造的恶意val。

4. Reference