PHP2Shell 复盘
● 安全预警 · 2026 年 10 月

PHP2Shell:两个 CVSS 9.2 的 无认证 RCE,根因藏在 PHP 引擎的请求生命周期里

这次漏洞不在 WordPress,不在 Laravel,也不在任何你能看到的那一行业务代码里。 它藏在 PHP 引擎的请求初始化与收尾逻辑中——一段存在多年的内存生命周期缺口, 曾被评估为「太难利用」而搁置,直到 2026 年 8 月在 AI 辅助审计下被重新证明可被利用。 本文用一张请求时间轴、几段运行时路径,把成因讲清楚,再给出能立刻执行的自查与处置清单。

📅 2026-10-23 更新 ⏱ 约 15 分钟阅读 🏷 PHP · RCE · UAF · OOM · OPcache
CVSS v4.0
9.2
两个 CVE 同为严重
受影响范围
< 8.2.35
即几乎全部在网 PHP
漏洞年龄
多年
曾被判定「无法利用」
未修主机(Censys)
3440 万
截至 10 月 23 日

01三十秒速览

如果你只有一分钟,读完这一节就够去处理服务器了。

2026 年 10 月 21 日 14:00 UTC,PHP 官方发布了四个安全版本,修复两个可以被未认证攻击者利用的远程代码执行漏洞。 攻击者只要向服务器发送一个精心构造的 HTTP 请求,就可能在目标机器上执行任意命令——不需要账号,不需要插件漏洞, 甚至不需要你的应用代码跑起来:Datadog 的复现显示,请求根本到不了应用层,PHP 在清理这个「失败请求」的过程中就把攻击者的命令执行了。

Wiz 研究团队对它有一句很重的评价:「这些发现属于 PHP 史上最严重的漏洞之一,也可以说是软件史上最严重的之一」, 并把这件事称为继 Heartbleed(2014)、Log4Shell(2021)之后的第三个转折点——因为在它的背后, 是一个更让人不安的问题:为什么一个藏了十几年的内存安全问题,今年才被证明可利用?

编号类型一句话成因状态
CVE-2026-109618
CNVD-2026-41674
OOM 引发内存破坏
+ 远程内存泄漏
内存分配失败(OOM)打断了请求处理,哈希表在「计数已增加、桶还没初始化」时被放弃,请求收尾却按有效桶去遍历它。 已修复
CVE-2026-109619
CNVD-2026-41675
释放后使用(UAF)
+ OPcache 类型混淆
worker 在请求内存被释放后仍保留进程级全局状态,下一个请求在正常初始化重置它之前,先踩到了这份悬空状态。 已修复
CVE-2026-115560
10-23 新增
RCE(特定配置) 应用先用 php_binary/php_serialize,再在运行时用 ini_set() 切回 php 后调用 session_start(),叠加会话上传进度功能。 暂无补丁

先跑这一条命令

判断你在不在射程内,只需要看版本号。注意:要看的是真正对外提供服务的那个运行时(php-fpm / mod_php / php-cgi),不是你在 shell 里敲的 php -v。

bash在 Web 进程视角下确认版本
# 方式一:通过网站访问一个 phpinfo 页面 / 或临时丢一个探针文件
# 方式二:对 FPM 主进程 / php-cgi 二进制直接查询
php-fpm -v
# 方式三:从容器镜像里查(Docker 用户注意别只看 tag,要重新 pull)
docker exec <container> php -r 'echo PHP_VERSION, PHP_EOL;'

# 对照表:满足任一即为已修复
# 8.5.x >= 8.5.12   8.4.x >= 8.4.27   8.3.x >= 8.3.36   8.2.x >= 8.2.35
最重要的结论

更新 WordPress / Drupal / Laravel 的核心、主题、插件,对这个漏洞毫无作用。漏洞位于 PHP 运行时本身, 必须升级服务器上的 PHP(或让主机商升级),或者直接下线。

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——也包括一个只打印 Hello World 的裸 PHP 文件。

全文导航

🧬 想知道它为什么坏

第 03 节讲三个失败点:哈希表桶、请求启动屏障、OPcache 共享内存。这是全文的技术核心。

🔗 想知道怎么被打穿

第 04 节按 Datadog 的复现拆成三阶段:打断插入 → 堆布局填充 → 把数组清理变成函数调用。

🛡️ 想知道怎么防

第 08 节是可执行清单:版本表、ini 配置、反向代理拦截、日志排查、应急响应。

🧠 想知道这件事意味着什么

第 10 节:AI 如何把「十几年没人能利用的洞」变成「一周出 PoC」,以及补丁没有真正解决的问题。

02事实与时间线:一场被泄漏逼着提前的发布

泄露从哪来、谁抢跑、谁下线——这些细节比漏洞本身更能说明它是怎么被对待的。

PHP 安全团队原本计划在 10 月 22 日发布。但在 10 月 20 日,CVE 编号开始外流;当天傍晚,一家参与报告的机构误发了一段 PoC 视频; 再然后,一家商业镜像发行商在禁运解除前六小时抢先发布了含补丁的镜像。结果是:发布提前一天, 多个国家公共服务在补丁还没到手时就直接选择了下线。

10-20 15:00 UTC
CVE 编号在论坛与社交平台外流,附带错误的发布时间和错误的 CVSS 10.0(实际 9.2)。据 PHP 基金会说明,技术细节并未泄漏。
10-20 19:30 UTC
PHP 提前发出预告:发布日从 22 日改到 21 日。理由是分发商还在打包,立刻发会让大量用户没有简单的升级路径。
10-20 20:00 UTC
Calif 误发了 PoC 演示视频(对多个目标的实际利用效果),删除后副本迅速扩散。视频加速了大量 PoC 的开发,并在 10-21 进一步扩散。PHP 团队随后持续检查新出现的 PoC,在正式发布时仍未发现可用的 exploit。
10-21 07:34 UTC
多家主机商向 Patchstack 报告正在发生的利用尝试。
10-21 07:41 UTC
Chainguard 单方面解除禁运,发布含补丁的镜像——此时距离协调发布还有六个多小时。任何攻击者都可以先研究二进制形式的补丁,而其他人还在等源码。基金会措辞很克制:「这无视了多方数周的工作。」
10-21 13:30–14:00 UTC
8.5.12 / 8.4.27 / 8.3.36 / 8.2.35 四个版本正式发布。
10-21 14:00 UTC 起
Cloudflare 观测:披露后 45 分钟内数万次尝试,2 小时内数十万次。
10-22
攻击尝试规模达到数百万次;CERT/CC、CISA、NSA 相继发布警报;AWS 披露其蜜罐在公开披露前 4 天(10-17)就记录了针对该漏洞的利用尝试;VulnCheck 宣布做出了可绕过 ASLR 的完整 RCE。
10-23
PHP 追加信息性公告,披露 CVE-2026-115560,建议关闭会话上传进度;Censys 统计仍有 3440 万个 PHP 服务运行在易受影响的版本上。

