PHP Built-in Server是PHP自带的Web服务器,多用于在研发阶段快速启动并运行一个可以执行PHP脚本的Web服务器。由于其性能及安全性并没有得到完好的保障,故PHP官方并不建议在生产环境下使用这个服务器。
1、Windows下可以在文件名后面增加点号(.)来下载到PHP文件源码
2、Windows下通过修改文件名大小写来下载PHP文件源码
3、Windows下通过在文件名后面增加::$DATA来下载PHP文件源码
4、Linux下修改文件后缀的大小写造成解析漏洞
GET /phpinfo.php HTTP/1.1
Host: pd.research
rn
rn
GET / HTTP/1.1
rn
rn
php_cli_server_client_read_request
. 跟踪看起来像这样:main(...)
do_cli_server(...)
php_cli_server_do_event_loop(...)
php_cli_server_do_event_for_each_fd(...)
php_cli_server_poller_iter_on_active(...)
php_cli_server_do_event_for_each_fd_callback(...)
php_cli_server_recv_event_read_request(...)
php_cli_server_client_read_request(...)
php_cli_server_client_read_request
函数调用函数,php_http_parser_execute
顾名思义,就是用来解析HTT当下面提到的请求的第一部分几乎完成解析时:GET /phpinfo.php HTTP/1.1
Host: pd.research
rn
rn
Content-Length
标头,CALLBACK2(message_complete)
在下面的代码中调用。在这里,是一个宏,它会在请求消息处理完成后CALLBACK2
依次调用回调函数。php_cli_server_client_read_request_on_message_complete
if (parser->type == PHP_HTTP_REQUEST || php_http_should_keep_alive(parser)) {
/* Assume content-length 0 - read the next */
CALLBACK2(message_complete); // Here
state = NEW_MESSAGE(); // Afterwards the state is reverted back to start_state
}
#define CALLBACK2(FOR) \
do { \
if (settings->on_##FOR) { \
if (0 != settings->on_##FOR(parser)) return (p - data); \
} \
} \
while (0)
do {
if (settings->on_message_complete) {
if (0 != **settings->on_message_complete**(parser)) return (p - data);
}
} while (0)
php_http_parser_settings
php_http_parser_execute
的引用作为参数传递给函数。nbytes_consumed = php_http_parser_execute(&client->parser, &settings, buf, nbytes_read);
CALLBACK
和CALLBACK_NOCLEAR
宏的工作方式几乎相同。CALLBACK2(message_complete)
结果在调用php_cli_server_client_read_request_on_message_complete(...)
和CALLBACK(path)
调用php_cli_server_client_read_request_on_path(...)
等。static int php_cli_server_client_read_request_on_message_complete(php_http_parser *parser)
{
...
php_cli_server_request_translate_vpath(&client->request, client->server->document_root, client->server->document_root_len);
...
}
很快,我们进入php_cli_server_request_translate_vpath
功能。此函数将请求的 PHP 文件的路径转换为文件系统上的完整路径。如果请求的文件是目录,它会检查目录中是否存在索引文件,index.php
如果index.html
找到,则使用其中一个文件的路径。这允许服务器响应请求提供正确的文件
简而言之,此函数将结构vpath
和path_translated
成员设置为request
结构。所以,对于当前解析的请求,
GET /phpinfo.php HTTP/1.1
Host: pd.research
rn
rn
我们最终进入了**request->path_translated**
设置了的条件分支。这个很重要,后面会用到。
static void php_cli_server_request_translate_vpath(php_cli_server_request *request, const char *document_root, size_t document_root_len) {
...
else {
pefree(request->vpath, 1);
request->vpath = pestrndup(vpath, q - vpath, 1);
request->vpath_len = q - vpath;
// At this time buf is equal to /tmp/php/phpinfo.php where /tmp/php/
// is whatever the server's working directory is.
request->path_translated = buf;
// so the request->path_translated is now /tmp/php/phpinfo.php
request->path_translated_len = q - buf;
...
}
...
}
函数调用堆栈展开后,我们继续执行内部流程php_http_parser_execute
。现在,请求的第二部分被解析为状态恢复为start_state
:
GET / HTTP/1.1
rn
rn
和最初的请求一样,我们进入php_cli_server_client_read_request_on_message_complete
函数,然后调用php_cli_server_request_translate_vpath
. 这个过程用于像第一次请求一样解析和处理后续请求。
这一次,在 内部php_cli_server_request_translate_vpath
,由于我们请求的是目录 ( /
) 而不是文件,因此我们将输入不同的代码块。
...
// loops and checks for index.php, index.html inside working dir
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) {
// In case, index files are not present we enter here
if (prev_path) {
pefree(prev_path, 1);
}
pefree(buf, 1);
return; // This time we return from the function
// and no request->vpath or request->path_translated
// is set.
}
...
最后,在请求解析完成后,我们从php_http_parser_execute
. nbytes_consumed
比较已解析字节长度 ( ) 和已读取字节长度( )的返回值nbytes_read
(更多信息请参见此处)。如果它们相等,代码流继续,我们进入php_cli_server_dispatch
函数。
static int php_cli_server_dispatch(php_cli_server *server, php_cli_server_client *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;
}
...
}
上面提供的代码包括一个检查,以确定请求的文件是否应该被视为静态文件或作为 PHP 文件执行。这是通过检查文件的扩展名来完成的。如果扩展名不是.php
或.PHP
,或者扩展名的长度不等于 3,则认为该文件是静态文件。这通过将is_static_file
变量设置为 1 来指示。
该代码还检查对象的path_translated
字段client->request
是否不为空。该字段包含文件系统上所请求文件的完整路径,用于定位和提供文件。如果该path_translated
字段为空,则表示找不到请求的文件,请求将被视为错误。
代码流继续执行该php_cli_server_begin_send_static
函数,因为is_static_file
它被设置为 true。
if (!is_static_file) {
... // Executes the file as PHP script
} else {
...
if (SUCCESS != php_cli_server_begin_send_static(server, client)) {
php_cli_server_close_connection(server, client);
}
...
}
这就是错误所在。如上述代码块中所示,在解析第二个请求后,vpath
设置为/
并假设未找到索引文件client->request.ext
将设置为NULL
. 但是,client->request.path_translated
仍然设置为/tmp/php/phpinfo.php
来自第一个请求。检查是在client->request.ext
第二个请求的时候执行的,我们进入这个分支并将其设置is_static_file
为1
。基本上,将请求的文件视为静态文件而不是 PHP 脚本。
static int php_cli_server_begin_send_static(php_cli_server *server, php_cli_server_client *client) {
#ifdef PHP_WIN32
...
#else
fd = client->request.path_translated ? open(client->request.path_translated, O_RDONLY): -1;
#endif...
client->file_fd = fd;
...
}
请注意,此函数打开文件描述符并将其检索到存储在client->request.path_translated
. 在我们的示例中,client->request.path_translated
将设置为/tmp/php/phpinfo.php
. 这种差异,即检查发生在client->request.ext
第二个请求上,但随后打开client->request.path_translated
第一个请求设置的文件,导致源代码泄露。
现在文件被标记为is_static_file
,代码流现在只读取 fd 并将其作为静态文件返回,而不是执行它。
PHP 7.4.22 中引入了检查。此修复程序在解析请求路径时检查结构的vpath
成员是否不为 NULL。request
如果它不为 NULL,则函数返回 1。
static int php_cli_server_client_read_request_on_path(php_http_parser *parser, const char *at, size_t length)
{
...
if (UNEXPECTED(client->request.vpath != NULL)) {
return 1;
}
...
}
return 0;
}
解析请求消息第一部分的路径时,client->request.vpath
最初为 NULL,后来设置为/phpinfo.php
。但是,当解析请求的第二部分的路径时,client->request.vpath
已经设置了而不是 NULL,这导致函数返回 1。
#define CALLBACK(FOR) \
do { \
CALLBACK_NOCLEAR(FOR); \
FOR##_mark = NULL; \
} while (0)
#define CALLBACK_NOCLEAR(FOR) \
do { \
if (FOR##_mark) { \
if (settings->on_##FOR) { \
if (0 != settings->on_##FOR(parser, \
FOR##_mark, \
p - FOR##_mark)) \
{ \
return (p - data); \
} \
} \
} \
} while (0)
php_cli_server_client_read_request_on_path
在解析第二个请求的路径时,我们从CALLBACK(path)
进入这个修补过的函数。宏检查确保回调函数的CALLBACK(path)
返回值始终为 0。如果不是这种情况,我们将从解析函数php_http_parser_execute
返回,返回值将是它在解析请求时已经消耗的字节数。
返回值存储在nbytes_consumed
变量中并与nbytes_read
(即请求中的实际字节数)进行比较。
nbytes_consumed = php_http_parser_execute(&client->parser, &settings, buf, nbytes_read);
if (**nbytes_consumed != (size_t)nbytes_read**) {
if (php_cli_server_log_level >= PHP_CLI_SERVER_LOG_ERROR) {
if (buf[0] & 0x80 /* SSLv2 */ || buf[0] == 0x16 /* SSLv3/TLSv1 */) {
*errstr = estrdup("Unsupported SSL request");
} else {
*errstr = estrdup("Malformed HTTP request");
}
}
return -1;
}
如果解析器消耗的字节数不等于读取的字节总数,则意味着请求格式错误。在这种情况下,代码会检查缓冲区的第一个字节以确定请求是否为 SSL 请求。否则,它将错误消息设置为“ Malformed HTTP request ”并返回。
在解析 HTTP 请求期间,当某些回调被多次调用时,REQUEST_URI
服务器变量会被其自身的子字符串覆盖。
在某些情况下,此行为可能导致开放重定向或跨站点脚本 (XSS) 攻击。这是一个例子:
示例片段:
<a href="<?php echo htmlentities($_SERVER['REQUEST_URI']) ?>">Unexpected url</a>
复制
请求GET /index.php?abcd
将导致呈现为:
<a href="/index.php?abcd">Unexpected url</a>
复制
超链接将始终相对于它所在的域。此外,该路径会将元字符转换为其 HTML 实体。因此,XSS 是不可行的。
但是,攻击者仍然可以通过在 URL 中发送带有非常长查询字符串的 GET 请求来利用这一点,例如示例中所示的那个。
GET /?[AAAA...<1425 times>]javascript:alert(1) HTTP/1.1
Host: pd.research
复制
被REQUEST_URI
覆盖,仅以 结尾javascript:alert(1)
。用所需内容成功覆盖它所需的填充量各不相同,可能需要调整。
基本概念验证:
GET /phpinfo.php HTTP/1.1
Host: pd.research
rn
GET / HTTP/1.1
rn
rn
复制
上述请求提供了一个基本的 HTTP 请求作为概念证明,它将公开源代码phpinfo.php
而不是执行它。
确保在 Burp Suite 等拦截 HTTP 代理中关闭“更新内容长度”,以使概念验证生效。
index.php
我们观察到,如果该文件存在于服务器启动的当前目录中,则不会泄露源代码。index.php
然而,我们对漏洞利用 POC 进行了轻微修改,无论文件是否存在,它都会公开源代码。其原因在于上面对bug的解释。
升级后的 POC:
GET /index.php HTTP/1.1
Host: pd.research
rn
GET /xyz.xyz HTTP/1.1
rn
rn
原文始发于微信公众号(山石网科安全技术研究院):PHP开发服务器远程源代码泄露漏洞原理剖析