【漏洞复现】CVE-2026-109618 / 109619(php2shell) 复现与边界分析:触发面很宽,武器面很窄
原创
devildollking
devildollking
熔城Sec
2026年10月23日 14:00
辽宁
去阅读
读完这篇你会知道:为什么一个只有 echo \"OK\" 的页面也能被卷进来;为什么同一份 PoC 在 32 位 PHP 上跑不通(不是参数没调对);以及「定向打一个站」和「批量扫全网」之间差的是哪一整个工程量。
一、先把结论放前面
三句话:
1. 漏洞是真的,面很宽。PHP 5.4.0 到 8.5.x 全系列受影响。只要 session.upload_progress 是默认开着的,一个页面只有 <?php echo \"OK\
\"; ?> 也会被卷进来——不需要 session_start(),不需要上传逻辑,不需要认证。
2. 稳定 RCE 不是通用的。这条链依赖 OOM 打断 session 初始化后留下的进程级脏状态,再靠堆块精确重占完成写入。换位数、换 SAPI、换发行版、换配置,堆几何全部重来。要注意区分:这是「某一份 PoC 的边界」,不是「漏洞的边界」——漏洞本身还有别的触发路径,后面第三节和第八节会分开讲。
3.「一个 PoC 通杀全网」是误读。能通用的是「触发崩溃」的验证包,不是「拿到命令执行」的利用链。
本文在隔离虚拟机里完成复现(自建靶场,全部命令为 id 这类无害验证),环境为 PHP 8.4.26 + Apache prefork + mod\_php,最终取得:
PROOF: uid=33(www-data) gid=33(www-data) groups=33(www-data)
同时我也在 32 位环境上做了完整对照,结论是同一条链在 i386 上结构性不成立——公开分析里对 32 位的讨论比较少,所以这部分我写得细一些。
图 1:A 请求撑爆内存 → 会话引用悬垂 → B 请求字符串落位 → pDestructor 被改成 zif\_system
二、漏洞概况
| 漏洞编号 | CVE-2026-109618、CVE-2026-109619 GHSA-w8hw-w9fx-j79w / GHSA-6wmf-4rv5-p3rp |
| 代号 | php2shell |
| 组件 | PHP 运行时(session 扩展、OPcache、错误处理路径) |
| 影响版本 | PHP 5.4.0 至 8.5.x;8.5.x < 8.5.12、8.4.x < 8.4.27、8.3.x < 8.3.36、8.2.x < 8.2.35 |
| 修复版本 | 8.5.12 / 8.4.27 / 8.3.36 / 8.2.35;8.1 及更早无官方发布版本,官方提供了源码级最小回移分支(如 PHP-7.4-CVE-2026-109618-109619) |
| 评分 | CVSS v4.0 9.2(两个 CVE 均为 9.2) |
| 本质 | OOM 长跳转绕过清理 → 进程级全局残留悬垂指针 → 跨请求 UAF → 函数指针劫持 |
| 触发条件 | 未认证;完整利用需要两个请求——A 造脏状态,B 复用 |
| 已知路径 | 会话上传进度(本文复现)、fatal error backtrace、OPcache 类型混淆 |
| 暴露面 | 披露前(10-20)第三方统计约 150 万台暴露主机自报 PHP,其中约 88.4 万台(59%)为 ≤ 8.1——这部分没有官方修复版本,只能靠回移补丁或隔离 |
| 在野情况 | watchTowr 蜜罐在披露当日观测到利用;AWS MadPot 蜜罐称披露前已有尝试;披露时尚未进入 CISA KEV |
两个编号同源,但分工不同:109618 是「内存耗尽时提前结束请求,留下未初始化或已释放的数据」;109619 是「工作进程在请求释放后仍保留过期引用,被下一个请求用上」。单独一个只能造成崩溃或信息泄露,合起来才是 RCE。
官方公告明确写了:没有配置层面的措施能构成完整缓解,关闭某个开关只能挡住一条已知路径。这一点在后面「处置」一节会展开,因为一个常见的简化说法是「关掉 upload\_progress 就安全」——它只挡住了四条路径里的一条。
三、触发面:为什么目标页面不需要 session\_start()
这是整个漏洞最容易被低估的地方。PHP 从 5.4 起引入 session.upload_progress,源码里的默认值是开:
STD_PHP_INI_BOOLEAN(\"session.upload_progress.enabled\", \"1\", ZEND_INI_PERDIR, ...)
它的作用是在 multipart 解析过程中上报上传进度。问题在于,这个回调注册在请求初始化阶段、业务脚本执行之前,内部会直接调用 php_session_initialize()。也就是说,只要 multipart 请求里出现 PHP_SESSION_UPLOAD_PROGRESS 这个字段名,不管你的代码有没有写过 session_start(),session 生命周期都会走一遍。
于是攻击面从「用了 session 的应用」扩大到「所有处理 Web 请求的 PHP 安装」。Wiz 的实测中,WordPress、Drupal、Joomla 都在受影响之列——它们的应用代码本身没有任何问题。
另外两点容易忽略:
- 这个开关是
PHP_INI_PERDIR,ini_set()和 FPM 的.user.ini都改不了,必须在 php.ini / FPM pool / 服务配置里改并重启 worker。 - 官方标注 mod\_php 风险最高,原因在进程模型:前两个中断点依赖「请求中途失败、进程继续活着并走完关闭流程」,而 FPM 在请求启动失败时会以
FPM_EXIT_SOFTWARE结束 worker,脏状态没有被下一个请求消费的机会。但这不等于 FPM 安全:第三个中断点(反序列化全局残留、fatal backtrace、OPcache 类型混淆)发生在请求启动成功之后,worker 会活下来,FPM 同样成立。所以「我们跑的是 FPM」不是放松的理由。
四、脏状态为什么能跨请求活下来
这部分是 109618 的核心,也是我读源码时最想确认的一环。结论不是「PHP 忘了清理」,而是清理逻辑本身有缺口。
4.1 发 Cookie 失败的路径,标志没有复位
php_session_initialize() 在生成新 id 后会置 PS(send_cookie) = 1,然后调用 php_session_reset_id()。而它里面是这样的(8.4.26 原文):
if (PS(use_cookies) && PS(send_cookie)) {
zend_result cookies_sent = php_session_send_cookie();
if (UNEXPECTED(cookies_sent == FAILURE)) {
return FAILURE; /* 直接返回 */
}
PS(send_cookie) = 0; /* 只有成功才清 0 */
}
而 php_session_send_cookie() 的第一段就是:
if (SG(headers_sent)) {
... /* E_WARNING: Session cookie cannot be sent ... */
return FAILURE;
}
「清 0」写在成功分支里,失败就直接 return 了。于是 send_cookie 停在 1,成了一个没有被撤销的意图。
4.2 RINIT 的复位列表里,恰好没有它
下一个请求进来时,RINIT 会调用 php_rinit_session_globals(),把一批字段复位:
PS(id) = NULL;
PS(session_status) = php_session_none;
PS(in_save_handler) = 0;
PS(set_handler) = 0;
PS(mod_data) = NULL;
PS(mod_user_is_open) = 0;
PS(define_sid) = 1;
PS(session_vars) = NULL;
ZVAL_UNDEF(&PS(http_session_vars));
id、http_session_vars、mod_data 都清了——但 send\_cookie 不在这个列表里。
这就是 109619 的入口:PS() 是进程级全局,mod\_php / FPM 的 worker 又是长驻的,于是「上一个请求没撤销的意图」直接变成了「下一个请求的初始状态」。
4.3 OOM 把最后一道保险也打掉了
正常请求结束会走 RSHUTDOWN,把状态收拾干净。但 memory_limit 耗尽走的是 zend_bailout()——本质是 longjmp,直接从当前 C 栈跳回请求顶层。中间那些「没跑完的构造、没挂上的链表、没复位的指针」全部被跳过。
(这一节讲的是本文复现的那条路径。另外两个中断点——反序列化全局残留、fatal backtrace 与 OPcache——机制不同,但都源于同一个 longjmp 处理模型,见 8.1。)
图 2:send\_cookie 失败但标志保留 → OOM 长跳转 → 收尾被跳过 → 悬垂状态留给下一个请求
把三件事连起来:标志没清(4.1)+ RINIT 不复位(4.2)+ OOM 跳过收尾(4.3),一个跨请求的悬垂状态就成立了。
五、从 UAF 到 RCE 的四步
第一步:A 请求制造悬垂
请求形状的关键点有三个,每一个都对应源码里的一条判断:
| 要素 | 作用 | 源码依据 |
|---|---|---|
| 1001 个 POST 字段 | 触发 max_input_vars 警告,警告输出使 headers_sent=1 |
默认上限 1000,多一个就报 |
PHPSESSID=\\x00 |
强制生成新 id 并置 send_cookie=1 |
if (!PS(id) || !ZSTR_VAL(PS(id))[0]) |
| 12MB 字段 | 在 php_session_initialize() 内部耗尽 memory_limit |
必须落在初始化过程中,不能在脚本执行阶段 |
第二步:B 请求把受控数据压到目标地址上
这一步是整个链里最不「通用」的地方。在这条链里,攻击者能精确控制的堆内容基本只有一种:POST 字段值的字符串缓冲区。
PHP 为每个字段值分配 zend_string,把值抄进字符区。头部是分配器写的,攻击者改不了;字符区才是可控字节。所以「在地址 X 写入我想要的内容」就等价于一个几何条件:
字符区地址 = 块起点 + 头部长度
⇒ 块起点必须 = X − 头部长度
X 是那个悬垂引用对象的地址,头部长度由 ABI 决定(64 位 24 字节,32 位 16 字节)。这条约束后面会引出 32 位为什么走不通。
第三步:泄漏两个基址
UAF 只给出「写哪里」的机会,还得先知道「基址是多少」:
堆基址——让 ZSTR_LEN(PS(id)) 读到一个受控的大长度,php_url_encode() 就会把整段堆内容 URL 编码后写进 Set-Cookie。发 Cookie 的那一行本身就是泄漏点:
e_id = php_url_encode(ZSTR_VAL(PS(id)), ZSTR_LEN(PS(id)));
实测一次能带出 4 KB 量级的堆内容,其中同一个 chunk 基址重复出现几十次,取众数即可。
代码基址——让 ZSTR_LEN 恰好读到相邻 HashTable 的 nTableSize,数据区正好是 nNextFreeElement | pDestructor。而 pDestructor 就是 ZVAL_PTR_DTOR,减去符号偏移就得到 libphp.so 基址。符号偏移可以从容器里的 ELF 符号表现场解析,不需要硬编码。
第四步:劫持析构函数
最后一步不需要「骗」任何东西,PHP 自己会调用它。php_session_track_init() 里有这么一段:
/* Unconditionally destroy existing array -- possible dirty data */
zend_delete_global_variable(var_name);
if (!Z_ISUNDEF(PS(http_session_vars))) {
zval_ptr_dtor(&PS(http_session_vars));
}
那句注释写得很诚实——「possible dirty data」。它知道这里有脏数据,但只做了 Z_ISUNDEF 判断,没有判断「这个 zval 指向的内存还有没有效」。
把 PS(http_session_vars) 改造成指向伪造 HashTable 的 reference,析构链就会自然跑起来:
php_session_track_init()
zval_ptr_dtor(PS(http_session_vars))
zend_reference_destroy()
zval_ptr_dtor(&ref->val) /* IS_ARRAY_EX */
zend_array_destroy(fake HT)
zend_hash_destroy(fake HT)
pDestructor(&bucket->val) /* = zif_system */
zend_hash_destroy 的本意是「对每个桶的值调用它的析构函数」。攻击者只是把「析构函数」换成了 system——于是「清理」变成了「执行」。这是最经典的虚函数指针劫持,只不过发生在 PHP 内部对象模型上。
(完整的字符串落位构造与伪造对象布局略。)
六、实测结果
环境:
| 宿主 | Kali amd64(x86\_64 / LONG\_BIT=64) |
| 容器 | php:8.4.26-apache ,Debian 12,Apache prefork 单 worker |
| 关键配置 | memory_limit=8M 、post_max_size=64M、display_errors=On、session.use_strict_mode=0 |
| 网络 | --network host ,目标页面 `<?php echo \"OK\ |
| \"; ?>` |
三个阶段全部一次命中,没有重试:
| 环节 | 实测读数 | 含义 |
|---|---|---|
| 环境自检 | PHP_INT_SIZE=8 、ELF 64-bit x86-64 |
64 位门禁通过 |
| 符号偏移 | zif=0x465370 dtor=0x65eb60 htdata=0x690d8 |
由容器内 ELF 符号表现场解析,不用手改常量 |
| stage1 | chunk base = 0x7f415b400000 |
堆基址泄漏成功(attempt 1) |
| stage2 | libphp base = 0x7f415cfac000 |
代码基址泄漏成功(attempt 1) |
| stage3 | HTDATA=0x7f415b4690d8 ZIF=0x7f415d411370 |
伪造对象与函数指针就位(attempt 1) |
| 结果 | PROOF: uid=33(www-data) |
命令以 Apache 工作进程身份执行 |
两组地址可以互相验算,这也是判断「真的打通了」而不是「凑出个数字」的办法:0x7f415cfac000 + 0x465370 = 0x7f415d411370,0x7f415b400000 + 0x690d8 = 0x7f415b4690d8,都对得上。
三个阶段拿到的地址互不相关是正常的:每个阶段都独立重新泄漏一次,ASLR 每轮重排。判断成败看的是「是否 attempt 1」,不是地址数值。
图 3:实测终端输出(Kali amd64 + php:8.4.26-apache)。红框为 id 执行后回显的 proof;三个 stage 均为 attempt 1
七、为什么 32 位走不通(本文的重点差异)
我最初在一台 32 位 Kali 上做这套复现,stage1、stage2 都通了,stage3 连续二十多轮失败。换成 64 位之后一次就过。这不是运气,是几何问题。
7.1 两个数字
- 引用对象
zend_reference:32 位下是 gc 8 + zval 16 + sources 4 = 28,向 8 取整为 32 字节,落在 size class 32;64 位下也恰好是 32 字节,同样落 bin-32。
* 15 字节 carrier 的字符串
:64 位是 头 24 + 内容 15 + 结尾 1 = 40,落 bin-40;32 位是 头 16 + 内容 15 + 结尾 1 = 32,落 bin-32。
7.2 结论:32 位是「自己撞自己」
64 位的 carrier 和要覆盖的引用分属两个不同的 size class,两套网格相互独立,可以靠错开 8 字节正好咬合。
32 位的 carrier 却和引用挤在同一个 size class 里:同一个 free list、同一套 32 字节网格。实测 R0 % 32 = 16,即该 bin 的所有块起点都落在 16 这个格子上;而「字符区压中 R0」要求块起点 = R0 − 16,恰好落在两个格点正中间——分配器永远不会返回这个地址。
R0 ≡ 16 (mod 32) ← 引用块起点
chars = slot + 16 ← 字符串字符固定偏移
slot = R0 − 16 ≡ 0 (mod 32) ← 需要的块起点
0 ≠ 16 ← 恒不成立
换成别的 size class 也不行:不同 size class 用不同的 run 和页,地址段完全不同,物理上覆盖不到。
图 4:左边需要的起点落在两格中间,右边跨 bin 刚好咬合
7.3 这不是「参数没调对」
两个实测可以佐证:
| 实验 | 变量 | 结果 |
|---|---|---|
| 扫 A 的 pad 数 0–20 | 21 组 | R0 偏移恒为 0x54a10,21 行完全相同,无一命中 |
| 扫会话 id 长度 | 6 档,26–42 字符 | R0 恒为同一地址,% 32 恒为 16 |
也就是说,pad 数、字段长度、喷射数量这三个维度都动不了栅格。真正决定栅格的是结构体布局和分配器尺寸类,这两个都由 ABI 和编译配置写死。
一个容易踩的坑:调试时用 blob.find(tag) 找落点,很容易命中多部分请求体的原始缓冲区(连续、步长约 50、单调下降),而不是分配出来的 zend_string。必须按真实的字符串头结构扫描,否则所有落点数据都不可信——我在这上面走过一整晚的弯路。
八、边界分析:定向攻击与批量探测
这一节想拆开一个常被混在一起的问题:漏洞本身的可利用性,和某一份公开 PoC 的适用面,并不是同一件事。把两者分开,「通杀」和「危害不大」这两种说法才能各自归位。
8.1 先分层:漏洞的前提,和这份 PoC 的前提
官方与原始研究把可利用性拆成三个「中断点」。它们才是漏洞层面的前提:
| 中断点 | 触发路径 | mod\_php | PHP-FPM |
|---|---|---|---|
| ① 哈希表先计数后分配 | 自动 POST 变量注册(urlencoded 或 multipart) | 中招 | worker 直接退出 |
| ② 上传进度早于模块激活 | 带进度字段、无文件部件的 multipart 请求 | 中招 | worker 直接退出 |
| ③ 反序列化全局残留、fatal backtrace、OPcache | 请求启动成功之后的致命错误 | 中招 | 同样中招 |
而我在本文里复现的那份公开 PoC,走的是中断点 ②,并且额外依赖下面这几条。这才是「边界」真正所在:
| 这份 PoC 的要求 | 性质 | 说明 |
|---|---|---|
| 用「单个超长字段撑爆内存」这一种投递方式 | 实现选择 | 因此才要求 post_max_size > memory_limit。漏洞本身还有别的投递方式,见 8.2 |
display_errors = On |
PoC 当前进度 | 无 php.ini 时硬编码默认就是 On;production 模板里是 Off |
| 64 位 | 架构 | 32 位几何不成立(第七节),换写原语才能重来 |
| mod\_php + prefork,且 A、B 落在同一 worker | 架构 + 工程 | FPM 下中断点 ② 不成立;单 worker 时命中率 100%,多 worker 需多轮 |
| 命令 ≤ 14 字节 | 载体尺寸 | 不是漏洞限制,换更大载体或 sh</tmp/c 二次加载即可 |
先说一个我一开始也判断错的地方。调试阶段我曾认为「出厂 8M / 128M 的比例反着,默认配置因此打不动」——这个判断是错的。它只对「单字段撑爆」这一种投递方式成立。原始研究给出的三种投递原语,在默认配置下照样能吃穿 memory_limit。所以默认配置不能当成安全边界。下面就把这三条展开。
8.2 三种绕过 post_max_size 的投递原语
PHP 判断「请求是否超限」时,只看声明的 Content-Length,不核对实际消耗的字节。这个不对称产生了几条绕过路径:
| 原语 | 机制 |
|---|---|
| A. 长度声明不一致 | chunked 编码或 HTTP/2 不带 Content-Length 的 multipart,可以越过 post_max_size 这道闸门 |
| B. 输入解压过滤器 | 用 Content-Encoding 制造「压缩后长度」与「解码后流量」的错位,比的是前者,耗的是后者 |
| C. 空文件上传放大 | filename=\"\" 的部件不含数据、也不计入 max_file_uploads,但 PHP 仍会为它建 6 个 $_FILES 条目;字段名再嵌套成 files[a][b][c] 时,这 6 个条目各自都要重建整条嵌套路径,一条短记录就被放大成大量哈希表、桶和字符串键 |
第三条最值得注意:原始研究的数字是数百条空文件记录即可吃掉超过 128 MiB 堆,而整个 HTTP 请求体仍小于 8 MiB。这意味着「限制上传个数」「限制单文件大小」这类传统手段完全无效——它们管的维度,跟被放大的维度不是同一个。
所以对防御方来说,真正要卡的是解码后的请求形态:分段数、字段数、嵌套深度,以及「没有可用 Content-Length」的请求。只设一个 post_max_size 是挡不住的。
图 5:漏洞的触发面很宽,收窄发生在「特定 PoC + 特定配置 + 特定 ABI」这一层
8.3 定向攻击:真正需要知道什么
下面这套流程写出来,是为了让防守方知道自己面对的是什么量级的对手、哪些环节可以被打断,不是操作手册。流程本身是五步,真正花时间的是第四步:
探测版本与配置 → 条件否决 → 本地复刻 → 标定堆几何 → 打目标
探测之所以容易,是因为 PHP 会把配置值写在报错里。
| 要探测的 | 手段 | 判读 |
|---|---|---|
| 版本 | curl -sI 看响应头 |
读到 X-Powered-By;隐藏时改用报错页与静态资源指纹 |
post_max_size |
发 20MB 请求 | 报错里写 exceeds the limit of 8388608 bytes |
memory_limit |
发超过它的请求 | 报错里写 Allowed memory size of ... exhausted |
upload_progress |
发 PHP_SESSION_UPLOAD_PROGRESS + 文件 |
返回 Set-Cookie: PHPSESSID= 即开着 |
use_strict_mode |
带一个固定 PHPSESSID |
cookie 被保留即 0,被换成新 id 即 1 |
display_errors |
发 1001 个字段 | 响应里有 Input variables exceeded 1000 即 On |
要注意的是,这组探测结果的用法在看完 8.2 之后变了:post_max_size 不再是「能不能打」的开关,而是「该挑哪种投递方式」的依据。它是 8M 而 memory_limit 是 128M 时,单字段撑爆走不通,但空文件放大或 chunked 仍有空间。
标定之所以难,是因为堆几何只能试出来,算不出来。OOM 那一刻堆上摆了什么,取决于配置和请求形状共同决定的分配序列;配置一变,整条链上的数值全变。所以必须先在本地用同版本镜像、同配置复刻一遍,反复扫参数直到稳定打通,再把这组值套到目标上。
这里有一个容易被误解的点:标定是「按版本做一次」,不是「按目标做一次」。堆几何取决于 PHP 版本和关键配置,不取决于具体机器——ASLR 只改变基址,地址之间的相对偏移是固定的,而基址由 stage1/stage2 现场泄漏解决。
实际的成本结构是:
| 环节 | 成本 | 是否可复用 |
|---|---|---|
| 指纹探测 | 分钟级 | 每个目标都要做 |
| 本地复刻 + 标定 | 小时到天级 | 同版本同配置可复用 |
| 实际执行 | 秒级 | — |
8.4 批量探测:边界在哪里
批量扫描的困境不是「打不中」——恰恰相反,三种投递原语让盲发的触发率比直觉高——而是打中了也不知道:
- 扫描器无法远程确认
memory_limit、SAPI 类型、worker 是否复用,只能盲发;而每种投递方式敏感的配置项不同,盲发等于同时押几个未知数; - 触发成功和拿到 RCE 的表现很像——都是 worker 崩溃、返回 500 或空响应;
- 把「崩溃」当成「命令执行」,是这类分析里最常见的误判。
公开流传的「一键打全网」,多数情况下是验证包而不是武器包:它能证明「这个站点的 PHP 可能被触发」,不足以证明「这个站点能被拿 shell」。
另外三个降低批量价值的因素:
1. 每次尝试都会打崩一个 worker。Apache 日志里留下 AH00052: child pid ... Aborted (6),服务还会短暂抖动。隐蔽性极差。
2. 命令长度上限 14 字节。这不是漏洞限制,是当前载体尺寸的限制,但意味着「打进去」之后还得靠 sh</tmp/c 这类短命令二次加载。
3. 常量搬不过去。PoC 内置的 lab profile 写死 zif=0x43A870、dtor=0x613C50;而我在 php:8.4.26-apache 上现场解析到的是 0x465370 与 0x65eb60。同一个大版本,偏移已经不同——所以每一处都得现场解析,不能照抄。
8.5 客观结论
| 场景 | 可行性 | 理由 |
|---|---|---|
| 批量扫全网拿 shell | 低 | 触发不难,难在三点:无法确认结果、每个发行版的偏移要重标定、把崩溃误判成成功 |
| 定向打一个已知站点 | 中高 | 配置可远程探测、几何可本地预演、只需成功一次;代价是整个标定周期 |
| 触发 DoS / 服务抖动 | 高 | 不需要精确几何,OOM 即可 |
| 信息泄露(堆 / 代码基址) | 高 | stage1/stage2 容错度高,32 位也能通 |
一句话:漏洞面很宽,但能稳定落地的武器面很窄。它在定向场景里是真实的威胁,在批量场景里更接近「制造混乱」而不是「规模化拿权限」。
九、检测与处置
9.1 高价值检测点(按出现顺序)
| 阶段 | 特征 | 说明 |
|---|---|---|
| 形态 | multipart 且带 Transfer-Encoding: chunked、没有可用的 Content-Length |
高置信 。这是绕 post_max_size 的典型形状,正常浏览器上传不会这样发 |
| 形态 | filename=\"\" 的空文件部件 + 深嵌套字段名(3 层以上)+ 同一请求内大量重复 |
高置信 。这是内存放大器;它不占 max_file_uploads 配额,所以「限制上传个数」拦不住 |
| 形态 | 带 PHP_SESSION_UPLOAD_PROGRESS,但整个请求没有任何文件部件 |
高置信 。正常前端几乎不会产生这种形状,它直接对应中断点 ② |
| 探测 | 超大 POST(数十至数百 MB) | 平时业务不会有这种请求体,access log 长度异常 |
| 探测 | POST Content-Length ... exceeds the limit |
error\_log 里的 Warning,直接暴露攻击者在试上限 |
| 探测 | Input variables exceeded 1000 |
1001 字段的特征,且字段名通常规整递增 |
| 触发 | Allowed memory size ... exhausted in Unknown on line 0 |
注意 Unknown on line 0 ,说明错误发生在用户脚本之前 |
| 触发 | PHPSESSID 作为 multipart 表单字段出现 |
中等置信:对应会话固定化的变体,正常业务不会这样提交,但官方研究没有把它当作必要条件 |
| 利用 | Set-Cookie: PHPSESSID= 长度异常 |
正常会话 id 只有 26–32 字符,这里是数千字节 |
| 后果 | worker 频繁 SIGABRT / SIGSEGV、zend_mm_heap corrupted |
成簇出现时高度可疑 |
按置信度排,最强的三个信号是表格最上面那三条请求形态——它们直接对应两个中断点的触发条件,跟具体 payload 无关,改代码绕不过去。超长 Set-Cookie 是原始研究明确指认的泄露外显,一旦出现基本可以定性。相比之下,PHPSESSID 作为表单字段只是中等置信,不建议单独拿它当告警条件。
另外,「字段数超过 1000」「单字段 12MB」这类特征要谨慎对待:它们是那一份 PoC 的实现选择,不是漏洞的必需形态。只按这两个特征做规则,换一份 payload 就漏了。
9.2 处置优先级
1. 升级到 8.5.12 / 8.4.27 / 8.3.36 / 8.2.35。升级后必须重启所有 PHP worker / FPM pool / Apache 进程——二进制换了,旧进程内存里还是老代码。
2. 无法立即升级时,按官方给的方向逐条削弱:session.upload_progress.enabled=0(掐断中断点 ②)、PHP 8.5 下 fatal_error_backtraces=0(掐断回溯入口)、memory_limit=-1 并用容器 / cgroup 兜住可用性(消掉 OOM 这一条)。这里有个反直觉的地方:官方推荐把内存上限放开,因为「可被攻击者控制的内存上限故障」本身就是被利用的东西;但必须用 php_admin_value 这类应用改不动的方式落地,并且逐个确认没有 pool 或 .user.ini 又把它调回去。这些措施合起来仍不等于完整缓解。
3. 在 Web 服务器 / 代理层限制「解码后」的请求:请求体大小、字段数、multipart 分段数、header 数、嵌套深度,并且必须覆盖没有可用 Content-Length 的请求(chunked、HTTP/2),否则 8.2 里的原语 A 直接绕过。不上传文件的应用,干脆只对上传端点放行 multipart。
4. 缩短 worker 生命周期(pm.max_requests 调到较小值,厂商建议 50 量级),压缩脏状态被下一个请求复用的窗口。注意它防不了单请求内部的那类问题,真要堵死得设成 1,性能上通常不可接受。
5. 别忘了补丁之外的长期项:≤ 8.1 没有官方版本,只能用发行版 / 供应商的回移分支,或者把实例挪到 VPN、白名单后面。PHP 8.2 也已定于 2026-12-31 EOL,不适合当长期方案。
一个别搞反的坑:不要用 RLIMIT_AS / RLIMIT_DATA 去卡内存。那会让 mmap 失败,反而把进程推进那条脆弱的 OOM / bailout 路径。官方在缓解说明里专门点了这一条。
9.3 失陷排查
出现以下任一现象,按「可能已失陷」处理:zend_mm_heap corrupted、Unknown on line 0 的内存耗尽报错、异常超长的 Set-Cookie: PHPSESSID=、worker 频繁 SIGABRT、非法 session id。
对应动作:轮换凭据、排查 WebShell、核对 Apache / PHP worker 日志与反代访问日志、确认所有 worker 都已重启、核对发行版的回移补丁是否真的包含这两个 GHSA 编号(有些包只是跟着升了版本号)。
十、最后
这个漏洞最值得记住的不是「9.2 分」,而是它把一个常被忽略的事实摆到了台面上:
漏洞的触发面和武器的可用面,是两个完全不同的东西。
触发只需要一份默认配置和一个构造过的 POST;武器化却需要精确的镜像、精确的配置、精确的堆几何,以及一次不能出错的地址泄漏。中间隔着的不是「技术难度」,而是一整个工程量——这也解释了为什么披露前统计到的 150 万台暴露主机里有近六成跑在没有官方补丁的版本上,而公开确认拿到 shell 的案例并不成比例。
对防御方来说,结论反而简单:不要因为「利用困难」而降级处理。攻击者不需要通用,只需要对你的那一个目标成功一次。而这类攻击在试探阶段就会留下足够明显的请求形态——抓那一步,比抓最后一跳容易得多。
参考来源
PHP 官方安全公告(2026-10-21) php.net/archive/2026.php
GHSA-w8hw-w9fx-j79w GHSA-6wmf-4rv5-p3rp
Wiz Research 技术披露 wiz.io/blog/php2shell-php-rce
Calif 原始研究(三个中断点与投递原语) calif.io/php2shell/
watchTowr FAQ watchtowr.com/intelligence/php-cve-2026-109618-faq/
Cloudflare 公告(WAF 防护上线) blog.cloudflare.com
PHP 8.4.26 源码 ext/session/session.c、main/main.c、php.ini-production
公开复现仓库 github.com/9bie/php2shell-bypass-aslr-exp
本文仅在隔离虚拟机内、对自建靶场进行验证,所有载荷均为 id 这类无害命令,未对任何未授权系统进行测试。文章省略了完整利用链的实现细节,仅用于防御研究与检测规则建设。请勿利用文中技术从事非法测试,一切后果与作者及本公众号无关。
本文为「熔城Sec」原创,转载需授权并保留出处。请勿利用文章内的相关技术从事非法测试,如因此产生的一切不良后果与文章作者及本公众号无关。公众号现在只对常读和星标的账号展示大图推送,建议设为星标,否则可能收不到更新。
版权声明
本站仅做备份收录,仅供研究与教学参考之用。
读者将信息用于其他用途的,全部法律及连带责任由读者自行承担,本站不承担任何责任。








评论