发现过程:两条线,最后汇到同一个根因

这次不是「一个团队发现一个洞」。同一段时间里,至少三支独立的力量撞到了同一个根因的不同表现形式:

Calif × OpenAI 线(Patch the Planet)

07-30
Calif 在 OpenAI「Patch the Planet」计划下开始审计 PHP。
08-01
审计开始仅几天就找到了第一个 OOM-致命错误触发点,以及三个可中断点中的第一个,拼出了打 WordPress 的 RCE。
08-11
研究员 Ryan A 向 PHP 报告第一个漏洞;OpenAI 同日独立报告同一根因。
08-14 / 08-15
Calif 提供 RCE PoC;PHP 确认 PoC 有效。
08-21
Calif 报告第二个漏洞。
09-18
OpenAI 补充新的攻击向量。

Wiz Research 线

08-19
Wiz 用 Wiz Atlas + Gemini 4 Argon + GPT-5.6 Sol 发现第一个 PHP 漏洞。
08-20
确认对 Drupal 的未认证 RCE。
08-21
确认对 WordPress 的未认证 RCE。
08-24
Wiz 向 PHP 安全团队报告。
09-21
PHP 确认收到报告,开始制定协调修复方案。

再往后就是第 02 节开头那张时间线。这条并行发现的过程本身就是一个信号:同样的「计数先于分配」模式,被两套独立的 AI 辅助流程在几周内同时挖了出来—— 这比任何单个 CVE 都更能说明问题的普遍性。

一个值得记住的信号

荷兰 DigiD、MijnOverheid、议会相关站点、英国政府站点,以及若干亚洲、欧洲的大型服务,在补丁发布之前就选择了主动下线。 这个动作本身,比任何 CVSS 分数都更能说明修复窗口有多紧。

03根因:三个「中断点」,同一个模式

这一节有点长,但它是全文的地基。看懂这三节,后面的一切都顺着推得出来。

Wiz 与 Calif 对这批漏洞有一个共同的、非常精炼的概括:

发布或销毁状态 → 执行可能失败的操作 → 初始化 / 提交 / 恢复

如果在「可能失败的操作」里发生了非局部的致命错误,那么提交或回滚就被跳过,而幸存下来的消费者会信任这个错误的状态。

—— Calif Research 对「计数先于分配」模式的抽象

用大白话讲:PHP 在干一件事的时候,先把「我已经完成了」记在账上,再去干这件可能失败的事。 正常情况下没问题;但只要中间插进来一个致命错误(OOM 最常见的入口),流程就直接跳到了收尾, 而收尾的逻辑只会问「账上记了几笔」,不会问「这几笔是不是真的干完了」。

3.1 前置知识:PHP 为什么能被「内存耗尽」打断

PHP 有一个每个请求共用的 memory_limit(默认常见值是 128M)。一旦任何一次内存分配会超过这个上限, PHP 就会触发致命错误并跳出(这正是你偶尔在日志里看到的 Allowed memory size exhausted)。

这在开发时是好事——防止一个死循环把机器吃光。但它也提供了一个可由攻击者远程触发的、精确到字节的「中断开关」: 攻击者只要知道一个请求大概会分配多少内存,就能构造请求,让它在指定的那一行附近恰好撞上上限。

这个「可用内存上限作为攻击原语」的思路其实很老——Calif 的复盘直接把它追溯到 2004 年 Stefan Esser 发现的第一个 PHP memory_limit 远程漏洞。 区别在于:当年没有找到稳定可靠的利用链,而今天有了。

3.2 触发点一:哈希表桶 —— 「先记账,再分配」

PHP 把表单提交变成 $_POST,底层是哈希表(HashTable),每个键值对存在一个叫「桶」(Bucket)的槽里。 插入一个键值对的顺序,在源码里是这样的:

cZend/zend_hash.c —— _zend_hash_str_add_or_update_i()(简化)
idx = ht->nNumUsed++;          /* ① 先递增「已用槽位」,桶立刻对拆解可见 */
ht->nNumOfElements++;         /* ② 再递增「元素计数」 */

p = ht->arData + idx;
p->key = key = zend_string_init(str, len, ...);  /* ③ 真正分配键 —— 可能 OOM 致命 */

p->h = ZSTR_H(p->key) = h;
ZVAL_COPY_VALUE(&p->val, ...);                    /* ④ 填充哈希值和值 */

问题就在 ①~④ 之间:如果第 ③ 步分配失败触发致命错误,第 ④ 步永远不会执行,但 ① 已经执行了。 这个桶在哈希表眼里是「存在且有效」的,可它的 key 和 val 里是什么?是这块内存上一次被使用时留下的字节。

接下来 PHP 会照常清理这个失败的请求。清理代码遍历数组时只看计数:

czend_array_destroy() 相关分支(简化)
for (p = ht->arData; p != ht->arData + ht->nNumUsed; p++) {
    i_zval_ptr_dtor(&p->val);        /* 把「值」当成真正的 zval 去析构 */
    if (p->key) {
        zend_string_release(p->key); /* 把「键」当成真正的 zend_string 去释放 */
    }
}

于是:一个攻击者可以部分控制的桶,被当作有效的 PHP 值来析构。 而「析构」在 PHP 里不只是释放内存——数组的析构会调用它记录的回调(destructor),这正是把它升级成任意函数调用的入口(第 04 节详述)。

ht->arData[ ] nNumUsed = 3(已记账) 桶 0 · 已初始化 key + val 正常 桶 1 · 已初始化 key 分配成功 桶 2 · 半初始化(危险) 计数已加,key/val 是堆上的残留字节 请求关闭时被遍历 i_zval_ptr_dtor() 把残留字节当 zval → 指向伪造的数组 → 调用其析构回调 system("<攻击者的命令>") 受害请求的内存布局由攻击者通过堆布局(heap grooming)塑造

图 1:一个「记了账但没初始化」的桶,如何变成命令执行。这也是为什么它和「越界写」这类漏洞完全不同。

← 左右滑动查看完整图示 →
关键点:这是「通用模式」,不是「单个 bug」

Datadog 特别提醒:不要针对哈希表这个具体点去建防御,因为「先更新元数据、再分配它描述的内存」这个模式在代码库里还有别的实例。 事实上,Calif 是在找到第一个洞之后,把这个模式抽象成一份检索规则,再去代码库里「按图索骥」,才找到后面几个点的。 补丁也没有重构这个顺序——它改的是周围的错误处理。

3.3 触发点二:请求启动屏障 —— 为什么应用层完全看不见

要理解为什么「认证、过滤、路由都挡不住」,得看 main/main.c 里请求启动的真实顺序:

cmain/main.c —— 请求启动阶段(简化)
PG(modules_activated) = 0;          /* 此刻:模块尚未被标记为「已激活」 */

php_hash_environment();             /* ← 在这里解析 $_POST / $_FILES、multipart 输入 */
zend_activate_modules();            /* ← 各扩展的正常 RINIT 在这里才执行 */
PG(modules_activated) = 1;

看到那个顺序了吗?解析攻击者的输入,发生在「模块激活」之前。 这意味着两件事同时成立:

这一「跳过」正是漏洞的核心:本该在 RSHUTDOWN 里把全局指针清掉的代码没跑,而它指向的内存已经没了。 这也解释了 Datadog 那句话——他们把漏洞复现在一个只打印一行字的 PHP 文件上: 因为出事的代码根本不在应用里,而在请求启动 / 关闭这两段「基础设施代码」里。

会话上传进度(session upload progress)只是暴露得最典型的那个窗口。这段代码需要在请求启动期就打开会话,才能追踪上传进度, 于是它在模块激活之前就初始化了会话状态,把 PS(id) 等指针指向当前请求的堆。正常路径下,multipart 解析的 END 回调会把这些收拾干净; 一旦解析过程先触发致命错误,这个回调就被跳过,页面就这样留了下来。

3.4 触发点三:worker 只重启一半 —— UAF 与 OPcache 污染

CVE-2026-109619 的核心是「进程级全局状态」跨请求存活。php-fpm 的 worker 是长驻的,处理完一个请求就去接下一个。 PHP 为此设计了一套重置逻辑;但如果致命错误插入得足够巧,重置本身也可能被打断,或者发生得太晚。

Wiz 确认了两个可利用的形态:

而 OPcache 那条更麻烦,因为它跨进程。OPcache 把编译后的脚本结构持久化在共享内存里,所有 worker 共用, 每个 worker 用一个「索引 → 运行时指针」的映射表来访问。持久化时会先推进进程内的 frontier(CG(map_ptr_last)), 完成之后才发布共享的 frontier。如果中间分配失败:进程内推进了,共享状态却以为那个槽位还空着。 别的 worker 把这个槽位分配给一个类型不同的对象时,旧的共享元数据就会解析出一个类型错误的指针——于是在共享内存里形成类型混淆。

单个 worker 重启

能清掉 EG()、PS()、BG() 这类进程级残留,解决 CVE-2026-109619 的单进程路径。

但对于已被污染的 OPcache 共享内存,重启一个 worker 会把脏数据原样读回来。

重建整个 worker 池 / 共享内存

重启 FPM master、让所有 worker 连同共享内存一起重建,才是干净的。

这也是为什么第 08 节的应急步骤里写的是「重启全部 worker」而不是「重启 PHP 服务」就完事。

3.5 第四张脸:不执行代码,只读内存

这批漏洞里最容易被低估的一条,其实不是 RCE,而是内存泄漏。Wiz 用一个很好的类比形容它: 「Heartbleed 风格的内存泄漏」——只不过泄漏的范围是整个 PHP worker 的工作内存。

机制同样利用了「请求启动早于模块初始化」这个窗口:

请求 Amultipart

在上传进度处理里初始化会话,把会话 ID 发布到 PS(id),指向当前请求的堆。

打断OOM

发生内存耗尽,请求在清理完成之前被中止;请求堆被销毁,但 PS(id) 仍指向已释放的分配。

请求 Bmultipart

同一个 worker 处理下一个 multipart 请求,走到上传进度的清理路径,在正常的会话初始化重置这个指针之前就用到它。

泄漏Cookie

PHP 把这个悬空指针和长度当作一个有效的 zend_string,URL 编码后写进响应的 PHPSESSID Cookie——攻击者可控大小的 worker 内存就此回传到攻击者手里。

三个细节让它格外恶心:

顺带一提,这也是为什么第 08 节里第 4 条排查信号是「PHPSESSID Cookie 异常变大或格式不对」—— 这正是泄漏原语在你日志 / 浏览器里留下的唯一痕迹。

04利用链:把一个半初始化的桶变成 shell

以下基于 Datadog Security Labs 在 PHP 8.5.11 上的独立复现(Debian 12 ARM64、Apache prefork + mod_php)。只讲机制,不提供可用 PoC。

先说清楚为什么大家做不出稳定 exploit

漏洞披露两天,GitHub 上冒出了数百个自称的 PoC 仓库,但真正能用的极少。VulnCheck 的 Spencer McIntyre 点得很准: 瓶颈不在于「找到 bug」,而在于 ASLR——你需要知道伪造结构体和目标函数(比如 system())在内存里的真实地址。 猜地址的代价是 worker 崩溃,重试成本极高。VulnCheck 声称做出了第一个在开启 ASLR 的 x86-64 + 推荐生产配置下成功的 exploit,仅向其客户提供。

所以一条完整的利用链需要三块拼图。缺任何一块,你都只能打崩我的进程;三块齐了,你就能拿到我的 shell。

💥

第一块 · 破坏原语

用 OOM 打断插入,留下一个「被信任的残留桶」,让引擎把攻击者的字节当成有效数据结构。

🔍

第二块 · 地址发现

用 PHPSESSID Cookie 泄漏把进程内存读回来,拿到可用的指针,从而抹平 ASLR 的不确定性。

🎯

第三块 · 控制流劫持

让数组析构时调用一个「攻击者指定地址」的回调,参数也是攻击者指定的——于是变成 system(cmd)。

4.1 先解决一个前置问题:怎么发出「足够大又足够小」的请求

要打 OOM,请求得足够大;但 PHP 还有 post_max_size(默认 8M)拦在前面。Calif 找到了两个绕过思路,都很巧妙:

绕过一:POST 体积被「声明的长度」限制,而不是「实际消耗的字节」

PHP 是在开始自动解析 POST 之前,用声明的 Content-Length 去和 post_max_size 比较的, 但它并不对「实际消耗的字节」执行同样的限制。于是 Apache 可以把分块的 HTTP/1.1 请求或没有长度的 HTTP/2 multipart 请求原样交给 PHP, 而其中并没有一个有意义的声明长度。如果配置了输入解压过滤器,压缩长度与解码后流长度之间也会形成同样的错配。

绕过二:一个「空文件」字段,能在 8M 体积内吃掉 128M 内存

这是一个非常「PHP 特色」的放大技巧:RFC1867 的文件上传部分如果写 filename="",它不包含任何文件数据, 也不占用 max_file_uploads 的名额。但 PHP 依然会为它创建 $_FILES 里的一整套六个条目: name, full_path, type, tmp_name, error, size。

当表单字段名被做成嵌套形式(如 files[a][b][c]...),PHP 必须为这六个条目各自把整个层级重建一遍。 于是一条短短的空上传记录,会被放大成大量原生 HashTable、Bucket 和字符串键分配。 几百条这样的记录就能吃掉超过默认 128 MiB 的堆,而完整的 HTTP 请求体仍然小于默认 8 MiB 的 post_max_size。

这也解释了为什么官方缓解建议里特别强调:要在反向代理层限制「解码后的请求体大小、字段数、multipart part 数、嵌套深度」,并且要覆盖没有合法 Content-Length 的请求—— 因为这些恰恰是绕过上面两道闸门的地方。

4.2 三阶段攻击流程

1
打断一次插入

Datadog 的复现请求带三个表单字段,各有分工:

=[276 字节二进制数据]&a=1&[一个约 3.5MB 的超长字段名][x]=1
  • 空名字段:它的值会在解析过程中被复制、然后因为字段名为空而被释放——提前准备好一块「待复用」的内存;
  • a=1:让 PHP 分配哈希表存储,顺手把刚释放的内存取回来;
  • 超长名字段:[x] 数组语法会走「字符串键插入」这个特定路径(普通字段名走的是另一条不会出事的路径), 而字段名的长度被精确调校成「第一次键分配成功、第二次正好超过 memory_limit」。

失败发生在请求初始化阶段。目标脚本里没有任何一行代码能影响它——认证、过滤、ORM,全部还没来得及生效。

2
用可控数据填进那个「账上有、实际空」的桶

光有内存破坏还不够——残留的字节如果是随机的,结果只是崩溃。攻击者要的是那 276 字节里装着自己精心构造的数据。

Datadog 复现里的具体数字很有教学意义:某个小哈希表会申请一块 320 字节的内存; 而 276 字节的二进制值加上 zend_string 头和结尾符,恰好落在同一个 320 字节的分配尺寸档里。 于是空名字段的值被解析时复制、随后被释放,a=1 让哈希表复用这块刚释放的内存——大部分二进制内容就这样在第一个合法桶之后幸存下来。 等到超长字段触发致命错误、PHP 去读那个「有计数但没初始化」的桶时,读到的就是攻击者埋下的字节。

这个步骤的专业名词叫 堆布局(heap grooming):通过一连串「分配—释放」的顺序,把内存堆整理成你想要的样子。 它也是这类漏洞为什么难以复用落地的原因——不同的 PHP 版本、不同的配置、不同的应用行为,都会让这些「尺寸档」对不上。

3
让「数组清理」变成一次函数调用

PHP 的数组结构里带一个析构回调——PHP 销毁这个数组时,用它来清理每个元素。攻击者要做的,就是把那段伪造的字节塑造成: 「这是一个数组」+「它的析构回调地址 = system()」+「它的元素 = 一个命令字符串」。

然后什么都不用做了。请求关闭时:

请求初始化失败
  → 请求关闭时销毁 $_POST
  → PHP 把「半初始化的桶」当作一个数组
  → PHP 销毁这个伪造的数组
  → 析构回调调用 system(命令)

Datadog 的复现里,命令以 www-data 权限运行,总耗时 3.10 秒,进程树里能看到 Apache worker 下挂着一个 shell。 最坑的是:这次攻击返回了 HTTP 200,内存耗尽的错误信息就写在响应体里,而那个 Apache worker 在命令执行完之后还在继续运行。

这条结论请划重点

「没有崩溃」「返回 200 」「网站看起来正常」,都不能证明你没被打。 这也是为什么下一节的检测手段必须看进程行为和日志相关信号,而不是看响应码。

4.3 那 CVE-2026-109619 呢?

它的走向不同。Calif 的第 2 号触发点(通用反序列化器的簿记状态)是这样利用的:

这里有一个特别值得记住的设计失误:析构块的计数是一个有符号整数 used_slots, 分配函数只检查「加上新增条目后会不会超过上限」,却默认已有计数不可能是负数。 内存被回收污染之后,负数的计数会让「槽位」算到目标数组前面去;再配合序列化输入里的重复键, 就能把一份真实的 zval(指针 + 类型信息)复制到任意指定位置。这就是一个可控的写原语。

注意它的性质:这是 application-dependent RCE——它要求应用里存在一次「启动后可触达的解码操作」。 换句话说,这类路径的触发条件取决于你的业务代码。Calif 演示的是 Nginx + PHP-FPM 上打真实应用。

