【漏洞复现】CVE-2026-109618/109619(php2shell)复现与边界分析:触发面很宽,武器面很窄"

admin 2026-10-24 14:05:19 网络安全文章 来源:ZONE.CI 全球网 0 阅读模式

【漏洞复现】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 忘了清理」,而是清理逻辑本身有缺口。

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」原创,转载需授权并保留出处。请勿利用文章内的相关技术从事非法测试,如因此产生的一切不良后果与文章作者及本公众号无关。公众号现在只对常读和星标的账号展示大图推送,建议设为星标,否则可能收不到更新。

KCon2026开幕在即|以100%的AI浓度,期待你的赴约" 网络安全文章

KCon2026开幕在即|以100%的AI浓度,期待你的赴约"

KCon2026黑客大会将于11月1日在北京举行,以AI攻防为核心主题,超六成议题围绕AI/Agent/大模型展开,覆盖推理层到供应链完整攻击面。讲者多为学术会议常客,议题强调真实环境验证与可下载工具。文章公布六个议题亮点,涵盖AIAgent隔离、黑盒AI服务验证、按需反编译、MCP生态安全、AI应用运行时攻防及状态转换Fuzzing等方向,并开放购票通道。
【漏洞复现】CVE-2026-109618/109619(php2shell)复现与边界分析:触发面很宽,武器面很窄" 网络安全文章

【漏洞复现】CVE-2026-109618/109619(php2shell)复现与边界分析:触发面很宽,武器面很窄"

本文复现CVE-2026-109618/109619(php2shell)漏洞,影响PHP5.4至8.5全系列,触发面宽但稳定RCE条件苛刻。漏洞本质为OOM长跳转绕过清理导致跨请求UAF,需两个请求配合利用。作者在64位环境复现成功,32位环境结构性不成立。文章强调需区分PoC边界与漏洞边界,并指出关闭upload\\_progress仅能挡住四条路径之一。
勒索事件日报:2026年-10月-22日" 网络安全文章

勒索事件日报:2026年-10月-22日"

10月22日全球勒索软件攻击事件30起,专业服务与法律机构占比三成,德国企业数据泄露,攻击团伙活动频繁,攻击手法多样化,专业服务成为主要目标,攻击者利用漏洞和凭证暴露,建议加强防护措施。
算力可控价值可见|山石网科发布统一AI网关企业级Token运营平台" 网络安全文章

算力可控价值可见|山石网科发布统一AI网关企业级Token运营平台"

山石网科发布统一AI网关企业级Token运营平台,旨在解决企业AI应用中的成本管理与安全治理问题。产品围绕成本可算、权限可控、风险可防、全程可溯四大能力,提供费用管理、权限控制、内容安全、审计追溯等功能,并支持分阶段部署。建议企业结合自身需求选择版本,并围绕省钱、提效、决策、审计和追责五项结果验证投入价值。
评论:0   参与:  0