前言

神奇的漏洞。


漏洞影响

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就会认为这是一个畸形的包。


参考

PHP Development Server <= 7.4.21 - Remote Source Disclosure


Web PHP

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

NodeJS VM2 沙盒绕过漏洞学习
CVE-2022-42920 BCEL 越界写漏洞