05为什么 mod_php 和 PHP-FPM 结局不同

同一个致命错误,在两种 SAPI 下走进了两条截然不同的错误处理分支。这一节是理解「我到底有多危险」的关键。

前面说过,致命错误发生的位置决定了后果。关键的差别只有一个问题:「启动失败」时,这个进程还要不要活下去?

Apache + mod_php:请求关闭 ⭢ 堆被释放 ⭢ 进程复用

php_apache_request_ctor() 调用 php_request_startup() 失败后 zend_bailout() 跳出, 但跳出之后的代码〈php_apache_request_dtor() → php_request_shutdown(NULL)〉照样执行。

于是:RSHUTDOWN 因为模块未激活被跳过 → 全局指针没被清 → 但请求堆被销毁了。 同一个 Apache 子进程接着去服务下一个请求,正好踩进这条悬空链。

结论:mod_php 在默认配置下就能被无认证请求打穿,这是官方认定的最高风险形态。

PHP-FPM:启动失败 ⭢ 模块关闭 ⭢ 进程退出

FPM 的请求循环遇到 php_request_startup() == FAILURE,会 fcgi_finish_request()、php_module_shutdown(), 然后 return FPM_EXIT_SOFTWARE——worker 直接退出了。

没有「同一个进程的第二个请求」来消费那份悬空状态,所以「启动期」这一条路径在 FPM 上走不通。

结论:FPM 不是安全,只是入口不同——它需要经由应用层代码路径(启动之后的致命错误)来触发。

这个区别正好解释了官方公告里那句看起来自相矛盾的话:「我们认为 mod_php 风险最高,有一个已演示的利用,无认证请求能在任何应用代码运行之前触发攻击」, 同时又写了「PHP-FPM 上通过应用层代码路径也确认了 RCE」。两者都对,只是路径不同。

5.1 四种缺陷在不同 SAPI / 目标上的战绩

Wiz 公布的四类缺陷,以及它们分别打穿了什么:

缺陷类别已演示的效果
上传进度反序列化器 UAF UAF / 内存破坏 WordPress · Drupal · Joomla 预认证 RCE
远程进程内存泄漏 信息泄漏 与具体应用无关的内存读取
致命错误回溯 UAF UAF Drupal 预认证 RCE(8.5 上还在 mod_php 确认)
OPcache 类型混淆 共享内存污染 WordPress 预认证 RCE(跨 worker)

注意第三、第四行打的是不同的 CMS,而两个 CVE 各自只覆盖两个缺陷。这就是为什么单看一个 CVE 的说明会「看不懂全貌」—— 真正的问题在它们的交集:同一个「状态先发布、失败后不回滚」的代码模式,散落在内存管理、致命错误处理、请求关闭、OPcache 持久化这四个子系统里。

5.2 一张表:你现在到底有多容易被击穿

你的部署入口需要应用配合吗风险
Apache + mod_php单个无认证 multipart 请求不需要最高
Nginx + PHP-FPM应用层触发(如 session_start())+ 请求交替需要高
php-cgi / FrankenPHP / LiteSpeed官方明确同等对待,按各自启动路径不同视 SAPI高
纯 CLI 脚本不处理 HTTP 请求—不在射程

5.3 那么应用真的完全无辜吗?并不完全是

漏洞的成因在引擎,但你的应用决定了你有多容易被瞄准。Calif 的第二个触发点讲得很清楚: 它需要应用里存在一次「启动后可触达的解码操作」——session_start()、session_decode()、unserialize(), 以及像 WordPress maybe_unserialize() 这类包装器。解码的数据本身可以是完全合法的, 攻击者只要控制了解码周围的内存压力,就能把这次解码打断在一个「有用的」位置。

PHP 官方在临时缓解建议里也给出了同样方向:修复攻击者可触达的致命错误和执行超时,尤其是回调、上传/导入处理、清理流程中的失败。 因为这两类「异常的前置状态」,正是触发脆弱路径的引信。

06为什么它藏了这么多年

这段历史比漏洞本身更值得写进博客——它解释了「开源基础设施的安全,到底卡在哪」。

把时间轴拉长,这是一个跨越二十多年的故事。Calif 把它做成了漫画,但时间线上的每个点都是真的:

2004
Stefan Esser 发现了 PHP 第一个 memory_limit 远程漏洞。据 Calif 复盘,当时他判断严重到不公开发布 exploit; 后来 CORE 和 Immunity Inc 各自开发了非公开的利用。此后二十年,PHP 里「默认可反序列化的类」中的 UAF / 类型混淆都被逐个找到并修掉了。
2020
jr@coredu 报告了 PHP bug #78876:某些超长变量会导致 OOM,而且临时文件没有被清理。当时这条报告没被当成安全问题—— 但它恰恰是同一个 OOM 模式的早期影子。
2024
OSTIF 组织、Quarkslab 执行的 PHP 首次外部安全审计(由 Sovereign Tech Agency 资助)中, 再次发现了同一个 OOM 问题。当时有人推测它可能被利用,但没有拿出具体路径,而手头还有影响更明确的问题要修,于是被压下了。 有意思的是:HHVM 当年反而因为 #78876 的那个补丁而「顺便免疫」了这个问题。
2024 → 2026 · 修不动的两年
要修它,每条路都疼:改内存管理可能破坏向后兼容;换一种处理方式会引入新的 DoS 面; 某些方案要求大量第三方扩展跟着改,根本协调不动。安全团队是共识制,几个方案各有代价,谁也说服不了谁。 用 PHP 基金会自己的话说:「我们不假装不是这样:这次延误是一次结构性失败,既源于安全团队的决策流程,也源于资源不足。」
2026-08
Calif 的审计在开始几天内就找到了第一个 OOM 触发点和第一个可中断点;8 月 11 日报告,同一天 OpenAI 独立报告同一根因; 8 月 24 日 Wiz 报告。从这天起,它成为 PHP 基金会安全团队最高优先级:有人连着周末、通宵在协调、验证、写补丁。
2026-10-21
四个分支同时修复并发布。从「发现」到「全局修复」不到三个月——其中还包括一段被泄漏打乱的发布过程。

