前言 神奇的漏洞。
漏洞影响 PHP <= 7.4.21
环境搭建 开一个vultr云主机,安装docker下载php:7.4.21-apache镜像并下载到本地虚拟机里导入,开启镜像把8080端口映射到虚拟机的80端口。
最后进入虚拟机,开启内置服务器:
验证漏洞,要把bp的自动更新content-length的选项取消:
1 2 3 4 5 6 7 GET /phpinfo.php HTTP/1.1 Host : 192.168.88.129 \r\n \r\nGET / HTTP/1.1 \r\n \r\n
可以看到漏洞确实存在。
漏洞分析 提取源码 为了方便调试,使用php镜像内置的docker-php-source命令提取源码,并通过docker cp命令复制到虚拟机,再复制一份到主机用代码工具打开。
安装gdb 为了调试,首先安装gdb:
1 wget http://ftp.gnu.org/gnu/gdb/gdb-11 .2 .tar.gz
运行./configure,遇到问题:
1 no acceptable C compiler found in $ PATH
解决问题:
1 apt-get install build-essential
再次运行,没问题,然后make开始编译,等待许久,遇到问题:
1 configure: error : GMP is missing or unusable
解决问题:
1 apt-get install libgmp-dev
继续make,继续等待许久编译完成,运行make install完成安装。
最后运行gdb -v看看安装结果:
1 2 3 4 5 6 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源码目录,开始配置安装选项,设置好安装路径和调试选项:
1 ./configure --prefix=/home/aluvion/ 桌面/php --enable-debug
遇到问题:
1 2 checking for libxml-2 .0 >= 2 .7 .6 ... noconfigure : error: The pkg-config script could not be found or is too old.
看起来是libxml版本不够,解决问题:
1 apt-get install pkg-config libxml2-dev
继续configure,下一个问题:
1 No package 'sqlite3' found
解决问题:
1 apt-get install libsqlite3-dev sqlite3
继续configure,没问题,开始make编译,又是等待许久,也没问题,make install完成安装,最后php -v看看结果:
1 2 3 4 aluvion @aluvion-virtual-machine:~/桌面/php/bin$ ./php -vPHP 7 .4 .21 (cli) (built: Feb 1 2023 21 :00 :05 ) ( NTS DEBUG )Copyright (c) The PHP GroupZend Engine v3.4 .0 , Copyright (c) Zend Technologies
在bin目录下写一个phpinfo.php,重新开启内置服务器并验证一次漏洞存在。
源码分析 根据参考文章,在php_http_parser.c中找到HTTP包的处理函数php_http_parser_execute,该函数会根据state的不同做不同的处理,如对HTTP状态码、版本、Headers等信息的处理。
依次运行命令开始调试:
1 2 3 gdb ./php b php_http_parser_execute r -S 0.0.0.0:8080
使用bp发包,可以看到此时的state为s_start_req:
1 2 (gdb) p state $1 = s_start_req
当Headers处理完后state为s_headers_almost_done,走到1355行:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 if (parser->content_length == 0 ) { CALLBACK2(message_complete); state = NEW_MESSAGE(); } else if (parser->content_length > 0 ) { state = s_body_identity; } else { if (parser->type == PHP_HTTP_REQUEST || php_http_should_keep_alive(parser)) { CALLBACK2(message_complete); state = NEW_MESSAGE(); } else { state = s_body_identity_eof; } }
由于没有content-length头,所以会进入else代码块,走到CALLBACK2(message_complete),CALLBACK2的定义如下:
1 2 3 4 5 6 #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函数,
继续调试该函数,可以找到其关键代码如下:
1 2 3 4 5 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函数,此时由于访问的是/,是一个目录,所以会走到不同的代码块:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 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函数,首先会判断文件后缀:
1 2 3 4 5 6 7 8 9 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文件。
最后就是发送静态文件了:
1 2 3 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