这周最难受的一类人,是手里攥着十来个 PHP 入口的运维。
一边是 CVSS 9.2、未认证 RCE、新闻刷了三天;另一边是现实:WordPress 站点的插件没测过兼容性,祖传的 Drupal 只在 7.4 上跑通过,vendor 目录里塞的那个 php 二进制没人敢碰。一天升不完,这是常态,不是懒惰。
这次两个洞(CVE-2026-109618 和 CVE-2026-109619)不在你的应用里,在 PHP 清理失败请求的那几毫秒里。所以你代码写得再干净也躲不开——它出事前,你一行都还没跑。这篇文章要解决的是排期问题:今晚只能动三台机器,先动哪台。以及怎么确认你真修好了,而不是看起来修好了。
先对个口径,省得你被各种版本的标题绕晕:9.2 是 CVSS v4.0 的分数,NVD 上 v3.1 给的是 9.8。披露乱掉那天流传过"10.0 分"的说法,那是错的。
补丁是 8.2.35、8.3.36、8.4.27、8.5.12 四个版本。8.1 及更早,官方不再发更新。
请求还没进到你的代码,PHP 已经在清理它了
先立一个反直觉的前提:这两个洞的触发阶段,在你的框架、路由、中间件之前。
现代 PHP 收到一个请求,先启动请求上下文,引擎自动把 body 解析出来,multipart/form-data 就是在这一环解析的,然后才调用你的应用代码。自动解析是引擎干的,不经你的手——你也没法在校验函数里拦它,因为那会儿你还没机会执行任何一行代码。
109618 的触发玩法,全长在"引擎自己解析 body"这一环上。共同点一句话:看着没超你设的限额,实际上把 memory_limit 吃穿了。公告点了三种情况。请求没有可信的 Content-Length,解析器预判不了规模。单个 part 的 header 数量没有上限,网络上的字节可以很小,展开到内存里可以很大。空的文件控件不计入 max_file_uploads,可 PHP 照样给它们建元数据数组。三条都不需要你提供一个正常表单——攻击者自己造 HTTP 请求就够了。
内存耗尽以后,PHP 用 longjmp() 从当前上下文里"跳"出去,这个请求的正常收尾流程就直接放弃了。问题出在这个"跳"上:有些上下文里,请求的内存已经可达、内容却还没初始化完。这时候只要有一次分配失败——不用跟那块内存有关系,任何一次失败都行——清理流程就留在无效状态上。接着 request shutdown 去读它、按它分支,攻击就成了。
反常识就反在这个"任何一次"上。攻击者不需要精确命中什么,只要让这个进程在错误的时间点 OOM 一次。这种形状的洞,代码审查防不住,输入校验防不住,“我们没用上传功能"也防不住——出问题的不是你的判断逻辑,是引擎跳出去之后没收拾干净的那摊东西。
所以先把"我代码写得干净"这个心理安慰放一边。决定你这台机器今晚急不急的,不是版本号,是它怎么跑 PHP。
版本号回答的是"有没有补丁”。SAPI 回答的是"你等不等得起"。这是两个问题。
同样是 PHP,mod_php 是来不及防,FPM 是条件成立才防不住
这一段是全文重点,也是中文报道里最容易被糊成一句话的地方。
官方公告说得很直白:风险最高的是 mod_php。它已经被演示出完整利用链,一个未认证请求,在任何应用代码运行之前就能触发。原因很具体——mod_php 挂在 Apache 进程里,request startup 失败了,它照样会执行 request shutdown。唯一能拦住攻击的时机已经错过,后面的清理照跑,而 109618 要吃的就是清理这一步。
PHP-FPM 走的是另一条路。请求启动失败,FPM 直接杀掉这个子进程,正常收尾根本没机会跑,这条路就断了。所以公告的原话是:源码分析支持 FPM 也属于可能受影响的 SAPI,但这个 SAPI 上没有已演示的完整 exploit。
这句话极容易被读成"FPM 安全"。不是。它只说明 109618 这一支上,FPM 少了一环被证实的链条,源码层面仍然列在受影响里。
官方没有演示出 FPM 上的完整利用链,但这句只能读成"少一环",不能读成"安全"。少的那一环是 exploit,不是条件。
更要紧的是第二个洞。109619 是另一套机制:PHP 内部有一批全局状态,临时指向"当前请求的内存"。某些情况下这些状态没被正确重置,或者重置完又被写了回去——请求内存都释放了,全局指针还挂着。等下一个请求在正常初始化之前去消费这块过期状态,就出事了。
它的形状是"生产者 + 消费者",而且两个请求必须落在同一个 worker 上:
- 生产者:一次致命错误。解析失败、编译失败、未捕获异常、
require失败、执行超时,都会在配对的清理动作之前放弃操作。另外,shutdown 阶段扩展发出的非致命诊断,也能把 error 状态在清掉之后再写回去。 - 消费者:初始化期间的 INI 诊断——非法或弃用配置触发的 warning、notice,不用显示、不用记日志都算。还有 session upload progress,默认就开着,也不需要你调用过
session_start()。
mod_php 上,8.5 默认开启的 fatal error backtrace 这条路已经打通了完整 RCE。FPM 上验证过的是 session 生命周期那条:成功启动一次,应用执行期留下状态,再来一次致命错误,下一个请求拿 upload progress 去消费。公告专门写了一句:多 worker 和 worker 回收会降低单请求成功率,但不消除条件。
所以正确的分档是三句话:
- mod_php:来不及防。两条 CVE 都有确认的完整链,先动它。
- FPM:条件成立才防不住。109618 没有已演示的完整 exploit;109619 的 session 路径验证过,但要一次应用级操作、一次致命错误、下一个请求还得撞上同一个 worker。8.5 还要多看一眼
fatal_error_backtraces是不是默认的 1。 - 其它:
php-cgi、FrankenPHP、LiteSpeed 的 LSAPI。官方的判断是"很可能受影响"和"类比 FPM,很可能但未验证"。按同等暴露处理,别因为"没有 exploit 报道"就把它们排后面。
怎么知道自己是哪一档?一条命令:php -i | grep -E '^(Loaded Configuration File|Server API)'。看到 Apache 2.0 Handler 就是 mod_php——这个 SAPI 在宝塔面板、老虚拟主机、有些年头的老项目里还很常见。看到 FPM/FastCGI 才是 FPM。这一步比查版本号重要,因为版本号告诉你"有没有补丁",SAPI 告诉你"等不等得起"。
官方那套缓解措施,最容易死在"配了但没生效"上
公告给的临时缓解,两条都带着同一个前缀要求:得用应用改不掉的方式设置,并且重启每一个 worker。这个要求看着像废话,恰恰是绝大多数人踩坑的地方。
为什么"应用改不掉"是硬要求?因为 109619 的消费发生在正常初始化之前。ini_set() 是应用代码里跑的,.user.ini 也是请求跑起来才生效——等它们执行,消费者早过去了。
所以 session.upload_progress.enabled=0 必须落在 php.ini 里、FPM pool 的 php_admin_value 里,或者服务器配置里。改完不重启 worker,老进程还带着原值在跑,你以为关了,其实没关。这个功能默认开着,用的人却极少——官方自己都建议长期关掉,理由很直白:它属于"极少被测试的攻击面"。
memory_limit=-1 这条要单独说,因为它有两个反直觉的坑。第一,它不是完整缓解。拿掉 PHP 自己的内存上限,是为了不走 longjmp() 那条路;可底层分配失败的时候,PHP 照样可能跳。第二,RLIMIT_AS / RLIMIT_DATA 会反向把那条路打开——ulimit 让 mmap() 失败,正好制造出 109618 想要的失败点。很多人觉得"加个 ulimit 更保险",在这儿是反的。
代价也得算。memory_limit=-1 等于把天花板拆了,一个正常但超大的请求就能拖垮整机。所以配套必须跟上:前端限额按解码后大小、字段数、part 数、header 数、嵌套深度来卡,别忘了"没有可信 Content-Length"的请求;post_max_size 没上传给 8M、有上传给 32M,upload_max_filesize 压在它下面;再有就是 pm.max_children。容器或 cgroup 限额只能当可用性边界,而且要保证它挂的时候只杀单个 worker——杀掉 master,整池带走。
还有个配置差异值得记:fatal_error_backtraces 是 PHP 8.5 才引入的,默认值 1。在 8.2、8.3、8.4 上把它写进配置,你得到的是一个 unknown directive,不是"关掉了"。在 8.5 上它恰恰是 109619 的一条已知通路,官方建议改 0。
至于 error_reporting、display_errors、log_errors、@ 这些"把报错压下去"的手段,对 109619 全无效。原因还是那句:消费发生在初始化前,压不压显示跟它执行不执行没关系。日志干干净净,不能当安全的证据。
这一类配置的共性不是"难配",是"配对了也会白配":要么配在应用改不到的地方,要么压根没重启 worker。
最后说个不常见的建议:pm.max_requests=1,每个 worker 只处理一个请求就退休。跨请求残留状态这事从根上掐死——没有第二个请求,就没有消费者。代价是进程频繁 fork、吞吐明显掉,只适合低流量的入口:内网管理端、低频后台。这是拿性能换确定性的一档选项,不是通用解法。
三个能证伪"我已经修好了"的检查,以及这个洞为什么修了两年
php -v 为什么不可信?三个具体原因:CLI 和 FPM 读的是两份 ini,容器里的 php.ini 和宿主机的也不是同一份;你升级了包但没重启 worker,老进程还在跑旧二进制;一台机器好几个 pool,你只改了其中一个。
所以确认"修好了"要用证据,不能用版本号自报。三个检查,全是只读的。
第一,看清你看的是哪个 SAPI、哪份 ini。php -i | grep -E '^(Loaded Configuration File|Server API)'。FPM 环境要看 pool 的 phpinfo 或者 php-fpm -i,不是终端里那个 php -i。然后 docker exec <容器> php-fpm -v 拿版本,ps -o lstart -p $(pgrep -f 'php-fpm: pool') 拿 worker 的启动时间。只有启动时间晚于你升级的时间,这批 worker 才算换过血。
第二,确认缓解值真的生效在这份配置里。php -i | grep -E 'session.upload_progress.enabled|memory_limit|post_max_size|fatal_error_backtraces',同样要在 FPM 的 phpinfo 里看。注意 8.2 到 8.4 上 fatal_error_backtraces 应该是"不存在",不是 Off——它要是显示 Off,说明你其实在 8.5 上,版本判断错了。
第三,按这个顺序过一遍你名下的机器。先看 Server API 是不是 Apache 2.0 Handler,是就先动;FPM 的看 109619 的前置条件——有没有对外上传、是不是 8.5、fatal_error_backtraces 是不是 1;php-cgi、FrankenPHP、LiteSpeed 按同等暴露处理;再核对引擎版本对不对号:8.2.35、8.3.36、8.4.27、8.5.12;最后确认 worker 全部重启过——pm.max_requests 设小可以加速旧 worker 退场,这本身就是个过渡手段。
至于 PHP 7.4、8.0、8.1:官方给了源码补丁,分支名形如 PHP-7.4-CVE-2026-109618-109619,但那是源码,不是发行版包。没人兜底的话,选项只剩俩:自己编译,或者下线。CISA 的建议也一样直白:一时修不了的,先下线。
再说一个很多报道跳过的部分:这个洞为什么放到了现在。
PHP Foundation 自己在复盘里承认这是"结构性失败"。根因 2024 年就有苗头,一次外部安全审计里,有核心开发修别的问题时已经看到 OOM 处理不对劲的影子,但当时只有理论可能,没有具体触发路径。之后几年,修复方案轮流卡住:有的破坏兼容性,有的引入新的 DoS 面,有的要求大量第三方扩展跟着改。共识制下每种方案都要谈很久,「延续」和「重启」来回了好几轮。8 月 11 日,Calif 的 Ryan A 报告了同根因的无条件 RCE,修法才算真正锁定。
而最后的修法,动的是引擎的 OOM 退出机制本身。8.5.12 的 NEWS 里那条只有一行,“Avoid state corruption via bailout”,公告把它解释清楚了:OOM 时不再 longjmp(),改成在下一个 VM 安全点触发的延迟软退出;内存继续涨到第二个更高的阈值,就 _exit() 硬退。这样 shutdown 再也见不到无效状态,PHP 限制内存的能力也保住了。
还有一件绕不开的事,是这次的披露节奏。10 月 20 日,两个 CVE 编号在论坛和社媒上流出来,还带着错误的分值和错误的发布时间;官方把预公告和发布都提前了一天。更麻烦的是 10 月 21 日早上,比协调披露早了六个多小时,Chainguard 单方面打破禁运,发布了含修复的镜像——按 PHP Foundation 的说法,这等于提前把二进制形态的补丁递到了攻击者手里。同一天,AWS 的威胁情报团队说,他们 10 月 17 日的蜜罐里就已经看到针对这个漏洞的尝试,来自商业匿名化网络。
这几个说法得放一起读。AWS 说的是"有针对性的尝试",他们自己也不清楚这些尝试在真实系统上能不能成;CISA 在 10 月 21 日的公告里写的是"截至发布时未观察到利用";到那时,两个 CVE 都还没进已知被利用漏洞目录。“没进 KEV"和"没人在打"不是一回事,别读成一个。
最后留一个比 CVE 号更有用的判断。这个洞可以归成一句话:一个组件在异常路径上跳了出去,没把状态清干净,下一个请求撞了上来。 想想你的服务:异常分支里提前 return、连接池的引用没还、缓存里的半成品没标记失效、错误处理和正常路径共用一份可变全局——这样的地方你有多少?补丁修的是 PHP 那一处;这个模式在你自己代码里一处没少。
今晚要做的不是发全员通告,是打开 php -i,看清楚你面前这台机器到底是 mod_php 还是 FPM。这一步三十秒,决定你今晚先动哪台。