我们平时骂厂商「有洞不修」,但真实情况往往是一群人在兼容性、性能和安全性之间反复拉扯,最后被一个外部报告逼到必须选一条路。

—— 开源基础设施的安全,很多时候不是技术问题,是排期和人力问题

6.1 一个值得所有安全团队抄作业的方法论

Calif 在复盘里坦白了一件很有价值的事:他们没有用什么特殊工具,用的是所有人都能用的模型, 真正起作用的是「知道目标的历史」——他们清楚 PHP 二十年来的漏洞谱系,知道 2004 年那类 memory_limit 问题的思维方式, 所以能明确地引导模型往那个方向去查。

由此得出的两个实战结论:

07谁在射程里

只要是对外提供服务的 PHP 安装就在范围内。而环顾四周,这几乎等于「所有 PHP 网站」—— 按 W3Techs 的口径,PHP 驱动了全球接近四分之三的网站,而 Wiz 引用的「70%」这个数字,也就是从这来的。

WordPress

Wiz 用 wordpress:latest 打穿了它。一个零插件的纯 WordPress 站,只要 PHP 版本旧,照样在射程内。

Drupal / Joomla

同为 Wiz 的演示目标(drupal:latest、joomla:latest)。Drupal 官方在 20 日就挂了 Critical PHP update 公告——这是 PHP 的更新,不是 Drupal 的。

MediaWiki / Nextcloud / Magento

Wikipedia 背后的 MediaWiki、企业网盘 Nextcloud、电商 Magento,都被 Calif 确认过预认证 RCE。

phpMyAdmin / Roundcube / WHMCS

这次名单里最刺眼的几个:数据库管理界面、邮件客户端、主机商计费系统——都是「拿下来就是一片」的角色。

Laravel / Symfony 及自研系统

框架无关。Calif 明确表示,一个只打印 Hello World 的裸 PHP 文件,在 mod_php 上同样是可被 RCE 的目标。

共享主机 / 托管面板

风险被放大:同一个 FPM 池里的站点共享 worker,内存在泄漏原语面前几乎没有隔离可言。

7.1 在野活动:数百万次尝试,但攻城锤还在路上

截至目前,在野流量以扫描、指纹识别、Botnet 投放尝试为主。Sophos 与 Arctic Wolf 记录了试图投递一个名为 Potassium 的 Mirai 变种的动作 (连接硬编码 C2 → 检查 HTTP 200 → 下载 Linux 二进制 → 具备 DDoS 能力,并预留了通过 ENS 解析 C2 的能力,当时未启用)。 这些尝试被拦截,未见成功。

Sophos 说得很实在:即便用自己的安全团队配合 AI,做出可用 exploit 也比预期费劲得多。这正好印证了第 04 节——三块拼图缺一不可。

但趋势是确定变坏的

VulnCheck 已演示在开启 ASLR、推荐生产配置下的完整 RCE;Cisco Talos 提醒,扫到漏洞并套用公开 PoC 远比把攻击做进定制工具简单, 高级攻击者很可能仍在开发中,应利用这段窗口期完成修补,并警示其级联效应「让人想起 Log4j 之后的几波利用」。 The Register 援引 VulnCheck 时还有一句:对手显然在花时间和 token 研究这个洞。

08处置清单:按优先级排的今晚动作

官方原文很克制,但意思很明确:没有完整的缓解措施,更新是唯一安全的选择。以下每一条都附了「为什么」。

8.1 修复版本对照

分支受影响版本首个修复版本备注
8.5低于 8.5.128.5.12当前主线
8.4低于 8.4.278.4.27—
8.3低于 8.3.368.3.36—
8.2低于 8.2.358.2.352026-12-31 EOL,升完就该规划下一站
7.4 / 8.0 / 8.1全部官方不再发版 PHP 安全团队提供了最小源码 backport(分支名形如 PHP-8.1-CVE-2026-109618-109619); 发行版 / LTS 厂商可能有回填包。代价是这些老分支的修复可能更慢,因为厂商验证工作量更大
四个非常容易踩的坑

1. 别只看版本号字符串。Debian、Ubuntu 这类发行版是把补丁回填到旧版本号上的。 要从包变更日志或发行版公告里查这个 CVE,而不是看 php -v 的数字。

2. 别只查命令行 PHP。它和目标运行时可能是不同的版本、不同的配置。要查真正对外服务的那个: FPM 的池、Apache 模块、容器镜像里的解释器,以及那些自带 PHP 的设备面板(cPanel、Plesk 等)。

3. Docker 要重新 pull。只改 tag 不重新拉,本地缓存可能还停在旧 digest;升完记得重建镜像。

4. 重启,而且要重启「所有 worker」。升级了二进制但旧 worker 还在跑,等于没升;共享的 OPcache 也要跟着重建(见 3.4)。

8.2 如果暂时动不了:可以减伤,但不能免疫

PHP 官方把话说得很直白:「只有更新才是完整的解决方案,以下措施只降低特定攻击路径的暴露面,不能让安装变安全。」 下面按需要防护的 CVE 分开列——因为很多人以为一条 session.upload_progress.enabled=0 就万事大吉,但它只封住 109619 的部分路径,对 109618 毫无作用。

CVE-2026-109618(OOM 破坏)

  • 把 memory_limit 设为 -1,最好通过 FPM pool / Apache 的 php_admin_value,让应用改不回去; 并逐一确认没有哪个 pool、目录或应用设置又把有限值设回来。
  • 所有能设的地方设一遍——第三方扩展、php_admin_value、各 vhost 目录——保证没有人回滚这个值。
  • 压低 post_max_size:不需要上传的站设 8M,需要上传的设 32M;upload_max_filesize 保持在其之下。
  • 在 Web 服务器 / 代理层限制:解码后请求体大小、输入字段数、multipart part 数、头部数量、嵌套深度。必须覆盖没有合法 Content-Length 的请求(分块传输、HTTP/2),否则第 4.1 节的绕过继续有效。
  • 用容器 / cgroup 作为可用性边界,策略上只杀受影响的 worker。
  • 不要用 RLIMIT_AS / RLIMIT_DATA 限内存——该限制触发时 mmap() 失败,恰好把执行推入那条脆弱的分配失败路径。

