PHP 9.2 分 RCE 的补丁比公告早公开 3 天,我读完 389 个文件的 diff,也把"死在代码之前"跑了出来

PHP 那两个 9.2 分的 RCE(CVE-2026-109618 / 109619,外号 php2shell)刷屏的时候,我一开始是当新闻看的:AI 辅助审计把一个 2024 年就发现但没法证明能利用的问题做成了武器,FBI 和 CISA 出来催打补丁,日本首相发帖让站长来不及升级就先关站。这类文章看三五篇就够了,反复出现的都是同一批官方数字。

真正把我拽进去的是一条时间线。php.net 的正式公告是 10 月 21 日 14:00 UTC 发的,但那份修复 commit 10 月 18 日就已经挂在 php-src 的主干上了------比公告早三天。中间还插了一段插曲:10 月 20 日 CVE 编号先在网上泄露,Chainguard 又在 21 日早上提前六小时破坏 embargo 发布了带修复的镜像。PHP Foundation 自己的博客承认了这几点。

这意味着一件事:这几天里,攻击者拿到补丁的时间比我早。他们看的是二进制,但 git log 是公开的,谁都能拉。与其等下一篇转述文,我干脆自己把 diff 拉下来读,顺手在手里那台没修的 8.1 上,把官方公告里写的触发条件验证了一遍。

三千个文件里改了什么

拉下来一看规模先吓一跳。commit 标题很短,"Avoid state corruption via bailout",作者是 Arnaud Le Blanc,后面跟着四位 co-author。我本地统计的结果:389 个文件,+9843 行,-949 行。其中文件名带 oom 的有 237 个,绝大部分是新增的测试------Zend/tests 下面光测试文件就动了 70 个。

perl 复制代码
$ grep -c '^diff --git' fix.patch
389
$ grep -c '^+[^+]' fix.patch; grep -c '^-[^-]' fix.patch
9843
949
$ grep '^diff --git' fix.patch | grep -c oom
237

改动的分布也能说明问题。除了 Zend 引擎本体,ext/standard 动了 52 个文件,mbstring 30 个,gd 19 个,连 soap、exif、zlib 这种平时根本不会想到的地方都各改了一小撮。数字我都是在 patch 文件上 grep 出来的,命令就在上面,你要复核跑一遍就行。一开始我不理解为什么要改这么散,读到后面才明白:这不是修某一个功能的 bug,是给整个引擎换了一种"内存不够时怎么退出"的方式。

先说被修掉的问题是什么。PHP 处理每个请求时要填一堆内部结构,填的过程中如果内存超限了(memory_limit),它的传统做法是 zend_bailout------用 longjmp 直接跳出当前的 C 调用栈,整个请求立刻中止。问题在于这个"跳出"是不打招呼的:如果一个数据结构刚记完账(比如哈希表的元素计数已经 +1)还没来得及分配真正的内存,longjmp 一跳, cleanup 代码按账本清点时就会把一堆没初始化的内存当成正常数据来处理。CrowdStrike 的分析里点了名:Zend/zend_hash.c 里字符串键插入的路径,就是先把 nNumUsed 和 nNumOfElements 加一,然后才去 zend_string_init 分配 key------这个顺序单独看没毛病,配上"随时可能原地消失"的内存分配就成了雷。 修复的思路我在 diff 里数出来是三层:

第一层是新的分配 API。zend_alloc.h 里多了 femalloc、safe_femalloc 这一组(fallible 的意思就是"允许失败"),分配失败返回 NULL,而不是直接把进程跳走。配套的 zend_hash 改动全是同一个模式,原来是这样:

scss 复制代码
// 改之前:grow 内部失败 = bailout,执行流回不来
zend_hash_packed_grow(ht);

改完之后:

ini 复制代码
// 改之后:失败返回 HASH_NOMEM,调用方自己收尾
zend_result status = zend_hash_packed_fgrow(ht, flag & HASH_FALLIBLE);
if (ZEND_CONST_COND(flag & HASH_FALLIBLE, true) && UNEXPECTED(status == FAILURE)) {
    return HASH_NOMEM;
}

第二层是延迟 OOM。patch 里 ZEND_HAS_OOM() 和 zend_handle_delayed_oom() 出现了 177 次,从 json、pcre 到 gd、soap 全都插了检查------这就是为什么改动散得那么开。内存超限不再当场跳走,而是先记下状态,等执行流走到安全点再作为普通错误处理,中途的操作有机会干净收尾。

