# 拥抱php之CVE-2019-11043

**URL:** <https://forum.90sec.com/t/topic/558>\
**Category:** 技术文章\
**Created:** [2019 年10 月 24 日 13:22 UTC](https://forum.90sec.com/t/topic/558 "2019-10-24T13:22:18Z")\
**Posts on this page:** 2\
**Page:** 1

<div class="post-metadata">

作者： ![maplgebra](https://forum.90sec.com/user_avatar/forum.90sec.com/maplgebra/32/1628_2.png) [@maplgebra](https://forum.90sec.com/u/maplgebra)\
发布日期： [2019 年10 月 24 日 13:22 UTC](https://forum.90sec.com/t/topic/558/1 "2019-10-24T13:22:18Z")

</div>

# 拥抱php

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

[https://bugs.php.net/bug.php?id=78599](https://bugs.php.net/bug.php?id=78599@maple%CD%82%CD%82%CD%82#9207 "https://bugs.php.net/bug.php?id=78599@maple͂͂͂#9207")从作者的描述加上官方的patch。

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

```nginx

   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-.+。

```
http://127.0.0.1:8080/helloworld.php/%0aAAAAAAAAAAAAAAAAAAAAAAAAAAAA?QQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQ

```

按照上面的nginx的规则这里的`PATH_INFO`是个空值。而`SCRIPT_NAME`为`"$document_root/helloworld.php/\nAAAAAAAAAAAAAAAAAAAAAAAAAAAA"`,这在你调的时候都能打印出来。定位问题所在的函数`init_request_info`,注意看下面的代码段：

```
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);
							}

```

这段代码注释里面也说了这是用来找请求脚本的真实路径。什么意思呢？

```
SCRIPT_NAME = "$document_root/helloworld.php/\nAAAAAAAAAAAAAAAAAAAAAAAAAAAA";

```

这里脚本并不是真实路径，需要压缩，一直取最右边的`'/'`或者`'\'`来分割字符串，直到找到真实的路径。然后这里有一个操作

```
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](https://forum.90sec.com/uploads/default/original/2X/e/e7568b75c2802fa10eeea00497d8bab73969e504.png)

可以看到path\_info会指向`REDIRECT_STATUS`这个字符串。继续往后看：

```
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可以按照下面的写

```
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的地方是固定的。

```
/helloworld.php/PHP%0Ais_the_shittiest_lang.php?qqqqqqqqqqqqqqqqqqqqqq
				-------------------------------

```

影响`slen`的是画横线的地方，这个地方从exp可以看到一直是没有改变过的。完全没有思路的我，只能硬着头皮去看crash的点，从ASAN的bt回溯里面定位到下面地方

```
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`是个什么结构呢？

```
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`这个字段名，然后存储对应的值。我们可以画一下出现这种情况的内存分布：

```
		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。

```
#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)

```

具体过程如下:

```
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.

```
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用来作为调节。如下

```
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](https://forum.90sec.com/uploads/default/original/2X/a/a2b86e6ff8536bde8e16effa67bf8a05ca16a08d.png)

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

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

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

---

<div class="post-metadata">

作者： ![maplgebra](https://forum.90sec.com/user_avatar/forum.90sec.com/maplgebra/32/1628_2.png) [@maplgebra](https://forum.90sec.com/u/maplgebra)\
发布日期： [2019 年10 月 30 日 08:46 UTC](https://forum.90sec.com/t/topic/558/2 "2019-10-30T08:46:17Z")

</div>

还有一点小思考：其实这个地方，在看crash点之前，`env_path_info+offset`这种超边界的移动，ASAN竟然都没有报错，你其实可以预想到在进行内存分配的时候，并不是一个字段一个字段这种粒度的内存分配，可能是一大段内存，而且fastcgi的字段都是未知的，那么肯定存在这样一个内存管理分割的过程，按照php内存管理的习惯，喜欢把内存管理结构放在内存块的开始，我在这里是耗费了一些时间，所以反过来在这里留下一点小思考，供大家参考。