CVE-2026-109619(状态残留)

  • 设 session.upload_progress.enabled=0,然后重启所有 worker。必须在 php.ini / FPM pool / 服务器配置里生效,不能依赖 ini_set() 或 FPM 的 .user.ini(后者由 CGI/FastCGI 读取,Apache 模块不读,且默认 300 秒才重读一次)。
  • PHP 8.5 上再设 fatal_error_backtraces=0,并重启所有 worker(只封住已确认的 fatal backtrace 路径)。
  • 移除「在每个请求上生效、并在初始化前产生诊断信息」的无效或已废弃设置。用 error_reporting、display_errors 或 @ 把诊断藏起来没有用——诊断照常产生,只是你看不见。
  • 修复攻击者可触达的致命错误与执行超时(回调、上传/导入处理、清理流程)。
ini临时减伤配置(务必配合重启 worker 生效)
; —— 针对 CVE-2026-109618 ——
; 尽量放在 FPM pool 的 php_admin_value 里,应用无法覆盖
memory_limit = -1

; 不需要文件上传的站点
post_max_size = 8M
upload_max_filesize = 2M

; —— 针对 CVE-2026-109619 ——
session.upload_progress.enabled = 0

; 仅 PHP 8.5
fatal_error_backtraces = 0

官方也给了一句很重要的限定:memory_limit=-1 只是拆掉了可控的内存上限, 底层的分配失败仍然可能走到那个路径;而且它本身会放大内存耗尽的风险——所以这必须配合容器 / cgroup 边界使用,不是长久之计。

8.3 有争议的一条:拦截 multipart 请求

部分研究者提出比上面更强的临时方案:在 Web 服务器层,在请求到达 PHP 之前就拦截带 multipart/form-data 的请求体, 因为嫌疑人指出问题的入口正是 PHP 读取该请求体的方式。Datadog 也承认:反向代理或其他 PHP 前置边界上的控制,可以在请求到达脆弱解析器之前就把它挡掉。

如果要用,注意两件事

① 代价是真实存在的:文件上传、部分不带文件的 multipart 表单会立即失效,直到你撤掉拦截。

② 不能只拦 POST。PHP 8.4 新增了 request_parse_body(),可以在其他 HTTP 方法上读取 multipart 请求体,一条「只拦 POST」的规则会被轻松绕过。

8.4 WAF 能做什么,不能做什么

Cloudflare 已向所有套餐(含免费版)推送了针对已知利用向量的紧急规则,其 CTO 自己称之为「a STOPGAP only」。它有两个前提需要你清醒认识: 规则只对经过 Cloudflare 的流量生效(源站 IP 一旦裸奔,规则就是摆设),且它挡的是已经见过的利用形状,换个编码、改改分块方式就可能绕过去。

Datadog 还给了一条很实际的 WAF 建议:先确认你的检测引擎怎么处理大请求体—— 如果引擎会截断请求体、或者解析器会丢弃字段,那规则根本看不到第 4.2 节里那个 3.5 MB 的超长字段名。

8.5 打补丁之后:日志与进程要这样查

成功利用不一定崩进程。Datadog 的复现甚至返回了 HTTP 200。所以要分三层排查,并交叉比对:

层看什么为什么是信号
HTTP 请求 异常大的表单提交;单个字段名长达数 MB;表单值里出现原始 NUL 等控制字节 第 4.2 节的请求形状,正常业务几乎不会产生
PHP 错误日志 Allowed memory size ... exhausted,尤其位置显示为 in Unknown on line 0 Unknown on line 0 意味着在请求初始化阶段就挂了,脚本一行都没跑
Apache 错误日志 AH00052: child pid ... exit signal Segmentation fault (11) 攻击者猜错地址时 worker 崩掉——扫描失败的痕迹同样是情报
响应 Cookie PHPSESSID 异常变大、格式不对 内存泄漏原语的直接痕迹(第 3.5 节)
进程树 apache2 / php-fpm / httpd 拉起了 sh、bash、下载工具、扫描工具 最直接的证据;Calif 已开源 process-watcher 类工具,但它「只能看到、不能阻止」

官方与 Datadog 都提醒:不要只凭「没有崩溃」「返回 200」「网站正常」就判断安全。 如果出现过上述信号,或者你的机器在 10 月 21 日到打补丁之间一直暴露在公网,那么光打补丁不够: 应当假设数据库口令、API Key、会话密钥可能已被读取,该轮换的都要轮换。英国 NCSC 的建议更直接——修不了的系统,考虑先下线。 发现异常时正确顺序是:隔离服务器 → 保存 Web 与 PHP 日志 → 排查 webshell、Web 根目录新文件、新加的 cron / 计划任务、新账号。

8.6 今晚的自查清单

点一下可以勾选(仅本页交互,不保存)。

09AI 与前景:这次补丁没有真正解决的问题

这一节是全文我最想让你读完的部分——因为它关系到你未来几年怎么给漏洞排优先级。

9.1 补丁是怎么下的,以及它留下了什么

PHP 的补丁没有去重构那个「先记账再分配」的顺序。它改的是周围的错误处理:

Datadog 的一个关键观察

即便在 PHP 8.5.12 里,哈希表插入仍然是先更新计数、再分配键。修复的是错误处理,不是这个模式本身。 Datadog 的判断很直接:先更新元数据、再去分配它描述的内存,这个写法本身就留了后门。

如果攻击者找到另一种方式,在「元数据已经把未分配的内存当成有效」的情况下打断初始化 (比如——一个数组记录了长度为 2,而分配失败后实际只有一个元素),同一类漏洞会回来。

Wiz 也确认了这一点:他们对 PHP 报告的四类缺陷之外,还有其它变体,只是会被归进同样这两个 CVE 号下。 目前的补丁在已修复版本里消除了已知的所有变体——但没有人能保证这是全部的变体。 Datadog 甚至预期:随着研究者继续挖,更多同类 bug 会浮出水面,而逐个 backport 未必可持续; 更老的 PHP 版本里独有的同类实例,可能被发现而永远得不到补丁。

9.2 「利用难度」这道防线,正式失效

