师傅tql,debug过程很详细。我最近也有分析这个漏洞(没有wonderkun师傅调试详细),如果看师傅这篇有疑问的可以来我这篇找一下答案,分析思路稍微有点不一样 。 :)
https://whip1ash.cn/2019/10/30/PHP-FPM-RCE-CVE-2019-11043-Analysis
Orz
"此漏洞非常的棒,特别是利用写的非常的精妙,可以作为二进制结合web的漏洞利用的典范,非常值得思考和学习",phithon师傅说。
同时也是因为本人也是对结合二进制的web漏洞比较感兴趣,觉得比较的好玩,所以就自己学习和分析一波,如果哪里分析的不对,希望大家可以及时的提出斧正,一起学习进步。
对这个漏洞原理有所了解,但是想更加深入理解怎么利用的,建议直接看第五节
0x1 前言
我这里提供一下我的调试环境: https://github.com/wonderkun/CTFENV/tree/master/php7.2-fpm-debug
关于漏洞存在的条件就不再说了,这里可能需要说一下的是 php-fpm 的配置了:
[global]
error_log = /proc/self/fd/2
daemonize = no
[www]
access.log = /proc/self/fd/2
clear_env = no
listen = 127.0.0.1:9000
pm = dynamic
pm.max_children = 5
pm.start_servers = 1
pm.min_spare_servers = 1
pm.max_spare_servers = 1
我把 pm.start_servers
pm.max_spare_servers
都调整成了1,这样 php-fpm 只会启动一个子进程处理请求,我们只需要 gdb attach pid
到这个子进程上,就可以调试了,避免多进程时的一些不必要的麻烦。
0x2 触发异常行为
先看一下nginx的配置
fastcgi_split_path_info ^(.+?\.php)(/.*)$;
fastcgi_split_path_info
函数会根据提供的正则表表达式, 将请求的URL(不包括?之后的参数部分),分割为两个部分,分别赋值给变量 $fastcgi_script_name
和 $fastcgi_path_info
。
那么首先在index.php中打印出 $_SERVER["PATH_INFO"]
,然后发送如下请求
GET /index.php/test%0atest HTTP/1.1
Host: 192.168.15.166
按照预期的行为,由于/index.php/test%0atest
无法被正则表达式 ^(.+?\.php)(/.*)$
分割为两个部分,所以nginx传给php-fpm的变量中 SCRIPT_NAME
为 /index.php/test\ntest
, PATH_INFO
为空,这一点很容易通过抓取nginx 和 fpm 之间的通信数据来验证。
socat -v -x tcp-listen:9090,fork tcp-connect:127.0.0.1:9000
这里的变量名和变量值的长度和内容遵循如下定义(参考fastcgi的通讯协议):
typedef struct {
unsigned char nameLengthB0; /* nameLengthB0 >> 7 == 0 */
unsigned char valueLengthB0; /* valueLengthB0 >> 7 == 0 */
unsigned char nameData[nameLength];
unsigned char valueData[valueLength];
} FCGI_NameValuePair11;
typedef struct {
unsigned char nameLengthB0; /* nameLengthB0 >> 7 == 0 */
unsigned char valueLengthB3; /* valueLengthB3 >> 7 == 1 */
unsigned char valueLengthB2;
unsigned char valueLengthB1;
unsigned char valueLengthB0;
unsigned char nameData[nameLength];
unsigned char valueData[valueLength
((B3 & 0x7f) << 24) + (B2 << 16) + (B1 << 8) + B0];
} FCGI_NameValuePair14;
typedef struct {
unsigned char nameLengthB3; /* nameLengthB3 >> 7 == 1 */
unsigned char nameLengthB2;
unsigned char nameLengthB1;
unsigned char nameLengthB0;
unsigned char valueLengthB0; /* valueLengthB0 >> 7 == 0 */
unsigned char nameData[nameLength
((B3 & 0x7f) << 24) + (B2 << 16) + (B1 << 8) + B0];
unsigned char valueData[valueLength];
} FCGI_NameValuePair41;
typedef struct {
unsigned char nameLengthB3; /* nameLengthB3 >> 7 == 1 */
unsigned char nameLengthB2;
unsigned char nameLengthB1;
unsigned char nameLengthB0;
unsigned char valueLengthB3; /* valueLengthB3 >> 7 == 1 */
unsigned char valueLengthB2;
unsigned char valueLengthB1;
unsigned char valueLengthB0;
unsigned char nameData[nameLength
((B3 & 0x7f) << 24) + (B2 << 16) + (B1 << 8) + B0];
unsigned char valueData[valueLength
((B3 & 0x7f) << 24) + (B2 << 16) + (B1 << 8) + B0];
} FCGI_NameValuePair44;
它把长度放在内容的前面,这样做导致我们没办法能够使得php-fpm对数据产生误解。到此为止,一切都还在我们的预期的范围内。但是 index.php 打印出来的 $_SERVER["PATH_INFO"]
却是 "PATH_INFO", 这就非常奇怪了。。。。 为啥传过去的PATH_INFO
是空,打印出来却是有值的?
其实这个问题我和 @rebirthwyw 在做 real world CTF的时候已经注意到了,但是我并没有深层次的去看到底是为啥,错过了一个挖漏洞的好机会,真是tcl 。。。
0x3 调试分析异常原因
gdb attach
之后,程序会停下来,看一下栈帧,我们是停在了 fcgi_accept_request
函数的内部。
► f 0 7f1071dbe990 __accept_nocancel+7
f 1 558cb067d462 fcgi_accept_request+147
f 2 558cb068c95a main+4502
f 3 7f1071cf52e1 __libc_start_main+241
发一个请求,单步跟踪一下,或者全局搜索一下,发现调用点,这里while True
的从客户端接收请求,然后进行处理。
init_request_info
函数是用来初始化客户端发来的请求的全局变量的,这是关注的重点。
单步跟踪此函数,如果开启了fix_pathinfo
,就会进入如下尝试路径自动修复的关键代码。
在这里 script_path_translated
指向的就是全局变量 SCRIPT_FILENAME
, 在这里其实就是 /var/www/html/index.php/test\ntest
。红色箭头执行的函数 tsrm_realpath
是一个求绝对路径的操作,因为/var/www/html/index.php/test\ntest
路径不存在,所以real_path
是 NULL,进入后面的 while
操作, 这里 char *pt = estrndup(script_path_translated, script_path_translated_len);
是一个 malloc + 内容赋值的操作, 所以 pt存储的字符串也是 /var/www/html/index.php/test\ntest
。
看一下 while 的具体操作
while ((ptr = strrchr(pt, '/')) || (ptr = strrchr(pt, '\\'))) {
*ptr = 0; //
if (stat(pt, &st) == 0 && S_ISREG(st.st_mode)) {
/*
* okay, we found the base script!
* work out how many chars we had to strip off;
* then we can modify PATH_INFO
* accordingly
*
* we now have the makings of
* PATH_INFO=/test
* SCRIPT_FILENAME=/docroot/info.php
*
* we now need to figure out what docroot is.
* if DOCUMENT_ROOT is set, this is easy, otherwise,
* we have to play the game of hide and seek to figure
* out what SCRIPT_NAME should be
*/
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);
}
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;
} else if (apache_was_here && env_script_name) {
/* Using mod_proxy_fcgi and ProxyPass, apache cannot set PATH_INFO
* As we can extract PATH_INFO from PATH_TRANSLATED
* it is probably also in SCRIPT_NAME and need to be removed
*/
int snlen = strlen(env_script_name);
if (snlen>slen && !strcmp(env_script_name+snlen-slen, path_info)) {
FCGI_PUTENV(request, "ORIG_SCRIPT_NAME", orig_script_name);
env_script_name[snlen-slen] = 0;
SG(request_info).request_uri = FCGI_PUTENV(request, "SCRIPT_NAME", env_script_name);
}
}
env_path_info = FCGI_PUTENV(request, "PATH_INFO", path_info);
}
做一个简单的解释,先去掉 /var/www/html/index.php/test\ntest
最后一个 /
后面的内容,看 /var/www/html/index.php
这个文件是否存在,如果存在,就进入后续的操作。
注意几个长度:
ptlen 是 /var/www/html/index.php 的长度
len 是 /var/www/html/index.php/test\ntest 的长度
slen 是 /test\ntest 的长度
pilen 是 PATH_INFO 的长度,因为 PATH_INFO 在此时还是为空的,所以是 0
发生问题的关键是如下的操作: