一篇发布极慢的分析笔记
0. 前言
这个漏洞在我看来是一个很经典的Web + 二进制的漏洞,在利用过程中,不仅需要二进制的基础来判断数据填充的长度,同样需要对Web方向内容的掌握。
由于之前对于二进制安全的学习都仅限于CTF,因此这是我第一次分析真实的二进制漏洞,会写的较为详细,希望能对同样在学习二进制的Web同学起到一些帮助。
整个漏洞利用的步骤可以分为两个步骤:
- 利用空字节写覆盖结构体字段
- 伪造fastcgi_params完成RCE
1. 寻找漏洞触发点
本地搭建好环境之后,gdb attach到php-fpm对应的进程上,当接受新请求并进行初始化时,会调用下述函数:
1 | zend_first_try { |
跟入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 | char old; |
2.1 path_info 从何而来
path_info变量的赋值是在/sapi/fpm/fpm/fpm_main.c
的1206行开始的:
1 | char *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 | ... |
简单来说,slen记录的是 index.php
到 ?
之间到长度,因此slen的大小是我们可以控制的。这也意味着我们通过控制slen的大小,去控制pilen - slen的大小,进而控制path_info向低地址取。
2.2 空字节写
空字节写的漏洞代码部分为:
1 | FCGI_PUTENV(request, "ORIG_PATH_INFO", orig_path_info); |
在第三行中对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 |
|
从代码里不难看出,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结构体中,如图所示:
这也就意味着我们可以通过覆盖这些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 | typedef struct _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 | static inline char* fcgi_hash_strndup(fcgi_hash *h, char *str, unsigned int str_len) |
从这段代码中不难发现,在进行赋值时,首先会进行大小的校验,当剩余空间不足时,会进行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 | static char *fcgi_hash_get(fcgi_hash *h, unsigned int hash_value, char *var, unsigned int var_len, unsigned int *val_len) |
由于在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 | ini = FCGI_GETENV(request, "PHP_VALUE"); |
前面已经说到,会首先计算PHP_VALUE
的哈希值,确定idx,再根据idx去查找,此时就会查找到之前HTTP_EBUT
的bucket,随后读取我们伪造的恶意val。