这是这次事件真正的分水岭。过去做风险评估有一个隐含前提:利用复杂度本身就是一道防线。 一个需要精确控制内存布局、反复调试、对每个目标单独适配的漏洞,只有顶级团队玩得起,所以「暂缓修复」是可以接受的选择。

Wiz 的描述把这个前提拆得很干净:

「AI 之前,这种手工工作很难实现,更难扩展。有了 AI,这不再是障碍。大模型可以帮助把 exploit 适配到不同的 PHP 版本、配置和内存布局。 配合测试与调试工具,模型可以分析失败并迭代调整,从而降低每个目标的专家投入。」

—— Wiz Research,「AI 拐点」一节

原话里有个数字对比非常有说服力:「借助前沿 AI 模型和合适的工具,一名研究员用一周就完成了过去需要一支世界级团队数月才能完成的工作。」 而另外三位当事方给出的时间线是:审计从 7 月 30 日开始,8 月 1 日就已经拼出了第一条完整的 RCE 链。

Calif 的 CEO Thai Duong 在被问到补丁公开后的局面时说得更直白: 「网上随便一个人就能把补丁拿去跑一遍 AI,然后在网上甩出一段 exploit 脚本。所有事情都会失控。」

对普通开发者的具体含义

「这个洞太难利用了」不再是一个能写进风险评估的理由。 那些过去因为「需要顶级团队」而被降优先级、被搁置在 backlog 里的漏洞,现在需要按「一个研究员一周就能出 PoC」来重新排期。 这也会改变供应链安全的算法:你评估的不再只是「厂商有没有发补丁」,而是「修复的速度能不能追上发现的速度」。

9.3 一个更大的问题:AI 不光在找洞,也在制造工作量

PHP 基金会在公告里提供了一个很重要、但容易被忽略的数字:安全团队自 7 月 1 日以来发布了 15 个 CVE, 并处理了超过 100 份报告——这些报告最终被判定为无效、重复或无法复现。 「AI 帮助团队验证报告、把同一根因的报告归组,但每一个决定都由人来做,每一个补丁都由人来写。」

这才是真正的瓶颈所在。工具让「发现」变便宜了,但「决定一个存在多年的设计取舍该选哪条路、权衡兼容性代价、审查补丁」这件事, 仍然只能由真正熟悉整个 PHP 代码库的资深工程师来做——而这样的人现在太少了。PHP 基金会最后的呼吁也就在这里: 这件事需要基于 PHP 的产品公司、托管商、云平台、发行版,以及依赖 PHP 的政府机构来出钱赞助。

9.4 一个诚实的边界:我们仍然不知道有没有人被打穿

现有的公开报道口径是:在野活动达到数百万次,但还没有确认的成功远程代码执行; Previdian、Wordfence 等报告的是扫描与探测。与此同时,VulnCheck 已演示了可用的、绕过 ASLR 的完整利用(未公开)。 这两件事并不矛盾——它们合起来说明的是:「能力」已经存在,只是还没被大规模使用。

所以在读完这篇之后,正确的动作不是「放心」或者「恐慌」,而是检查版本号。

10写给我们自己的启示

  • 「应用层安全」有天花板,而且比你以为的低。 Datadog 那条结论值得刻在脑子里:漏洞可以在一个只打印一行字的 PHP 文件上被触发,请求根本到不了应用代码。 你花在输入校验、ORM、鉴权框架上的所有功夫,都建立在「运行时是可信的」这个前提之上。前提一破,上面全是装饰。 我们能做的,是把运行时补丁当成和依赖升级一样常规的动作,而不是等预警来了才抓瞎。
  • 「难以利用」是一个会过期的判断。 这次的故事是:2004 年发现了苗头 → 2020 年有人报告了同模式的 bug → 2024 年审计再次发现但「未证明可利用」→ 2026 年被 AI 在几天内打穿。 当年每一个「暂缓」的决定,从当时的信息看都是合理的。 可怕的不是有人做错了决定,而是「可利用性」这个变量变得太快了。 给自己留一条规矩:对 CVSS 高分但「难利用」的漏洞,设一个到期复查的日期,而不是一个「已评估,风险可接受」的终态。
  • 不要把自己绑在 EOL 版本上。 公开统计里约 59% 的暴露主机跑在「不会再有补丁」的版本上。这不是运气问题,是技术债在关键时刻变成了「无解」。 8.2 将在 2026 年 12 月 31 日 EOL——如果这次你是从 8.2 升上来的,顺手把下一站也规划掉。
  • 别把单条 ini 当作护身符。 两位 PHP 核心开发者在社交媒体上把这件事说得很直白:一位说「我能说的只有:唯一的缓解方式是打补丁 / 升级 PHP」, 另一位说「ini settings won't save you」。学会区分「降低了暴露面」与「修好了」—— 这是安全沟通里最容易被模糊、也最容易害人的一条线。
  • 检测要看进程,不要看响应码。 Datadog 的复现返回 HTTP 200、worker 活得好好的、网站看起来正常。如果你的监控只看 5xx 和可用性,那你对这类攻击是完全失明的。 Web 进程拉起 shell、初始化阶段的 Unknown on line 0、异常巨大的 PHPSESSID—— 这三个信号值得进你的告警规则。
  • 供应链安全要算「修复速度」,而不只是「有没有补丁」。 这次从报告到全球修复只用了不到三个月,还被泄漏打乱了发布节奏——这在开源项目里已经接近极限。 但攻击者的速度也在同步提升。作为使用者,你能做的最有效的一件事,是缩短自己从「补丁发布」到「线上已修复」的延迟, 因为那才是你真正能控制的那一段。
  • 一句话总结

    漏洞代码在引擎里,痕迹在日志里,而解决办法只有一个:把 PHP 升级到 8.5.12 / 8.4.27 / 8.3.36 / 8.2.35 中的任何一个。 其余的,都是在这件事完成之前买时间。

    还可以继续读

    11参考来源

    本文所有事实性陈述均来自以下公开来源(截至 2026-10-23)。标注「一手」的是研究者或厂商的原始发布。

    一手研究

    官方公告

    国家队与监管机构

    技术分析与二次报道