拥抱php之CVE-2019-11043

这个洞是在discord看见的,只能叹息一声,linux kernel又要往后延期了。作者是打realworld发现的一个0day,有趣,随手一打,服务就crash了,然后0day一枚。

https://bugs.php.net/bug.php?id=78599从作者的描述加上官方的patch。

你能知道所有的东西和PATH_INFO这个fastcgi的参数有关。

1
2
3
4
5
6
7
8
9

location ~ [^/]\.php(/|$) {
fastcgi_split_path_info ^(.+?\.php)(/.*)$;
fastcgi_param PATH_INFO $fastcgi_path_info;
fastcgi_param SCRIPT_FILENAME $document_root$fastcgi_script_name;
fastcgi_pass php:9000;
...
}
}
之前我对nginx完全一片空白,这个地方我想了很久,fastcgi_split_path_info这个关键字字面意思就是用来分割PATH_INFO的,后面这个正则第一个子匹配给$fastcgi_script_name,第二个子匹配给 $fastcgi_path_info,这里用个换行符这条正则就gg了,nginx里面的.可以用来匹配除换行符以外的字符。有意思是这个正则gg以后,全部的URI都给了$fastcgi_script_name.所以这里的PATH_INFO是个空值。

下面涉及到了php内核问题,没什么好办法,只能调。用作者给的crash_url,我这里并不能crash-.+。