第三层是兜底。新增了 memory_hard_limit_percent 这个 ini,延迟处理本身如果还在继续吃内存,进程直接 _exit(),宁可死个 worker 也不能让脏状态活到下一个请求。

另外有个细节我觉得挺妙:main/php_variables.c(就是填充 $_GET、$_POST 的那个文件)里新增了 array_init_singleton(),预先备好不可变的空数组,让请求解析里最常见的"空值塞进嵌套数组"场景根本不需要分配内存。攻击面最大的路径,直接把分配需求消掉了。

在没修的 8.1 上跑一遍

光读 diff 是别人的故事,我想自己看到那个"死在代码之前"。机器上正好有 apt 装的 PHP 8.1.2-1ubuntu2.28(Ubuntu 22.04 的包,构建日期 10 月 19 日,补丁发布前两天)。怎么确认它没含修复?我用 diff 里新增的 memory_hard_limit_percent 做标记:

css 复制代码
$ php -i | grep -c memory_hard_limit_percent
0

没有,就是没修。这台机器是个现成的"修复前"样本,连编译都省了。 先声明一下这个实验的边界:我不是去复现那个 RCE------那是内存破坏利用,也不该复现。我验证的是官方公告里写明的前提条件:请求解析阶段可以先于任何应用代码触发 memory_limit 的 fatal。做法很直接:起一个内置服务器,把 memory_limit 压到 2M,让一个 3 行的脚本当哨兵------第一行往日志里写一条记录,证明"应用代码执行过"。

php 复制代码
<?php
// docroot/index.php:哨兵脚本
file_put_contents(__DIR__.'/applog.txt', "app_code_ran\n", FILE_APPEND);
echo "APP_CODE_OK";

然后构造 POST 请求打过去。公告的缓解建议里提到了两类条件:输入变量数量、嵌套深度,我就照着这两个方向发请求,body 都不大。 第一发,深度嵌套,600 层。被拦了,但拦它的不是我预期的东西:

erlang 复制代码
PHP Warning:  Unknown: Input variable nesting level exceeded 64.
To increase the limit change max_input_nesting_level in php.ini.

max_input_nesting_level 默认 64,超深的嵌套字段在第 65 层就被截了,只是 Warning,请求继续走,哨兵日志照常写入。这层闸门是默认开着的。

第二发,换广度:10 万个顶层字段,body 不到 900KB。在默认 max_input_vars=1000 下,又是 Warning 截断,代码照常执行。这里我原先以为会直接 fatal,实测发现超限只是停止解析、记条警告------$_POST 里只有前 1000 个变量。所以默认配置下,这两类"教科书式"的触发姿势其实都过不去。

yaml 复制代码
PHP Warning:  Unknown: Input variables exceeded 1000.
To increase the limit change max_input_vars in php.ini.

但我没有收工。因为我马上想到,max_input_vars 在真实环境里是出了名的常被调大的参数------论坛里隔三差五就有人因为表单字段多报这个错,答案清一色是改 ini 调大。我把服务器的 max_input_vars 放到 200000,重启,再打同样的请求:

vbnet 复制代码
Fatal error: Allowed memory size of 2097152 bytes exhausted
(tried to allocate 4096 bytes) in Unknown on line 0

这次是 fatal 了。注意报错里的 "in Unknown on line 0" ------不是任何一行 PHP 代码,因为这个 fatal 发生在请求解析阶段,压根还没轮到执行脚本。我去看哨兵日志:0 条记录。响应状态码是 200,响应体是空的,php -S 的访问日志里也是一条平平无奇的 POST 200。

shell 复制代码
$ cat docroot/applog.txt
(空)
$ curl -si -X POST --data-binary @body.txt http://127.0.0.1:8091/index.php | head -1
HTTP/1.1 200 OK

应用代码一行没跑,服务器返回的还是一个"正常"的 200。官方说 mod_php 下"攻击发生在任何应用代码之前",我的机器上没有 mod_php,但这个 fatal 的位置是一致的:解析 POST 的那段引擎代码,在你的第一行业务代码之前。 对照组就不贴了:普通的小 POST,APP_CODE_OK 照常出现。

