php2shell(CVE-2026-109618 / CVE-2026-109619):背景、自查、修复、验收,一份给后端开发和架构师看的完整说明。
最后更新:2026-10-22。态势还在变,动手前先核对 php.net 的最新公告。
先看结论
赶时间的人,五行看完就能决策:
- 2026 年 10 月 21 日 14:00 UTC,PHP 官方发布非计划安全更新,修两个可远程执行代码的漏洞,CVSS v4.0 都是 9.2。研究界把它们合称 php2shell。
- 洞在 PHP 引擎自己身上,不在 WordPress、Drupal、Laravel、Symfony 里。你的业务代码写得再干净也没用 —— 一个 Hello World 页面跑在受影响的 PHP 上就能被打。
- 修复版本只有四个:8.5.12 / 8.4.27 / 8.3.36 / 8.2.35。7.4 / 8.0 / 8.1 官方只给了源码级最小回移补丁(要自己编译);7.3 及更早,没有任何官方修复。
- 补丁发布前后,watchTowr、Cloudflare、Patchstack、Hadrian 等多家厂商的传感器已经记录到在野利用尝试。截至本文写作时,还没有公开证据表明出现规模化成功入侵,但这个区别不改变你的优先级。
- 唯一的完整解法是升级 PHP 并重启每一个 worker。前面加 nginx、WAF、LB 都不能替代 —— nginx 只是原样转发 body(multipart 是 PHP 自己解析的),WAF 自己承认光看请求判断不出来(Cloudflare 是「把响应也一起看」才做到拦截),LB 只能限大小和速率,而这次的攻击请求体很小。官方原话是,所有配置类缓解「减少特定攻击路径的暴露,并不使安装变得安全」。
如果你现在只能做一件事:去把线上 PHP 的实际版本查清楚。注意是 Web 侧实际跑的版本,不是你在终端敲 php -v 看到的那个。
为什么这次和以往不一样
PHP 的历史上漏洞不少,但绝大多数是「某个函数用错了」「某个扩展有坑」。这次不一样,有三个反常识的地方,值得先说清楚,因为它直接决定你怎么处置。
一、漏洞层级在应用之下
常规的 Web 漏洞,修法是升框架、打插件补丁、改业务代码。这次的漏洞位于请求生命周期的最底层 —— 内存管理和错误处理,这两块是每一个请求都会经过的地方。
后果是:你的应用层防御基本失效。WAF 规则、框架的安全中间件、输入校验、ORM 参数绑定,这些都在应用层工作,而攻击在它们之前就已经发生了。官方公告里有一句很重的话:在 Apache mod_php 环境下,未认证的请求可以在任何应用代码执行之前触发攻击。
所以一个很常见的误判必须提前排掉:「我把 WordPress 升到最新版了,应该没事」—— 不成立。WordPress 只是一个跑在 PHP 上的程序,PHP 本身没升级,机器该中还是中。反过来也成立:Laravel 没发安全公告,不等于跑 Laravel 的服务器是安全的。
二、门槛低到「不需要你有功能」
研究人员演示的目标里,Calif 明确给出了确认打通未认证 RCE 的应用和版本:
WordPress 7.1.2 Drupal 11.4.3 Joomla 6.1.4
MediaWiki 1.46.2 Roundcube 1.7.4 Magento 2.4.9
phpMyAdmin 5.2.3 Nextcloud 35.0.1 Laravel 13.11
Symfony 8.1 WHMCS
注意这份清单里连 Symfony 8.1 都在 —— 如果你觉得「我用的是框架,框架会帮我兜住」,这条应该能打消这个念头。
但更要紧的是他们自己的措辞:这些只是挑出来做演示的代表性目标,不是受影响清单。Calif 的原话是,除了这些应用,在某些情况下连任意的 PHP 脚本都能打通。
原因在于,漏洞的触发点是 RFC1867 multipart 请求的解析 —— 这一步在 php_request_startup() 期间就执行了,早于你的任何一行业务代码,所以不需要你的应用开启上传功能,甚至不需要你的应用调用 session_start()。Wiz 用的词是 "application-agnostic",应用无关。
所以判断标准很简单:看你的 PHP 有没有在对外服务 HTTP 请求。「我的站有没有上传功能」不是标准。
三、两个洞不只是 RCE
大家都在说「远程执行代码」,但 CVE-2026-109618 这一组里有一条更阴的路径:远程读取 worker 进程内存。
原理很直白 —— PHP 在处理上传进度时会记住一个 session 标识,内存耗尽时清理没走完,这个指针就悬空了。下一个请求进来,PHP 把这块内存当成合法的字符串,URL 编码之后塞进响应给你的 PHPSESSID cookie 里。
也就是说:攻击者发一个请求,看响应头,就能读到别的请求留在内存里的数据 —— 可能是别人的密码、会话令牌、API Key、证书私钥。
这条路径为什么更麻烦?因为它不落地任何进程、不写任何文件、不需要 RCE。你那些只盯「PHP 拉起了可疑子进程」的检测规则,对它完全无感。真要抓它,得去看响应头本身有没有畸形。
一个 22 年前的幽灵
这件事最让人不适的地方,是它不是一个新漏洞。它是一条从 2004 年一直连到今天的线。
2004 年,Stefan Esser 发现了 PHP 历史上第一个 memory_limit 相关的远程漏洞。那个年代,「PHP 安全」这五个字基本就是他的代名词。
2020 年,有人向 PHP 提了 bug #78876:某些超长变量会导致内存耗尽,并且临时文件没有被清理。当时的处理很轻 —— 一个「看起来无害的小 bug」,报了就报了。
2024 年,PHP 做了十几年来第一次外部安全审计(OSTIF 组织、Quarkslab 执行、Sovereign Tech Agency 出资),同一个 OOM 问题被重新发现。但因为没有证明它能被利用,所以没有修。
有个细节很说明问题:Calif 的团队后来去查 HHVM 有没有同类问题时发现,HHVM 之所以不受影响,是因为 2020 年那个 #78876 的补丁顺手把它「缓解」掉了。而那个补丁在 git 里还挂着一个
security-audit-2024的 tag —— 2024 年那次审计看到了它,仍然没把它当成可利用的问题。
2026 年 8 月,这条线断了 22 年之后,接上了。 Calif 的 Ryan Austin 在做审计时,起点就是「Stefan 2004 年那个 bug 还有没有活着的手足」。开始审计几天后,第一条 OOM-fatal 就被翻了出来。
这也是为什么这次事件的教训不是「PHP 的代码烂」。恰恰相反 —— 这些 bug 在代码库里躺了十几年,不是因为没人看,是因为证明它可利用的链条长到超出人类研究者的实际承受范围。审计做过两轮,两轮都看到了,两轮都没修,因为「没有已知的利用路径」这个理由,在当时是成立的。
时间线:一场被提前引爆的协同披露
| 时间 | 事件 |
|---|---|
| 2004 | Stefan Esser 发现 PHP 第一个 memory_limit 远程漏洞 |
| 2020 | 用户报告 bug #78876:超长变量导致 OOM,临时文件未清理 |
| 2024 | OSTIF / Quarkslab 审计重新发现同一问题,因无法证明可利用而未修 |
| 2026-08-01 | Calif 开始审计,几天内复现第一条 OOM-fatal |
| 2026-08-05 | Calif 研究者 Ryan Austin 在推特上放出某个发现的 hash(不披露内容) |
| 2026-08-11 | Calif 报告第一个 bug,启动协同披露;同一天 OpenAI 独立报告同一根因 |
| 2026-08-14 | Calif 提交可工作的 RCE PoC |
| 2026-08-15 | PHP 确认 PoC 按预期工作 |
| 2026-08-19 ~ 08-24 | Wiz Research 独立发现并复现未认证代码执行,8-24 提交报告 |
| 2026-08-21 | Calif 报告第二个 bug |
| 2026-09-18 | OpenAI 补充更多攻击向量 |
| 2026-10-20 15:00 UTC | 两个 CVE 编号在论坛和社交媒体上泄露,附带的 CVSS 分数(10.0)和发布日期(10-22)都是错的 |
| 2026-10-20 19:30 UTC | PHP 提前发布预告,把发布时间从 10-22 挪到 10-21 |
| 2026-10-21 07:41 UTC | Chainguard 单方面解除禁运,提前发布含修复的镜像 |
| 2026-10-21 14:00 UTC | 正式发布 8.5.12 / 8.4.27 / 8.3.36 / 8.2.35 |
| 2026-10-21 全天 | 多家厂商报告在野利用尝试,多数走 session upload-progress 路径 |
这里有三个教训,都是给架构师看的:
第一,泄露的信息是错的,而错误信息会传染。 CVSS 被传成 10.0,实际 9.2;日期被传晚一天。更麻烦的是有人根据泄露内容得出「把 session.upload_progress 关掉就行了」的结论 —— 而官方公告里明确有第二个独立的 UAF 路径,走 SAPI 层的 multipart 解析,跟那个开关完全无关。按泄露信息做缓解,你会得到一种「已经处理了」的错觉,而服务器仍然暴露。
第二,安全公司自己也会破禁运。 Chainguard 提前六个多小时发镜像,等于让攻击者可以拿二进制去反推补丁。协同披露的假设是「所有人同时拿到」,这个假设很脆。
第三,补丁到大规模攻击的窗口,从「周」压缩到了「分钟」。 Sansec 在补丁发布前就预估「发布后 15 分钟内会开始大规模攻击」。事后看,Cloudflare、Patchstack 都在补丁发布前就观测到了利用尝试 —— 因为 PoC 已经流出来了。「周二扫描、按 SLA 打补丁、月底汇报」这套节奏,在这次事件里连一轮都跑不完。
两个洞到底在干什么
不讲 exploit,但机制要讲清楚,否则你没法判断自己的环境是否特殊。
CVE-2026-109618:内存耗尽时「账记到一半」
PHP 处理一个请求的时候,会临时记账:哪些内存要释放、哪些资源要清理、session 相关的东西存在哪儿。正常情况下请求结束,这笔账被完整清算。
问题出在内存耗尽的处理方式上。PHP 碰到 OOM 不会优雅返回,它用 longjmp() 直接跳到善后逻辑 —— 相当于从函数中间强行跳出去,那些「记到一半」的状态没被擦掉。善后逻辑接手时看到一本残缺的账本,把无效的指针当成有效数据处理,内存就被破坏了。
这个模式比单个 bug 重要
Calif 在复盘里做了一件很有价值的事:把这个 bug 抽象成了一个通用的坏模式,而不是当成一次性发现。他们说这个模式是:
先发布或销毁状态
-> 再做可能失败的操作
-> 最后才初始化 / 提交 / 恢复
如果在「可能失败的操作」处发生非局部 fatal
-> 提交或回滚被跳过
-> 幸存的消费者信任了一个假状态
拿 PHP 哈希表插入的代码举例,顺序是这样的:
idx = ht->nNumUsed++; /* 先让 bucket 对 teardown 可见 */
ht->nNumOfElements++;
p = ht->arData + idx;
p->key = zend_string_init(str, len, persistent); /* 这一步可能 bailout */
p->h = ZSTR_H(p->key) = h; /* 这三行在 fatal 时根本执行不到 */
ZVAL_COPY_VALUE(&p->val, pData);
计数先加了,key 还没分配。 如果这次分配正好把内存撞爆,zend_bailout() 会直接跳出函数 —— 后面那几行赋值全被跳过。但 nNumUsed 已经加了,所以善后逻辑在遍历时会把这个半成品 bucket 当成活的,去 zval_ptr_dtor(),去 zend_string_release() 一个从没赋值的指针。
内存破坏就是这么来的。不用任何花哨技巧,只需要「在错误的时刻把内存撞爆」。
最关键的一处:为什么 mod_php 和 FPM 结果不一样
这是整个事件里技术含量最高、也最容易被忽略的一处。同样触发上面那个畸形表,两个 SAPI 走向完全不同的分支:
/* mod_php:即使 request startup 失败,也照样执行请求级清理 */
php_request_shutdown(NULL);
|
v
销毁畸形的 $_POST / $_FILES
|
v
消费那个 stale bucket -> 内存破坏 -> RCE
/* PHP-FPM:startup 失败直接让 worker 退出 */
php_module_shutdown();
return FPM_EXIT_SOFTWARE;
|
v
请求级表根本没被销毁,跟着死掉的 worker 一起消失
所以:这条路径在 Apache mod_php 上是「对任意 PHP 脚本的 RCE」(PHP >= 7.3),在 PHP-FPM 上不是。
这就解释了官方公告里那句看起来矛盾的话 —— 为什么 mod_php 风险最高,而 FPM 需要「应用层代码路径」才能打通。原因不在应用写得烂,而在 SAPI 的失败处理策略不同:一个是「清理完再走」,一个是「直接死掉」。
这个区别对你有直接的实践意义:如果你还在用 mod_php,你的优先级不是「尽快」,是「现在」。
Wiz 另外披露了四条具体的利用路径,都挂在两个 CVE 下面:
| 编号 | 具体问题 | 能干什么 |
|---|---|---|
| CVE-2026-109618 | 上传进度反序列化器的 use-after-free | 内存泄露 + 代码执行 |
| CVE-2026-109618 | 远程进程内存泄露(PS(id) 悬空指针) | 读 worker 内存,应用无关 |
| CVE-2026-109619 | 致命错误回溯的 use-after-free(EG(last_fatal_error_backtrace)) | 跨请求 UAF → 代码执行 |
| CVE-2026-109619 | OPcache 陈旧类缓存指针(CG(map_ptr_last) 前沿不同步) | 任意文件读取,可组合进 RCE 链 |
最后那个 OPcache 的尤其值得单独说一句,因为它打破了「进程隔离」的心理安慰:多个 worker 共享同一块编译缓存,一个 worker 记账记到一半挂了,另一个 worker 以为那些槽位是空的,往里塞了别的东西 —— 两个进程对同一块内存的理解不一致。你就算把 FPM 池拆开、把进程隔离做满,共享内存这一层还是通的。Imperva 的实测结果是任意文件读取(在 worker 的权限范围内),Wiz 则把它当原语拼进了 RCE 链。
顺带补一个数字,Imperva 实测内存泄露的规模是约 67 KB。67 KB 的进程内存够装下什么,你自己想。
另外,两个 CVE 的分类是 CWE-824(访问未初始化的指针),不是传统的缓冲区溢出。
还有一个必须讲明白的点:这四个只是已披露的样本,不是全部。 Wiz 明确说还有「已跟踪的和很可能未被跟踪的变体」。PHP 官方的补丁是从根因上修,所以对变体也有效 —— 这也是为什么「关掉某个功能」永远不等于修复。
为什么 post_max_size 挡不住
这一节请重点看,因为它直接推翻了「我把 post_max_size 调成 8M 就安全了」这个想法。
第一层:post_max_size 比的对象不对。
PHP 是在自动 POST 解析开始之前,拿 post_max_size 去跟请求声明的 Content-Length 做比较。但它并不会对「实际消耗掉的字节数」施加同样的限制。
于是有两个现成的绕过口子:
- chunked 的 HTTP/1.1,或者不带长度头的 HTTP/2 multipart 请求 —— 没有可信的声明长度,那道比较就形同虚设
- 配置了输入解压过滤器时 —— 压缩前后长度不一致,比的是压缩长度,吃的是解压后的内存
第二层:请求体真的在限制之内,也能把内存撞爆。
这一条更狠。一个 RFC1867 的上传 part,如果 filename=""(空文件名),它不含任何文件数据,因此不占用 max_file_uploads 的配额。但 PHP 仍然会为它创建完整的六个 $_FILES 条目:name、full_path、type、tmp_name、error、size。
麻烦出在字段名可以嵌套这件事上。当字段名长成 files[a][b][c]... 这种形状时,PHP 必须为这六个条目各自单独构建一遍这个层级。于是一条很短的空上传记录,会膨胀成大量的原生 HashTable、Bucket 和字符串 key 分配。
几百条这样的记录,就能吃掉超过默认的 128 MiB PHP 堆 —— 而整个 HTTP 请求体仍然低于默认的 8 MiB post_max_size。
所以结论很硬:post_max_size 是一个流量整形参数,不是一个安全边界。 你能用它降低被撞爆的概率,但它挡不住「有人专门设计一个又小又胖的请求」。真正需要限制的是解码后的字节数、字段数量、multipart part 数量、header 数量、嵌套深度 —— 而且这些限制必须对没有可用 Content-Length 的请求同样生效。
让人后背发凉的部分:AI
这件事有个绕不开的背景,而且比「AI 挖到了漏洞」这句话要细致得多。
Calif 在复盘里说了一句很实在的话:「我们本来没指望找到什么严重的东西 —— 每个人、每个实验室都已经对这个目标做过大量 AI 辅助审计了。」 他们开始审计几天之后就拿到了第一条 OOM-fatal。
差别不在算力,在问的问题。他们的起点是「Stefan 2004 年那个 bug 还有没有活着的手足」—— 一个非常具体的假设,而不是「帮我找 PHP 的漏洞」。
更有启发的是他们的方法论。找到第一个 bug(哈希表那个)之后,他们没有继续手工翻代码,而是让模型把这个模式抽象成一份技能文件,然后拿这份技能去扫整个代码库。技能里写的就是前面那个四行模式:
发布或销毁状态 -> 执行可能失败的操作 -> 初始化 / 提交 / 恢复
-> 中间发生非局部 fatal -> 提交被跳过 -> 幸存者信任假状态
这份抽象出来的模式,直接带来了 Session upload-progress 那个新发现。 从「一个 bug」变成「一类 bug」,这才是这次研究真正的产出。
Wiz 那边的路径类似:用自家的 Wiz Atlas 加上 Gemini 4 Argon 和 GPT-5.6 Sol,把 exploit 适配到不同 PHP 版本和不同配置上。研究者的说法是:这项工作原本需要几个月的专家时间,压缩到了一个人一周左右。
Calif 也主动加了限定:「没有任何模型只靠简单提示词就能挖出『所有 bug』。」这话是对的,工具不替你做判断,选哪个假设、信不信这个发现、怎么证明它可利用,全是人的活。
但对防御方来说,结论不太好听:这类「躺了十年、突然被证明可利用」的补丁,会来得更频繁,而且不会给你更长的准备时间。 这次事件甚至被 WSJ 和 NYT 报道了 —— 当安全事件的传播速度开始跟社会新闻同频,你靠「等安全圈消化完再看」已经来不及了。
谁受影响:版本、SAPI、和被忽略的角落
版本矩阵
| 分支 | 受影响 | 修复版本 | 你该做什么 |
|---|---|---|---|
| 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 | 立即升,注意这条分支 2026-12-31 就 EOL 了 |
| 8.1 / 8.0 / 7.4 | 全部 | 无正式版本 | 官方给了源码级最小回移补丁(php-src 上三条分支,各一个 commit),自己编译;或找发行版 / LTS 厂商的回移包 |
| 7.3 及更早、5.4+ | 全部 | 无 | 迁移,或彻底网络隔离 |
受影响范围回溯到 PHP 5.4(2012 年发布)。也就是说,这十几年来发布的每一个 PHP 版本,都至少中其中一个漏洞。
SAPI 矩阵
官方点名「所有处理 Web 请求的 PHP 安装」,包括:
- Apache + mod_php —— 官方认为风险最高,因为已演示的利用可以在应用代码执行前触发
- PHP-FPM —— 已通过应用层代码路径确认 RCE,具体条件取决于应用行为、扩展和配置
- php-cgi、FrankenPHP、LiteSpeed / LSAPI
- 容器、托管面板、商业设备里内嵌的 PHP
只跑离线命令、不接收 Web 请求的纯 CLI 环境不在主要攻击范围内 —— 但如果一台机器同时装了 CLI 和 Web SAPI,你在命令行看到的版本不代表 Web 在用的版本。这是最常见的漏检点。
四个必须排掉的误区
误区一:升级应用就能解决。 不能。应用和运行时是两层,升 CMS 不动 PHP。
误区二:php -v 看到 8.4.27 就放心了。 不能。cPanel / Plesk / DirectAdmin 都是多版本并存,每个虚拟主机可以绑不同的 PHP;容器里也可以有自己的一套。你终端里的 php 很可能只是 CLI 的默认软链。
误区三:docker restart 就生效了。 不能。镜像没换,重启只是把旧代码重新跑一遍。必须拉新镜像、重建容器。同理,只用 apt upgrade 装包但没重启 FPM / Apache / LiteSpeed,旧 worker 还在跑旧的二进制。
误区四:发行版版本号更高就等于修了。 反了。发行版的回移补丁通常不改版本号 —— Debian 的 8.4.26-1~deb13u1、Ubuntu 的 8.1.34-0ubuntu2、RHEL 的 8.4.26-x.el9_6,版本号看着还在受影响区间里,但补丁已经在里面了。判断依据是包 changelog 里的 CVE 号,不是版本号本身。
顺便说一句,如果你在用 rpms.remirepo.net(很多 CentOS 7 老系统的 PHP 来源),PHP 官方公告里专门点了名:这个仓库发布当天不会有更新,建议切换到发行版软件包或自己编译。
第一步:确认是否中招
要分两件事:「我会不会被打」 和 「我是不是已经被打了」。很多人只做第一件。
A. 我会不会被打 —— 暴露面自查
按这个顺序走,不要跳步。
1. 盘清 PHP 运行时清单。 注意是「运行时」不是「应用」。检查这些地方:
# 系统里所有 PHP 二进制
ls -l /usr/bin/php* /usr/local/bin/php* /usr/local/php*/bin/php 2>/dev/null
ls -l /opt/cpanel/ea-php*/root/usr/bin/php 2>/dev/null # cPanel EasyApache
ls -l /opt/plesk/php/*/bin/php 2>/dev/null # Plesk
# 正在跑的 Web SAPI 进程,以及它们的真实二进制
ps -eo pid,user,lstart,cmd | grep -E '[p]hp-fpm|[l]sphp|[a]pache2|[h]ttpd'
对每个 FPM master 进程,看一眼它真正在执行哪个文件、什么时候启动的:
for p in $(pgrep -f 'php-fpm: master'); do
echo "PID $p -> $(readlink -f /proc/$p/exe) started $(ps -o lstart= -p $p)"
done
这一步比任何扫描器都准,因为它读的是内核记录的、进程实际执行的二进制路径。注意 started 时间 —— 如果补丁是今天打的,而进程是三个月前启动的,那就是没生效。
2. 逐站点确认版本。 多版本面板必须按站点查,不能只看全局默认。最可靠的做法是临时放一个探针(用完删):
<?php
header('Content-Type: text/plain');
echo 'version=', PHP_VERSION, "\n";
echo 'sapi=', PHP_SAPI, "\n";
echo 'memory_limit=', ini_get('memory_limit'), "\n";
echo 'upload_progress=', ini_get('session.upload_progress.enabled'), "\n";
echo 'binary=', (function_exists('posix_getpid') ? readlink('/proc/'.posix_getpid().'/exe') : 'n/a'), "\n";
这个探针的价值在于它以 Web 进程的身份读配置 —— 这是唯一能看到 php_admin_value 覆盖结果的地方,命令行读不到。
3. 找那些容易被漏掉的 PHP。 网络设备、NAS、防火墙、监控系统、工单系统、邮件系统、phpMyAdmin、Adminer、Roundcube、Nextcloud、MantisBT、Nagios、Pterodactyl、Synology Web Station、Unraid —— 这些里面都内嵌 PHP。别自己去替换设备内部的 PHP,找厂商的更新。
4. 判断暴露面。 不是所有 PHP 都要按 P0 处理。分级:
- 公网可直连 + mod_php → 最高优先级,官方说这条路径可以在应用代码之前触发
- 公网可直连 + FPM → 高优先级
- 仅内网 / VPN 后 → 中,但如果内网有 SSRF 或横向移动路径,视同高
- 只有 CLI cron → 低,但同机若有 Web SAPI 一起升
B. 我是不是已经被打了 —— 痕迹排查
先接受一个不愉快的事实:这个漏洞的执行发生在应用层之下,所以应用日志里可能几乎没有上下文。 Wiz 的原话是 "application logs may contain little context"。标准做法是从系统和网络侧反推。
按「性价比」排序,前三条最值得先看。
1. 响应头里的畸形 PHPSESSID。 这是内存泄露路径成功的直接证据,也是这次事件里最独特的信号。正常 session id 是 26 个字符的字母数字;如果 access log 或流量里出现长度异常、含大量 %XX 转义的 PHPSESSID,那不是你的应用在设置 cookie,是 PHP 把堆内存 URL 编码吐出来了。
# 前提:nginx 的 log_format 里加了 $sent_http_set_cookie,否则 access log 不记响应头
grep -o 'PHPSESSID=[^;"]\{60,\}' /var/log/nginx/access.log | head -20
如果你的 access log 没记响应头(默认就是不记),走另外两条路:应用层在输出 cookie 的地方打日志(改动小、可回滚),或者用 tcpdump / 云端全流量留存来抓。如果你有 Zeek / Suricata / 云 WAF 的完整流量,直接查 Set-Cookie 里 PHPSESSID 值长度超过 64 字节的响应 —— 这类响应在正常业务里几乎不可能出现。
2. 内存耗尽错误 × multipart 请求的时间聚集。 漏洞需要「在错误的时刻撞上内存上限」,所以攻击会在日志里留下这个组合:
# PHP 错误日志里 OOM 的时间分布(FPM 需要 catch_workers_output=yes 才会进这里)
grep -h "Allowed memory size" /var/log/php*-fpm.log /var/log/php_errors.log 2>/dev/null \
| awk '{print $1, $2}' | sort | uniq -c | sort -rn | head
# access log 里返回 5xx 的 POST 源 IP(combined 格式;用引号锚定避免字段错位)
grep -E '"POST [^"]*" 5[0-9][0-9] ' /var/log/nginx/access.log \
| awk '{print $1}' | sort | uniq -c | sort -rn | head
注意这是粗筛。正常业务也可能有 OOM(比如一个导出功能没限内存)。要看的特征是:OOM 次数在短时间内突然密集、且来自少数几个源 IP、且和大量 multipart 请求同时出现。
3. worker 的无解释震荡。 攻击会打崩 worker,FPM 会重启它:
systemctl show php8.2-fpm -p NRestarts # 累计重启次数
journalctl -u php8.2-fpm --since '3 days ago' | grep -ciE 'segfault|SIGSEGV|bailout|SIGKILL|killed'
dmesg -T | grep -iE 'oom|killed process' | tail -20 # 被 OS 杀的(配 cgroup 限制时尤其要看)
容器环境下看 OOMKilled 计数和重启次数。
4. 应用层之下的执行痕迹。 这是判断「已经 RCE 成功」的核心:
# Web 用户拉起的子进程 —— php-fpm 正常情况下不该有 sh/curl/wget/nc 这种孩子
ps -eo user,pid,ppid,cmd --forest | grep -A2 -E 'php-fpm|lsphp|apache2|httpd' \
| grep -E '\b(sh|bash|dash|curl|wget|nc|ncat|python3?|perl|base64)\b'
5. 新增或篡改的 PHP 文件。 攻击者拿 shell 之后最常见的动作是往 web 目录、上传目录、session.save_path、/tmp 里丢一个 webshell:
find /var/www /srv /home -name '*.php' -type f -newermt '-7 days' -ls 2>/dev/null | head -50
find /tmp /var/tmp /dev/shm -name '*.php' -type f 2>/dev/null
# session 目录:注意 ini 里可能是 "2;/var/lib/php/sessions" 这种带分级前缀的写法
grep -rh '^[[:space:]]*session.save_path' /etc/php* /etc/php.ini 2>/dev/null | sort -u
6. 出网和持久化。 这两项意味着已经进入后渗透阶段了:
# Web 用户发起的对外连接
ss -tnp | grep -E 'php-fpm|apache|nginx' | grep -v '127.0.0.1'
# 持久化
crontab -l -u www-data 2>/dev/null
find /etc/cron* -newermt '-14 days' 2>/dev/null
grep -rlE 'base64|curl|wget' /etc/cron.d /var/spool/cron 2>/dev/null
这里必须诚实一句:目前没有官方确认的、能覆盖全部攻击手法的固定 IOC。 上面这些是排查思路,不是检测签名。指望拿一串 User-Agent 就判断有没有被打,不现实 —— 尤其内存泄露那条路径,攻击者可以做到完全不留痕。
第二步:怎么修
优先级排序
P0:公网可直连 + mod_php。 别讨论,先升。官方明确说这条路径能在应用代码之前触发。
P1:公网可直连 + FPM / php-cgi / FrankenPHP / LiteSpeed。 同一天内完成。
P2:内网服务、多版本面板、内嵌 PHP 的设备。 一周内,按厂商通告节奏走。
P3:纯 CLI / CI 环境。 顺手升,但别为它挡住 P0。
三条升级路径
路径 A:发行版包(最推荐给大多数人)
# Debian / Ubuntu
apt update && apt upgrade php8.2-fpm php8.2-cli php8.2-common
apt-get changelog php8.2 | head -40 # 确认 changelog 里出现 CVE 号
# RHEL / Rocky / AlmaLinux
dnf update php-fpm php-cli
rpm -q --changelog php | grep -i 'CVE-2026-1096'
装完必须重启对应的服务,否则等于没装:
systemctl restart php8.2-fpm apache2 # 按你实际的 SAPI 组合
# LiteSpeed: systemctl restart lsws
路径 B:官方源码 / 官方 Windows 包。 适合自己编译或有 Windows 服务的场景。windows.php.net 上 8.2~8.5 的 Win32 包在发布前已就位,但 7.4 / 8.0 / 8.1 的 Windows 包没有更新 —— Windows 上跑 EOL 分支的,只能自己编译回移补丁。
路径 C:容器。 记住一句话:改 docker-compose.yml 里的 tag 不等于升级,必须重新 pull + 重建。
docker pull php:8.4-fpm
docker compose up -d --force-recreate
docker inspect --format '{{.Image}}' <container> # 核对镜像 digest
docker image inspect <image> --format '{{.Created}}' # 核对构建时间
卡在 EOL 分支上怎么办
这是最难的一类,也是很多团队的真实处境。7.4 / 8.0 / 8.1 官方给了最小源码回移补丁,对应 php-src 上的三条分支:
PHP-7.4-CVE-2026-109618-109619PHP-8.0-CVE-2026-109618-109619PHP-8.1-CVE-2026-109618-109619
注意这是源码级补丁,没有 tarball、没有二进制、没有版本号变更,你要自己编译、自己打包、自己验证。适合有编译能力和 CI 的团队,不适合「运维只有一个人」的环境。
7.3 及更早,包括 5.x —— 官方明确不会修,大多数发行版也不会。 这时候只有三个选项,按代价排序:
- 迁移到受支持版本。 唯一的长期解,但工作量和业务耦合度成正比。
- 编译回移补丁。 把 7.4 的补丁往 7.2 / 7.3 上移植。技术上未必不可能,但这是你自己承担的风险,官方不背书。
- 彻底切断暴露面。 撤出公网,只允许 VPN 或可信 IP 访问。这是一种妥协,谈不上安全 —— 但如果这套系统本来就不该对外,它反而是最省事的答案。
顺便提醒一类人:如果你的系统是「老框架 + 老 PHP」的组合(比如 Symfony 3.4 跑在 PHP 7.2 上),那么升级 PHP 和升级框架是绑在一起的工作 —— 前者不能脱离后者单独做。这类环境里,「先隔离、再排期迁移」通常比「硬啃升级」更现实。
临时缓解:请把它当绷带,不是创可贴
官方说得很直白:这些措施「减少特定攻击路径的暴露,并不使安装变得安全」。我按 CVE 分开列,因为它们针对的路径不同。
针对 CVE-2026-109618(OOM 路径):
memory_limit=-1,并且要用应用无法覆盖的方式设置(FPM 池的php_admin_value或 Apache 配置),同时确认没有任何 pool / 目录 / 应用层设置把有限上限加回来。这消除了「可控的内存上限触发点」,但底层的分配失败(比如mmap()失败)仍然能走到易受攻击的路径。- 收紧
post_max_size:不需要上传的应用设8M,需要上传的设32M,并保证upload_max_filesize < post_max_size。同时在 Web 服务器或反向代理层限制解码后的请求体大小、字段数、multipart part 数、header 数、嵌套深度,而且必须覆盖没有可用Content-Length的请求(chunked 编码过去是绕过这类限制的常见手法)。 - 用容器 / cgroup 做可用性边界,策略优先只杀受影响的 worker。
关于
post_max_size,请回头看一眼前面那一节。 它比的是声明的Content-Length,不是实际消耗的字节数;而一个filename=""的空上传 part 不占max_file_uploads配额,配上嵌套字段名就能在小请求体里放大出巨大的堆分配。所以这条措施只能降低被撞爆的概率,堵不住专门构造的请求 —— 后面半句「在代理层限制解码后大小和嵌套深度」才是真正起作用的部分,别只做前半句。
这里有个必须警告的坑:不要用 RLIMIT_AS 或 RLIMIT_DATA 替代内存限制。 官方公告专门写了这一条 —— 这两种限制会触发同样的易受攻击的分配失败路径。你以为是加固,实际是把攻击面换个地方打开。
再提醒一遍 memory_limit=-1 的代价:它把「请求失败返回 500」变成「进程被操作系统干掉」。在 mod_php 这种线程化部署里,杀掉进程会连带中断同进程上其他线程正在处理的请求。所以配套必须有 cgroup v2 的 memory.max 兜底,否则你会用可用性换安全。
针对 CVE-2026-109619(陈旧状态路径):
session.upload_progress.enabled=0,然后重启每一个 worker。必须通过php.ini、FPM 池配置或服务器配置生效 —— 不能靠ini_set()或 FPM 的.user.ini,因为那两者生效的时机太晚,请求已经开始解析了。- PHP 8.5 额外设
fatal_error_backtraces=0,重启 worker。这只挡住已确认的致命错误回溯路径。 - 清掉那些「在请求初始化前就产生 warning / deprecation」的过期配置。注意:用
error_reporting、display_errors或@把诊断藏起来没有用,问题不在显示,在于诊断被触发了。
中间加设备有用吗:nginx、WAF、LB 各能拦住什么
这是我在真实环境里被问得最多的一类问题,因为大家的架构几乎都是「WAF → LB → pod(nginx + php-fpm)」。先说结论:这条链路上,能做实质事情的只有两个位置 —— PHP 自己,和 nginx。 其余都是降低概率,不是阻断。
公网
|
v
[ CDN / WAF ] L7,能看到请求和响应。请求侧判断力有限(见下)
|
v
[ LB ] L4 完全无感知;L7 只能做粗粒度限量
|
v
[ ingress-nginx ] 唯一「能完整看到 body」且「你能改」的位置
|
v unix socket / 127.0.0.1:9000
[ php-fpm worker ] 攻击在这里发生,前面所有层都是间接的
| 层 | 能看到什么 | 对本次漏洞是否有效 | 说明 |
|---|---|---|---|
| L4 LB(TCP) | 五元组、字节数 | 无效 | 不解析 HTTP,无从判断 |
| L7 LB(HTTP) | 头、body 大小、状态码 | 基本无效 | 攻击请求体很小,限大小不触发 |
| WAF | 完整请求 + 可选响应 | 部分有效,但有硬上限 | 见下方 |
| nginx | 完整 body(会缓冲) | 有效,但需自己加检查 | 原生不做 multipart 语义解析 |
| php-fpm | 一切 | 攻击点 | 升级才是解 |
一、nginx + php-fpm 会不会中招
会,而且和直连 Apache 一样会。 原因很简单:nginx 根本不解析 PHP,它只是把请求体通过 FastCGI 原样转给 php-fpm,multipart 是 PHP 自己解析的。中间加一层 nginx 不改变 PHP 看到的字节。
不过这里有两个必须分清的点,很多讨论把它们混在一起了。
好消息是:mod_php 那条「一个请求打穿任意脚本」的路径不成立。 前面讲过 SAPI 的失败处理分叉 —— FPM 在 startup 失败时直接让 worker 退出,不销毁畸形的 $_POST/$_FILES,所以那条最省事的链子断了。
坏消息是:CVE-2026-109619 反而更适合 FPM。 因为它的前提是「后续请求由同一个 worker 处理」,而 FPM 的 worker 复用率比 mod_php 高得多 —— 一个 worker 连着处理成百上千个请求是常态。所以别以为「我没用 mod_php 所以安全」,只是换了一条更长的链子,而这条链子的前提条件在你的架构里天然满足。
顺便排掉一个误解:nginx 的 client_max_body_size 挡不住。 它默认 1m,看起来挺严,但这次的攻击是「小又胖」—— 请求体几百 KB,靠嵌套字段名放大出巨大的堆分配。限大小是必要的基础卫生,但对这个漏洞不是防线。
二、WAF 能过滤吗:能,但它的能力上限比宣传的低
最有说服力的一条证据来自 Cloudflare 自己,他们在公告里写得很坦诚:
对付 php2shell 需要的不是标准的基于请求的规则。光靠请求数据,往往不足以区分「这是 exploit」还是「这是正常流量」。源站的响应能提供额外上下文。
所以他们专门为这件事做了一个新能力:full transaction detection —— 同时看同一笔事务的请求和响应,然后阻断那个响应。这是他们第一次用这个能力发托管规则。
这句话的含义很重,值得拆开:
(1)请求侧基本只能靠「结构异常」,不是靠签名。 因为一个用来撞爆内存的 multipart 请求,在语法上完全合法 —— 它没有 ' OR 1=1 这种特征串,也没有 <script>。能被抓住的只有「形状不对」:part 数量异常、字段数异常、字段名嵌套过深、filename="" 出现太多次、session 相关字段被重复塞。Imperva 的描述也是这几个维度:异常的 multipart 载荷、重复的 session 字段、异常的响应头、可疑的 upload-progress 活动。
(2)绑住「响应」能救一部分,但救不了 RCE。 这是最容易被忽略的一点:
- 对内存泄露路径:攻击者必须读那个畸形的
PHPSESSID才能拿到数据。WAF 把响应拦掉,他确实拿不到 —— 这条有效。 - 对代码执行路径:攻击者根本不需要读你的响应,一个带外请求(DNS 解析、外连 HTTP)就能把结果和数据送走。WAF 拦响应拦不住带外。
所以 WAF 的真实定位是:挡住机会主义的扫描器 + 降低数据外带的成功率。它不是「阻止攻击」,因为攻击在你响应之前就已经发生了。
(3)攻击者在主动变形。 Cloudflare 提到他们通过统计规则命中量、比对规则逻辑,观察到攻击者在变异 payload 试图绕过。这在内存破坏类漏洞上是必然的 —— 攻击者只需要让「撞爆内存的时机」换个方式发生,特征就变了。
(4)别把「默认开启」当成「已经防护」。 Cloudflare 从 10-21 03:02 UTC 起对所有含 WAF 的套餐默认生效(比正式发布早约 11 小时),免费版也有 7 条 Generic Rules - Anomalous Execution。该开就开,但 Cloudflare 自己那句话才是重点:「即使 WAF 提供了纵深防御,客户也应当修补他们的 PHP」。
三、LB 能处理吗
直说:L4 LB 一点用都没有,L7 LB 只比没有强一点。
- L4(TCP/四层):它只转发字节流,连 HTTP 都不解析。谈不上处理。
- L7(HTTP/七层):能做的是 body 大小上限、header 数量上限、连接/请求速率限制、超时。这几项对本次攻击基本无效,因为攻击请求体很小、header 正常、速率也不高。
- 想让 LB 做「multipart part 数」「字段嵌套深度」这类语义判断,等于在 LB 上跑一个 WAF —— 少数企业级 ADC(F5、Citrix)能写策略做到,但成本和维护复杂度都比直接在 WAF 上做高,收益一样。
所以你的架构里,「只有 WAF 能处理」这个判断大体是对的 —— 但要加一句:WAF 也只能处理一部分,而且不是靠签名。
四、这个架构下真正值得做的三件事
按性价比排:
1. 升 PHP。 还是这句。上面所有讨论都是在「暂时升不了」的前提下谈的。
2. 降低 worker 复用 —— 这是纯配置、立刻能做、且真正相关的一条。
; php-fpm pool 配置
pm.max_requests = 200 ; 默认 0(永不重启)-> 改成有限值
原理:CVE-2026-109619 需要「污染一个 worker,然后让后续请求落到同一个 worker 上」。worker 活多久,这个窗口就有多长。 让 worker 处理几百个请求就重启,窗口被压缩到原来的一小部分。
这一条官方在缓解措施里也列了,叫「reduce worker reuse」,但很多团队的 pm.max_requests 还是默认的 0,等于永远不轮换。
这里有个反直觉的地方,别搞反了:worker 池开得越小,风险越高,不是越低。 因为池子越小,「后续请求落到同一个 worker」的概率越大,攻击者越容易凑齐条件。所以正确做法是「池子开够 + 让它们快速轮换」,而不是「关掉几个 worker 省内存」。
代价:worker 重启有开销。好在 opcache 是 master 管理的共享内存,不随 worker 重启清空,所以冷启动惩罚比想象中小。设 200~500 是个合理起点,按你的 QPS 和单请求内存调。
3. 在 nginx 这一层做 multipart 结构检查。
这是唯一「能在 PHP 解析之前」掐死攻击的位置,因为 nginx 手里有完整 body。但 nginx 原生不做 multipart 语义解析 —— client_max_body_size 只管字节数。
三条可行路径,按实施成本排:
- ModSecurity + CRS:有 multipart 解析器和参数数量类规则,能覆盖一部分「字段数爆炸」的场景。缺点是性能和误杀需要调。
- OpenResty + Lua:自己读 body、数 boundary 出现次数、数字段名里
[的深度,超阈值直接 413/400。代码量很小(几十行),规则很硬,误杀风险低 —— 正常业务不会出现几百层嵌套字段名。 - 一个极简的 sidecar 预检:Go/Node 写个小服务,nginx 用
auth_request或直接反代过去,只看 Content-Type 是 multipart 的请求,检查完放行。
判断阈值怎么定:先去你的 access log 里统计正常业务的 multipart part 数和最大字段嵌套深度,取 P99 再乘个安全系数。不要拍脑袋定 10,否则大表单和批量上传会挂。
最后提醒一句:这三件事全部是缓解。它们能把「一个脚本小子拿公开 PoC 打穿你」变成「需要针对你的配置定制的攻击」,但在内存破坏类漏洞面前,没有任何代理层能做「安全」这个承诺。
第三步:怎么确认修好了
这一节是大多数人会跳过、出事之后最想抽自己的部分。装完包不等于修好,修好要有证据链。按顺序做完七项。
动手之前可以先跑一个现成的:OpenAI 通过 Patch the Planet 放出了官方版本检查工具 github.com/openai/php2shell-checker(以 Codex 技能形式提供),它负责快速定性 —— 机器上到底装了几个 PHP、分别是什么版本。
但定性不等于验收。下面这七项它不替你看:进程是不是新拉起来的、FPM 池里有没有被 php_admin_value 改回去、发行版回移有没有真的把补丁打进去。这些只能自己一条条过。
1. 版本号对上了(而且是对的那个版本号)
四个终态,逐字比对:8.5.12 / 8.4.27 / 8.3.36 / 8.2.35。用 Web 侧的探针确认,不要用命令行的 php -v。
发行版回移的情况,版本号不会变。 这时候唯一可信的依据是包 changelog 里有 CVE 号:
dpkg -l | grep php | head # Debian/Ubuntu:看包版本后缀
apt-get changelog php8.2 2>/dev/null | grep -i 'CVE-2026-1096'
rpm -q --changelog php | grep -i 'CVE-2026-1096' # RHEL 系
如果 changelog 里查不到,就当没修,去找发行版的安全通告(Debian DSA、Ubuntu USN、RHSA)核对。
2. 跑着的确实是新二进制
这是最容易翻车的一步。三个证据:
# a) 服务的启动时间应该晚于你打补丁的时间
systemctl status php8.2-fpm --no-pager | grep -E 'Active:|Main PID'
# b) 进程实际执行的二进制路径和修改时间
for p in $(pgrep -f 'php-fpm: master'); do
ls -l /proc/$p/exe; ps -o lstart= -p $p
done
# c) 有没有还在用已删除的旧 .so(升级后没重启的典型特征)
PID=$(pgrep -f 'php-fpm: master' | head -1)
lsof -p $PID 2>/dev/null | grep -c 'deleted'
第三项如果返回值大于 0,说明进程还映射着已经被替换掉的旧文件 —— 升级没生效。
3. 容器是重建的,不是重启的
docker inspect --format '{{.Image}} {{.State.StartedAt}}' <container>
docker image inspect <image> --format '{{.Created}} {{.Id}}'
.Created 必须晚于补丁发布时间(2026-10-21 14:00 UTC)。如果镜像构建时间是几个月前,那你只是在跑旧代码。
4. 缓解配置真的生效了
如果你暂时用了 memory_limit=-1 和关 upload_progress,一定要从 Web 侧读回来。探针应该输出成这样:
version = 8.4.27
sapi = fpm-fcgi
memory_limit = -1
upload_progress = 0
四行里任何一行不符合预期,就说明配置没落到那个池上。
从命令行读这两个值没有意义 —— php_admin_value 只在那个 FPM 池里生效。这就是前面那个探针存在的理由。
同时检查有没有「漏网的池」:
grep -rn 'php_admin_value\|php_value' /etc/php/*/fpm/pool.d/ /etc/httpd/ 2>/dev/null \
| grep -E 'memory_limit|upload_progress'
任何一个池把 memory_limit 又设回有限值,那个站点就还在攻击面上。
5. 行为指纹(辅助证据,别单独用)
修复引入了一个新的硬性内存上限:达到上限时直接终止进程,不再执行 shutdown handler。这给了一个可观察的行为差异。
对照实验(在测试环境做,不要在生产做):
<?php
register_shutdown_function(function () {
file_put_contents('/tmp/ps-shutdown-marker', 'ran');
});
// 注意:如果 memory_limit 被 php_admin_value 锁成 PHP_INI_SYSTEM,ini_set 是无效的
// 这种情况下改用 <php-binary> -d memory_limit=32M -f this.php 在 CLI 下观察
ini_set('memory_limit', '32M');
$a = [];
while (true) { $a[] = str_repeat('A', 1024 * 1024); }
- 旧版本:
/tmp/ps-shutdown-marker会被写出来,请求返回 500 和 "Allowed memory size exhausted" - 已修复:进程被终止,标记文件不出现
我把这条标成「辅助」,因为它依赖具体的失败路径,不同 SAPI 表现可能有差异。版本号 + 包 changelog 才是主证据,这条只是用来抓「版本号看着对但二进制是旧的」这种诡异情况。
6. 在隔离环境用公开 PoC 复测自己
PoC 已经在 GitHub 上公开流传,有 UAF 复现环境和完整利用链。做法是:把生产镜像快照一份到隔离网络,拿 PoC 打一遍 —— 打不穿,说明补丁到位;打穿了,说明你的版本判断哪里错了。
绝对不要在生产环境做这件事,也不要拿别人的站做验证。
7. 留痕:谁签的字
CTO 需要的不是「我们升了」,是一张表:
| 主机 / 服务 | 生效 SAPI | 原版本 | 现版本 | 重启时间 | 证据(changelog / lsof) | 确认人 |
|---|---|---|---|---|---|---|
| web-01 | fpm-fcgi | 8.2.34 | 8.2.35 | 10-21 22:10 | changelog 命中 CVE-2026-109619,deleted=0 | 张三 |
没有这张表的组织,下一次还会问同样的问题。 这次事件里唯一值得抄的作业,就是把「谁负责 PHP」这件事从组织架构上定下来 —— 很多团队在事件当天才发现,没人说得清线上到底有几个 PHP。
如果确认已经被打进来了
顺序不能错,先取证再清理:
- 隔离,但不要急着重装 —— 内存取证和日志是唯一的线索,重装等于销毁证据。至少先把
/var/log、进程列表、ss输出、web 目录打包留档。 - 从可信镜像重建,不要在受污染的机器上「清理干净继续用」。
- 轮换所有可能进过进程内存的凭据:数据库密码、云 AK/SK、API Token、会话密钥、管理员口令、证书私钥。这次漏洞能读 worker 内存,所以「密码只在环境变量里」不构成保护 —— 环境变量也在进程内存里。
- 从干净设备执行轮换,别在可能还带 shell 的机器上操作。
- 复盘:为什么补丁晚了?是不知道有这台机器,还是知道了但没人有权重启?
长期作业:这次事件真正的教训
补丁打完只是把火灭了。有四件事值得借这个机会推到架构层面。
一、PHP 8.2 三个月后就 EOL 了。 2026-12-31 之后,这条分支不再有任何安全修复。也就是说,你今天刚打完这个补丁的系统,明年一月起重新变成「等下一个大洞」的状态。如果这次升级的目标只是「升到 8.2.35」,那这活儿只干了一半。
二、你需要的是「运行时清单」,不是「应用清单」。 这次的暴露面跟你的应用有多少个、用什么框架没关系,只跟「有多少个 PHP 在对外服务 HTTP」有关系。cPanel 里的每个虚拟主机、容器里的每个 tag、防火墙的 Web 管理口、NAS 的 Web Station,都算一个。
三、「15 分钟」这个数字要重新设计你的补丁流程。 如果从「公告发布」到「全网运行时换掉」需要两周,那你所有的安全建设都在赌「这次不会被打」。可行的方向只有:不可变镜像 + 一键重建 + 灰度验证自动化。能在一小时内换掉一个运行时,比有一百条 WAF 规则更能救命。
四、密钥的假设要改。 这次披露的泄露面是「PHP 进程内存,可能包含其他请求的密码、令牌、密钥、证书」。这打破了一个隐含假设:进程内存是可信边界内的。对策是短 TTL + 可轮换 + 最小权限,而不是「加密存储」(解密的明文照样在内存里)。
给三种人的三句话
给后端开发: 这次的洞在你的代码下面。你能做的不是写防御代码,而是搞清楚你的应用实际跑在哪个 PHP 上 —— 这个信息在很多团队里是空的。
给架构师: 应用层防御解决不了引擎层漏洞。你要补的不是一个 WAF 规则,是「运行时也要有清单、有责任人、能在小时级替换」这条能力。
给 CTO: 你的风险不取决于「有没有人告诉你有漏洞」,取决于「知道之后多久能全网换掉」。这次是 15 分钟的窗口,下次可能更短,而且会来得更频繁 —— 因为挖洞的一方也开始用 AI 了。
延伸阅读
官方与研究方原文,按可信度排序。信息冲突时以 php.net 公告为准,社交平台的解读(包括 CVSS 分数)在本次事件里已被证明是错的。
- PHP 官方安全公告(唯一权威口径):php.net 2026 年安全档案
- Calif 的完整技术复盘 ★ 最值得细读,从 2004 年讲到 mod_php / FPM 的分叉:calif.io/php2shell
- Wiz Research 技术深度分析(四条 bug 路径 + 检测信号):wiz.io/blog/php2shell-php-rce
- PHP Foundation 复盘(泄露、破禁运、以及为什么这个 bug 拖了两年才修):thephp.foundation
- OpenAI 关于 Patch the Planet 与协同披露的说明:openai.com
- Patch the Planet 公开指导:patchtheplanet.org
- 汇总站点(版本矩阵与缓解措施):php2shell.com
- CVE-2026-109618 安全公告:GHSA-w8hw-w9fx-j79w
- CVE-2026-109619 安全公告:GHSA-6wmf-4rv5-p3rp
- Unit 42 威胁简报:unit42.paloaltonetworks.com
- Cisco Talos 分析:blog.talosintelligence.com
如果你要把「按风险排优先级」写成内部流程,OpenAI 那篇里提到 CISA 的 BOD 26-04 是个可直接引用的框架 —— 它要求按「暴露程度 × 是否已被利用 × 潜在影响」排序,而不是按 CVSS 分数一刀切。
关于「受影响主机数量」,各家估算差异巨大(约 88 万到 2260 万),因为口径完全不同:有的数 HTTP banner 里暴露了版本的,有的数遥测里见过的。这些数字只能用来判断量级,不能用来判断你自己的风险 —— 你的风险取决于你那几台机器。