1
http://127.0.0.1:8080/helloworld.php/%0aAAAAAAAAAAAAAAAAAAAAAAAAAAAA?QQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQ
按照上面的nginx的规则这里的PATH_INFO是个空值。而SCRIPT_NAME"$document_root/helloworld.php/\nAAAAAAAAAAAAAAAAAAAAAAAAAAAA",这在你调的时候都能打印出来。定位问题所在的函数init_request_info,注意看下面的代码段:
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
if (script_path_translated &&
(script_path_translated_len = strlen(script_path_translated)) > 0 &&
(script_path_translated[script_path_translated_len-1] == '/' ||
(real_path = tsrm_realpath(script_path_translated, NULL)) == NULL)
) {
char *pt = estrndup(script_path_translated, script_path_translated_len);
int len = script_path_translated_len;
char *ptr;

if (pt) {
while ((ptr = strrchr(pt, '/')) || (ptr = strrchr(pt, '\\'))) {
*ptr = 0;
if (stat(pt, &st) == 0 && S_ISREG(st.st_mode)) {
int ptlen = strlen(pt);
int slen = len - ptlen;
int pilen = env_path_info ? strlen(env_path_info) : 0;
int tflag = 0;
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);
}
这段代码注释里面也说了这是用来找请求脚本的真实路径。什么意思呢?
1
SCRIPT_NAME = "$document_root/helloworld.php/\nAAAAAAAAAAAAAAAAAAAAAAAAAAAA";
这里脚本并不是真实路径,需要压缩,一直取最右边的'/'或者'\'来分割字符串,直到找到真实的路径。然后这里有一个操作
1
path_info = env_path_info ? env_path_info + pilen - slen : NULL;
因为有可能出现这种情况/something/random.php/real_path.php/aaaaaaaaaaaa; 这里本意是应该用来真实的PATH_INFO,但是这里出现了问题,直接把env_path_info当做了判断条件,但是php里面存储空值的fastcgi字段,是用的char[1],什么情况才会出现NULL呢?除非fastcgi的请求里面没有这个字段,但只要你有这个字段,尽管是空值,就给你char[1].

所以这里env_path_info不是个NULL的指针。这就出现问题了,后面的正常逻辑应该是env_path_info指向的是个非空的字符串。这里pilen代表PATH_INFO的长度,当然为0,这里相当于你把path_info往后移了slen个字节。关于这一点我放个图就知道了 Screenshot%20from%202019-10-24%2019-38-59|690x330

可以看到path_info会指向REDIRECT_STATUS这个字符串。继续往后看:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
if (tflag) {
if (orig_path_info) {
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;
这操作太熟悉了path_info[0] = 0;,有点NULL off by one的味道了。这个写一字节null的机会应该怎么用呢?上面的截图,就算在这个REDIRECT_STATUS字符串写NULL似乎没什么用,控制写NULL的偏移是可以来控制,但是当我上面偏移往后看的时候,存储都是fastcgi的字段,怎么写似乎都没什么作用,似乎这个洞用来DOS都没办法做到。

单纯看作者的exp也没什么眉目,但是有一个重要的地方,在运行exp过程中fpm的worker crash过一次。这是我用ASAN检测到的,具体在php编译的时候如何加上ASAN可以按照下面的写

1
CFLAGS="-O0 -g -fsanitize=address -fno-omit-frame-pointer" LIBS='-ldl' ./configure --prefix=/root/php-7.3-fpm --enable-fpm  --enable-debug
千万别加上--disable-all,这会导致后面用session.auto_start检测回显的时候出错。所以标准库改加上的就加上。

这个时候,其实我有点手足无措,但是有一个地方我有注意到,作者的exp中对于从env_path_info往后偏移的slen一直都是没有变的,这个是值是30,相当于写NULL的地方是固定的。

1
2
/helloworld.php/PHP%0Ais_the_shittiest_lang.php?qqqqqqqqqqqqqqqqqqqqqq
-------------------------------
影响slen的是画横线的地方,这个地方从exp可以看到一直是没有改变过的。完全没有思路的我,只能硬着头皮去看crash的点,从ASAN的bt回溯里面定位到下面地方
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); //crashed~!!!!!!!!!!!!!!!!!!!!!1
ret[str_len] = 0;
h->data->pos += str_len + 1;
return ret;
}
memcpy地方出现了Segmentfault,ret是个非法地址。ret来自于h->data->pos,这个h->data是个什么结构呢?
1
2
3
4
5
6
struct _fcgi_data_seg {
char *pos;
char *end;
struct _fcgi_data_seg *next;
char data[1];
} *
看起来似乎一个内存块管理结构,而且是pos的第5个字节被写NULL了。这就变得很有趣了,env_path_info往后写30字节,怎么能写到这个结构上。反过来想env_path_info-30就在_fcgi_data_seg结构里面。这地方就需要看这个结构是怎么分配了,继续往前看。

可以看到这个结构位于内存块的起始位置,最大可以分配malloc(sizeof(fcgi_data_seg) - 1 + seg_size);考虑到16对齐,这个size应该是4096+32,可以看到逻辑上内存块是个链式结构,用pos和end分布来记录起始和结束,这个char data[1]就是data段的开始。当这块内存不够的时候,会重新分配。

这一切都变的明朗了,储存fastcgi的参数地方内存是动态分配的。初始时候会分配最大的内存块4128,那么会存在这样一种情况,在分配PATH_INFO的时候,前面初始化的内存块用完了。重新开始分配一块内存,存储fastcgi的参数的是个_fcgi_hash_bucket结构,先存储是PATH_INFO这个字段名,然后存储对应的值。我们可以画一下出现这种情况的内存分布:

1
2
3
4
5
6
7
8
9
10
char *pos  
------------- +8
char *end
------------- +8
char *next
------------- +8
PATH_INFO\x00
------------- +10
\x00 <---- env_path_info
-------------

你可以算一下这种情况下 env_path_info-30,刚好在pos位置的第5个字节上,一般用户态的地址只用了6个字节,第5字节高字节一般都是随机化的字节,写NULL以后,最后肯定非法了。

这个NULL写的地方现在来看就有意义了。我们可以一直通过增加query的长度,来达到这个效果,最后返回404就代表worker crash了。这样我们就可以控制写的位置,下面来讲一讲具体存储fastcgi字段的过程,只有了解这工程以后,我们才知道具体哪里写NULL。

我们直接来看是怎么取值的,首先会根据fastcgi的参数名取一个hash。

1
2
3
4
5
6
#define FCGI_HASH_FUNC(var, var_len) \
(UNEXPECTED(var_len < 3) ? (unsigned int)var_len : \
(((unsigned int)var[3]) << 2) + \
(((unsigned int)var[var_len-2]) << 4) + \
(((unsigned int)var[var_len-1]) << 2) + \
var_len)
具体过程如下:
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; //这里做的是个映射,映射到0-127
fcgi_hash_bucket *p = h->hash_table[idx]; //取hash_bucket

while (p != NULL) { //这里取值是比较严格的hash_value 和参数名要完全对上。
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;
}

按照上面的思路,如果我们要伪造PHP_VALUE,就得找到和PHP_VALUE hash值和长度相同的fcgi_hash_bucket,相当于要找已经插入到h->hash_table的键值对,mochzz问过我下面字段出现了fastcgi请求,是有什么作用,作用就在这里,可以自己加http_header 直接传不就行了,这样我们就直接插入一个PHP_VALUE位置对应的fcgi_hash_bucket.

1
2
0x559b058ea102:	"HTTP_EBUT"
0x559b058ea10c: "mamku tvoyu"
这个HTTP_EBUT就和PHP_VALUE有相同的hash值和长度。如果不太确定话,可以直接用上面计算hash的方法算一下,所以现在我们需要做就是让我们的PHP_VALUE刚好能覆盖HTTP_EBUT,并且紧随在后面的mamku tvoyu也能被我们构造的ini设置覆盖掉。

具体怎么做呢? 我们NULL off by one,现在不能写在pos的第5字节上了。需要把pos往后移动,最好的情况应该是写第一个字节,把第一个字节置NULL,pos后移。为了能精准的覆盖,这个时候还需要加一个http_header用来作为调节。如下

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
HTTP_D_PISOS8
==============================D
HTTP_EBUT
mamku tvoyu
...
...
... <------- 正常pos的指向。

HTTP_D_PISOS8
==============================D <--------------- 写pos第一个字节以后的指向
HTTP_EBUT
mamku tvoyu
...
...
...
我可以通过调节HTTP_D_PISOS8的长度,让位置的fake PHP_VALUE正好覆盖在HTTP_EBUT上。写个NULL最多往前移0xff个字节,完全可控。这是第二个需要爆破的点。

这也需要调节前面的结构,让PATH_INFO现在需要偏移34而不是30,因为要写pos的第一个字节。这里作者exp里面用的是session.auto_start=1让页面返回Set-Cookie来判断恰好覆盖点,因为每次通过ini设置的语句长度可以不太一样,这个时候在后面填;,第一次在session.auto_start=1;;;;来保证长度足够后面写。因为fpm以worker来调度的,一个worker就是一个进程,进程只要不crash就可以保存之前ini设置。所以这里可以分开来写ini的设置来getshell,这样引用一张mochazz的图,你就可以知道是如何getshell的。

image|405x223

mochazz也问我了一个问题,为什么前面加Q是5个的一加来进行爆破的,这里其实很简单,PATH_INFO\x00,长度为10,加5个Q相当于加了10个Q因为fastcgi里面有两个字段 queryrequest_uri都会包含查询字段。

还有这里fpm不只有一个woker,在后面写ini的时候,你需要一个请求发几次,确保同一个worker都能写上去。这里调试的时候,你可以把fpm只开一个worker,虽然我这里开了3个worker,但我用gdb把其他两个都挂起来了,相当于只有一个worker。

只能说作者幸运值和技术值爆表,想要弄个crash也不太容易啊,我为什么没有碰到过这种好事-.-,如果上述分析有不对地方,师傅们都可以指出来,有疑问的都可以一起来探讨!