前言
神奇的漏洞。
漏洞影响
PHP <= 7.4.21
环境搭建
开一个vultr云主机,安装docker下载php:7.4.21-apache镜像并下载到本地虚拟机里导入,开启镜像把8080端口映射到虚拟机的80端口。
最后进入虚拟机,开启内置服务器:
php -S 0.0.0.0:8080
验证漏洞,要把bp的自动更新content-length的选项取消:
GET /phpinfo.php HTTP/1.1
Host: 192.168.88.129
\r\n
\r\n
GET / HTTP/1.1
\r\n
\r\n
可以看到漏洞确实存在。
漏洞分析
提取源码
为了方便调试,使用php镜像内置的docker-php-source命令提取源码,并通过docker cp命令复制到虚拟机,再复制一份到主机用代码工具打开。
安装gdb
为了调试,首先安装gdb:
wget http://ftp.gnu.org/gnu/gdb/gdb-11.2.tar.gz
运行./configure,遇到问题:
no acceptable C compiler found in $PATH
解决问题:
apt-get install build-essential
再次运行,没问题,然后make开始编译,等待许久,遇到问题:
configure: error: GMP is missing or unusable
解决问题:
apt-get install libgmp-dev
继续make,继续等待许久编译完成,运行make install完成安装。
最后运行gdb -v看看安装结果:
aluvion@aluvion-virtual-machine:~/桌面/gdb/gdb-11.2$ gdb -v
GNU gdb (GDB) 11.2
Copyright (C) 2022 Free Software Foundation, Inc.
License GPLv3+: GNU GPL version 3 or later <http://gnu.org/licenses/gpl.html>
This is free software: you are free to change and redistribute it.
There is NO WARRANTY, to the extent permitted by law.
编译安装PHP
进入提取出来的PHP源码目录,开始配置安装选项,设置好安装路径和调试选项:
./configure --prefix=/home/aluvion/桌面/php --enable-debug
遇到问题:
checking for libxml-2.0 >= 2.7.6... no
configure: error: The pkg-config script could not be found or is too old.
看起来是libxml版本不够,解决问题:
apt-get install pkg-config libxml2-dev
继续configure,下一个问题:
No package 'sqlite3' found
解决问题:
apt-get install libsqlite3-dev sqlite3
继续configure,没问题,开始make编译,又是等待许久,也没问题,make install完成安装,最后php -v看看结果:
aluvion@aluvion-virtual-machine:~/桌面/php/bin$ ./php -v
PHP 7.4.21 (cli) (built: Feb 1 2023 21:00:05) ( NTS DEBUG )
Copyright (c) The PHP Group
Zend Engine v3.4.0, Copyright (c) Zend Technologies
在bin目录下写一个phpinfo.php,重新开启内置服务器并验证一次漏洞存在。
源码分析
根据参考文章,在php_http_parser.c中找到HTTP包的处理函数php_http_parser_execute,该函数会根据state的不同做不同的处理,如对HTTP状态码、版本、Headers等信息的处理。
依次运行命令开始调试:
gdb ./php # 开始调试
b php_http_parser_execute # 给函数下断点
r -S 0.0.0.0:8080 # 开启内置服务器
使用bp发包,可以看到此时的state为s_start_req:
(gdb) p state
$1 = s_start_req
当Headers处理完后state为s_headers_almost_done,走到1355行:
if (parser->content_length == 0) {
/* Content-Length header given but zero: Content-Length: 0\r\n */
CALLBACK2(message_complete);
state = NEW_MESSAGE();
} else if (parser->content_length > 0) {
/* Content-Length header given and non-zero */
state = s_body_identity;
} else {
if (parser->type == PHP_HTTP_REQUEST || php_http_should_keep_alive(parser)) {
/* Assume content-length 0 - read the next */
CALLBACK2(message_complete);
state = NEW_MESSAGE();
} else {
/* Read body until EOF */
state = s_body_identity_eof;
}
}
由于没有content-length头,所以会进入else代码块,走到CALLBACK2(message_complete),CALLBACK2的定义如下:
#define CALLBACK2(FOR) \
do { \
if (settings->on_##FOR) { \
if (0 != settings->on_##FOR(parser)) return (p - data); \
} \
} while (0)
看起来是从settings里面找到对应的函数来调用,可以在gdb里面打印一下settings看看:
可以看到,实际调用的就是php_cli_server_client_read_request_on_message_complete函数,可以在php_cli_server.c里面找到这个函数,该函数会继续调用php_cli_server_request_translate_vpath函数,
继续调试该函数,可以找到其关键代码如下:
pefree(request->vpath, 1);
request->vpath = pestrndup(vpath, q - vpath, 1);
request->vpath_len = q - vpath;
request->path_translated = buf;
request->path_translated_len = q - buf;
将/phpinfo.php赋值给了request->vpath,长度12赋值给了request->vpath_len,phpinfo.php的绝对路径/home/aluvion/桌面/php/bin/phpinfo.php赋值给了request->path_translated,长度40赋值给了request->path_translated_len(似乎中文字占不止一个长度)。
触发漏洞的poc实际上是两个HTTP包,所以它会第二次来到php_cli_server_request_translate_vpath函数,此时由于访问的是/,是一个目录,所以会走到不同的代码块:
if (sb.st_mode & S_IFDIR) {
const char **file = index_files;
if (q[-1] != DEFAULT_SLASH) {
*q++ = DEFAULT_SLASH;
}
while (*file) {
size_t l = strlen(*file);
memmove(q, *file, l + 1);
if (!php_sys_stat(buf, &sb) && (sb.st_mode & S_IFREG)) {
q += l;
break;
}
file++;
}
if (!*file || is_static_file) {
if (prev_path) {
pefree(prev_path, 1);
}
pefree(buf, 1);
return;
}
}
找不到index.php和index.html,最后会走到return,所以request->path_translated实际上没有变化:
回到php_cli_server_do_event_loop函数,然后经过php_cli_server_recv_event_read_request函数继续走到php_cli_server_dispatch函数,首先会判断文件后缀:
int is_static_file = 0;
const char *ext = client->request.ext;
SG(server_context) = client;
if (client->request.ext_len != 3
|| (ext[0] != 'p' && ext[0] != 'P') || (ext[1] != 'h' && ext[1] != 'H') || (ext[2] != 'p' && ext[2] != 'P')
|| !client->request.path_translated) {
is_static_file = 1;
}
如果后缀长度不是3,或者后缀不是php(无论大小写),或者request.path_translated不存在,则认为访问的是静态资源,将is_static_file设置为1。由于php在解析第二个包时没有置空request->path_translated,而且访问的是/,所以这里发生了矛盾。php认为访问的是静态文件,但类似UAF,由于没有置空文件路径,导致该静态文件的路径实际上是一个php文件。
最后就是发送静态文件了:
if (SUCCESS != php_cli_server_begin_send_static(server, client)) {
php_cli_server_close_connection(server, client);
}
将php文件当作静态文件发送给了客户端,导致了源码泄露。
漏洞修复
根据参考文章所说,当处理第二个HTTP包时会判断vpath是否为空,如果不为空说明不是第一个包,PHP就会认为这是一个畸形的包。