这两道闸门挡住了什么,没挡住什么

实验做完,官方那份缓解清单在我眼里就立体多了。 php.net 原文说得很直白:这些措施"do not make an installation safe"。这话不是免责声明,我实测里最有意思的矛盾就出在这。 最值得琢磨的是 post_max_size=8M。缓解建议把它列为第一条,听着像是给"超大请求"设的卡。但我实测里打爆 2M memory_limit 的那个请求,body 才 900KB------离 8M 差得远。真正吃内存的是解析后的内部结构:10 万个字段要在 $_POST 里各占一个哈希槽,内存涨的是账本不是账单。这个参数在 PHP 自己这层只能看到 body 字节数,所以官方才要求在 web server 或代理层去限解码后的大小、字段数、multipart 部件数------那些东西 post_max_size 管不到,必须到 nginx 里配。

memory_limit=-1 的味道也差不多,它是把"可控的失败点"拆掉:解析期不再有那个会按错时机爆炸的 memory_limit fatal,但任何别的分配失败照样能走到同一条危险路径上。而且它顺手取消了内存保护,一个失控脚本就能把小机器吃穿------官方才特意补一句用容器或 cgroup 做可用性边界,别用 RLIMIT_AS/RLIMIT_DATA 替代,后两者恰恰会制造那种分配失败。至于 session.upload_progress.enabled=0,它对应另一个 CVE 的已确认路径,这功能默认是开的(php -i | grep upload_progress 一查便知),10 月 23 日 php.net 还单独重申过一遍关闭建议------三条措施里它最简单,照做就行。

我会按这个顺序动手

写到这里,说说我自己的动手顺序。最先查的是 mod_php:官方把风险最高的位置点得很清楚,mod_php 默认配置下攻击跑在代码前面,WAF 和应用层防护全是摆设,这种机器今天就该升,没有讨价还价的空间。FPM 的机器稍微宽裕一点,但我吃过"补丁落了盘、worker 还是旧的"这种亏------升级完 php -v 确认的是 CLI 版本,跑着请求的进程里换没换代码是另一回事,重启这一步别省。暂时升不了的,缓解配置写进 pool 的 php_admin_value,别留在应用能碰到的地方,官方原文特意提醒 ini_set() 和 .user.ini 不可信,毕竟攻击发生时应用代码还没执行。

至于 8.1 及更老的版本,PHP 官方只给了 7.4/8.0/8.1 的最小源码 backport(分支名长得像 PHP-8.1-CVE-2026-109618-109619),得自己编译。我手里这台 Ubuntu 的 php8.1 包截至今天还是不含修复的 2.28,发行版的 backport 还没进源------如果你也在等,这几天盯一下 changelog。

最后说回那个让我起念的细节:修复公开三天、镜像提前六小时、CVE 编号先飞------这套流程把"等公告"的默认姿势打穿了一个角。以后遇到大版本的安全修复,git log 可能比公告快。反正 diff 就在那里,读一遍又不要钱。

相关推荐
2601_967212721 小时前
可移动轨道插座安全结构、场景适配与安装技术说明
大数据·经验分享·安全
凯基迪科技2 小时前
STM32G系列(G0/G4)芯片解密程序提取RDP读保护解析
网络·stm32·安全
萌动的小火苗2 小时前
MQTT的QoS0、QoS1、QoS2有什么区别?
开发语言·网络·python·php·信息与通信
silvamux_2 小时前
10款模型写“君幸食”讲解词:统一事实档案与逐款审校
开发语言·php
用户094248568032 小时前
第8章:Redis Bitmap 与 BitField 实战:签到、活跃用户与状态压缩
redis·架构·源码
科技小登2 小时前
CC攻击防护哪家好?Web安全加速、WAF与Bot管理同入口方案对比
网络·安全·web安全
loulanyue_2 小时前
密度定律驱动端侧AGI跃迁,用物理级解耦破解自主网络攻击之困:迈向安全可控的通用智能——读清华大学刘知远教授CNCC2026网络生态治理论坛演讲实录
网络·安全
安教安全员通关宝3 小时前
2026年浙江安全员C2证考试题库+答案+解析
安全
Smoothcloud润云3 小时前
GPU远程纳管节点频繁离线?心跳、DNS与时钟排障实战
运维·服务器·开发语言·人工智能·php·gpu算力