PHP2Shell 十年启示:当 AI 把“可疑”变成可利用,修复为何仍要数月

PHP2Shell 事件表明:AI 已把漏洞的“发现与证明”成本大幅压缩,使大量“理论性风险”迅速成为可利用的现实,但语言运行时、维护者短缺与披露流程的结构性限制使得真正的修复与责任分配仍需数月,揭示了攻防速度失衡、制度滞后与开源治理的深层问题。


十年之洞:PHP2Shell 与 AI 重构安全行业

执笔:雨轩

完成时间:2026 年 10 月 22 日


序言 为什么写一个洞

2026 年 10 月 21 日的下午,我盯着一条时间线看了很久。

那条时间线不长,只有十来行:一个 CVE 编号在论坛上流出、一个视频被误发又删除、一家镜像厂商提前六小时发布、四个补丁版本在同一分钟上线、几个国家的政府网站在同一天下线。把这些点连起来,是一个完整的故事;但让我停住的不是故事,而是一个细节——这条时间线里,发现漏洞的那一段只占了两周,而修补它的那一段占了七十天。

两周对七十天。

这个比例在我的经验里是反的。过去二十年,安全行业的基本节奏是「发现难、修补快」:一个漏洞要几个月甚至几年才被找到,一旦被找到,补丁通常几天内就能出。所以行业的全部制度设计——漏洞赏金、CVE 编号、协调披露、补丁窗口、SLA——都建立在「发现是稀缺的,修补是相对充裕的」这个假设之上。

2026 年 10 月,这个假设被推翻了。发现变成两周,修补还是七十天,而且这七十天里没有任何一天是浪费的。稀缺性换了一边。当稀缺性换边,所有建立在上一个稀缺性上的制度,都会开始漏气。

这本书想讲的,就是这件事。

一个被误读的事件

关于 PHP2Shell,公共讨论里最流行的一句话是「AI 找到了一个藏了十年的漏洞」。

这句话没错,但它把重点放错了。

真正的事情是:这个洞的位置,PHP 的核心开发者在 2024 年就看见了。那一年,PHP 做了一次由公共资金资助、外部机构执行的安全审计。修复审计发现的问题时,他们碰到了这个洞的最初迹象,判断「理论上存在利用可能」,但找不到具体的攻击路径,于是把它放下了。

两年后,三家机构在两周内独立抵达同一个根因,并且把「理论上可利用」变成了「可以命令执行」。

所以准确的说法不是「AI 找到了一个没人知道的洞」,而是「AI 把一个人人都知道可能有问题、但没人能证明有多危险的地方,变成了一件必须立刻处理的事」。

这两句话的差别,决定了你怎么理解 AI 在安全行业里的位置。

如果 AI 是「更快的搜索」,那它只是把发现变便宜了,行业会经历一轮漏洞数量的通胀,然后慢慢适应。如果 AI 是「更强的证明」,那它改变的是整个经济的记账方式——一个无法被证明的风险,在风险管理里等于不存在。AI 让大量「存在但不可证明」的风险突然可以被记账,这些账一次性涌进系统,系统的偿付能力就被打穿了。

这本书的立场是后者。

为什么是 PHP,为什么是这一次

过去三年,AI 找漏洞的新闻并不少:Big Sleep 在 SQLite 里找到第一个真实零日,XBOW 登上 HackerOne 美国榜第一,Claude Mythos 在 Firefox 里翻出两百多个潜伏缺陷。这些都很重要,但它们都是「样本」。

PHP2Shell 不一样,它同时满足三个条件,所以它成了一个可以解剖的完整标本。

第一,洞在语言核心,不在应用层。这意味着它不挑应用、不挑框架、不挑你有没有写 session 代码。一个只有十行的 PHP 页面,和一个四千万行代码的电商系统,在这两个 CVE 面前是同一个风险等级。这让影响面第一次可以被「语言份额」这种粗颗粒的指标直接换算,而不需要一家一家去数。

第二,AI 参与的不只是发现,还有证明。Google Cloud 的公开表述把模型的作用拆成四段:发现漏洞、开发利用、设计检测、验证补丁。四段里,最难、最贵、也最容易被外行忽略的是第二段。这本书会用一整章讲它。

第三,披露流程在同一周里断了一次。编号提前流出,PoC 视频误发,镜像厂商提前发布。三个环节都不是恶意,但合起来让「协调披露」这个运行了二十多年的机制当场失效。这让我们能看到:当发现速度提升一个数量级,而披露机制还是旧节奏时,会发生什么。

三个条件凑齐,才有这本书。否则它只是一篇技术分析。

这本书在讲什么

全书分四部分,十二章。

第一部分(第 1 到 5 章)讲事件本身。 从 10 月 20 日那个下午开始,把披露链条、PHP 的请求生命周期、两个 CVE 的技术机理、影响面的算术,一层一层拆开。这三章技术内容(第 2、3、4 章)是全书的地基,我把它们写得比一般读物更细,因为我拒绝用「内存破坏导致代码执行」这种句子糊过去。如果你想知道一个 3.5MB 的表单字段名,怎么变成服务器上的一条命令,这里有完整的因果链。

第二部分(第 6 到 8 章)讲发现。 三支队伍如何在两周内独立抵达同一根因;2024 年那次搁置到底发生了什么;模型在「发现、利用、检测、验证」四个角色里各自做了什么、做不到什么。第 7 章是全书最重要的一章,它讲的不是技术,是「可证明性」作为一种资源。

第三部分(第 9 到 11 章)讲行业。 三段成本与三个速度如何被改写;防守方那条流水线在哪里断;为什么瓶颈最终落在人身上,以及这件事为什么是结构性的,而不是「多给点钱」能解决的。

第四部分(第 12 章与结语)讲没解决的问题。 能力边界、责任归属、能力扩散的不对称、制度的三处滞后。我不打算给答案,因为现在没有答案;我只打算把问题的形状说清楚。

这本书不是什么

它不是技术手册。想找 PoC 的人会失望,全书没有一行可直接执行的攻击代码,涉及载荷的部分一律省略。这不是审查,是分寸:一本讨论「证明成本」的书,自己不应该变成降低攻击成本的工具。

它不是行业报告。报告追求覆盖,这本书追求因果。我会为了讲清一条因果链,花三千字讲 PHP 的哈希表;也会为了不误导,把一段很漂亮的类比删掉。

它更不是 AI 宣传册。书里对 AI 的每一处能力陈述,都标注了来源和口径;对厂商自述的效率数字,我专门写了它的三种偏差来源。因为这本书的核心判断之一就是:在信息传播里,二手转述的失真速度,比漏洞的传播速度快得多。PHP2Shell 这一周里,同一个 CVE 在不同媒体上有过三种不同的技术解释,其中两种是错的。如果我写作时不做这件事,这本书就只是噪声的一部分。

方法与限定

书里所有事实来自公开材料:PHP 基金会的官方博文与 oss-security 公告、CERT-EU 的安全公告、Rapid7、Datadog、Coalition、Cisco 的公告、Wiz 与 OpenAI 的公开表述,以及各家媒体的报道。

凡是数字,我都尽量带上口径:CVSS 9.2 是官方评分,10.0 是某家安全公司的自评;「70% 的互联网」是暴露面估计,不是统计;「一名研究员一周完成过去世界级团队数月的工作」是厂商自述,不是测量结果。这些限定不是啰嗦,它们决定了结论能不能被反驳。

凡是推断,我都标注为推断。书中有些地方会出现「假设」二字,那不是免责声明,是邀请:你可以换掉我的假设,看结论还剩多少。

凡是没把握的地方,我留白。这本书没有采访到任何一方的内部人员,也没有拿到补丁的完整 diff。所有关于「他们当时怎么想」的描述,都是从公开时间线反推的,我会在文中说明这一点。

致谢

要感谢那些把过程完整写下来的人。PHP 基金会那篇博文是罕见的诚实文本——一家开源基金会公开承认自己的决策机制「结构性失败」,这在行业里不多见,它让这本书的第 11 章有了骨架。Wiz 和 OpenAI 各自披露了工具链与方法论,Datadog 做了独立的复现分析,CERT-EU 在混乱的第一天给出了最冷静的时间戳。没有这些,这本书只能是一篇评论。

也要感谢那些没有留下名字的人:在 10 月 21 日那天晚上加班重建镜像的运维、在托管面板上批量推送更新的工程师、把不兼容的扩展一个个重新编译的维护者。这本书写的是结构,但承受结构的是他们。

最后一个感谢留给读者。如果你读完之后,对「AI 改变了什么」这个问题有了一个更精确、更少口号化的答案,这本书就完成了它的工作。

雨轩于听雨轩

2026 年 10 月 22 日


目录

  • 序言 为什么写一个洞

  • 第 1 章 十月二十日:一条论坛帖改写了全球互联网的日程

  • 第 2 章 洞在引擎里:一段被复用的内存

  • 第 3 章 先加一再分配:109618 与半初始化的世界

  • 第 4 章 上一个请求的幽灵:109619 与脏状态

  • 第 5 章 影响面的算术:70% 是怎么算出来的

  • 第 6 章 三支队伍,同一个洞

  • 第 7 章 2024 年他们就知道:被搁置的可利用性

  • 第 8 章 机器如何证明一个洞能打穿:AI 的四个角色

  • 第 9 章 三个速度与三段成本:攻防经济学被改写

  • 第 10 章 防守方的机器:从扫描到补丁验证

  • 第 11 章 瓶颈在人身上:维护者、流程与开源的经济学

  • 第 12 章 剩下的问题:能力边界、责任与下一个十年

  • 结语 一个洞的启示


正文

第 1 章 十月二十日:一条论坛帖改写了全球互联网的日程

“AI 找到了 bug,实验室跑完了披露,瓶颈现在在维护者身上。”

——clauday 对本次事件的评论,见素材汇编

这一章要回答一个问题:一场已经协调了整整两个月的漏洞披露,为什么会在一个下午之内变成抢跑。而在这次抢跑里,几乎没有人说谎,也没有人恶意泄露——它依然崩了。这件事比漏洞本身更值得写下来。

在进入任何一个技术细节之前,需要先说清一件事:PHP2Shell 的技术内容(哈希表、bailout 路径、脏状态、伪造 zval)都是第 2 章往后的事。这一章只处理时间。因为 2026 年 10 月 20 日到 21 日这三十个小时里发生的事情,构成了一个独立的、完整的、可以被单独解剖的失败样本。它不关于代码写错了哪一行,它关于一套运行了二十多年的社会约定,在遇到新的传播结构时,是怎样一段一段裂开的。

1.1 两个编号的清晨:2026 年 10 月 20 日 15:00 UTC 前后发生了什么

要理解 15:00 UTC 那一刻的重量,得先把时钟往回拨九周。

按照 PHP 基金会的官方口径,这条线上最早的一颗钉子是 2026 年 8 月 11 日:Calif(OpenAI 旗下的安全研究团队)的研究员 Ryan A 在这一天报告了“无条件远程代码执行”,根因与 2024 年那次外部审计时被搁置的 OOM 问题相同;同一天,OpenAI 也独立报告了相同的根因。八天之后的 8 月 19 日,Wiz Research 用 Wiz Atlas 加上 Gemini 4 Argon 与 GPT-5.6 Sol 发现了他们自己的第一个 PHP 漏洞,8 月 20 日确认可对 Drupal 实现未认证 RCE,8 月 21 日确认可对 WordPress 实现未认证 RCE,8 月 24 日把相同根因与攻击向量报给了 PHP 安全团队。9 月 18 日,OpenAI 又报告了更多攻击向量。9 月 21 日,PHP 安全团队正式确认收到报告,开始制定协调修复方案。

从 9 月 21 日到 10 月 20 日,整整一个月,是标准的协调披露(CVD,Coordinated Vulnerability Disclosure)流程:厂商在手上有补丁之前不公开,研究者在厂商发布之前不公开,第三方托管方在约定时间之前不释放。定好的发布时刻是 10 月 22 日 14:00 UTC。四个受影响版本线要一起出,8.5.12、8.4.27、8.3.36、8.2.35,同时还要给发行版打包者留出窗口。

然后 10 月 20 日到了。

按多家来源交叉的记述,这一天大约 15:00 UTC,两个 CVE 编号开始在论坛和社交媒体上流传:CVE-2026-109618 与 CVE-2026-109619。帖子带了三样东西——两个编号、一个 CVSS 分数、一个发布时间。三样里有两样是错的:它给出的发布时间与实际发布时刻不符,它给出的评分是 10.0,而两个漏洞的官方评分都是 CVSS v4.0 的 9.2。

这两个错误本身就是证据。10.0 这个数字并非凭空出现,它是 watchTowr 的自评(非官方评分);在信息链的某一环,评论者的口径被当成了官方口径,然后这个被污染的数字随着转发一路复制下去。至于“错误的发布时间”,它更直白地暴露了这条帖子不是从官方渠道流出的——如果它来自基金会或 MITRE 的正式记录,日期不会错。

材料没有留下那条帖子的原文,所以我们只能从后续报道反推它的信息构成,这一点必须说明白:我们确知的是“有编号、有错误评分、有错误时间、无技术细节”这四件事,而不是帖子的具体措辞。任何比这更细的现场描写都会变成编造。

关键在“无技术细节”这四个字。这条帖子的信息熵极低。它没有说漏洞在哪、怎么触发、影响哪个 SAPI、有没有 PoC。它只说了一件事:有两个编号存在,而且被评得很高。

但它改变了所有人的行为。

原因不在帖子,在编号这种物件的性质。CVE 编号是元数据,不是内容。它在一个流程的最上游被分配:报告进来,编号发出,然后才轮到描述、评分、公布、修补。也就是说,编号的“存在”天然早于编号的“含义”。这套设计本来是为了让多个系统(NVD、GHSA、发行版追踪器、企业资产库)能够互相引用同一件事,代价是:编号一旦被看见,全世界都知道有一件大事正在路上,却不知道是什么事。

对防守方,这是最坏的中间态。你不知道该查什么日志、该拦什么请求、该不该停机。你只知道有两件事,被评到了接近满分。

对攻击方,这是最好的中间态。它不需要技术细节就能开始准备:把 PHP 相关资产清单拉出来,把指纹扫描跑一遍,把可能的攻击面(表单、上传、会话)先摸清楚。真正稀缺的不是“知道怎么打”,而是“知道该打谁”,而编号帖给出的恰恰是后者。

同一天更早的时候,这个漏洞的性质在官方档案里已经定了型:CVE-2026-109618(GHSA-w8hw-w9fx-j79w)是 OOM 处理期间的内存破坏,CVE-2026-109619(GHSA-6wmf-4rv5-p3rp)是跨请求陈旧全局状态导致的 use-after-free,两者都是 CVSS v4.0 9.2。它们的共同前提是未认证、远程、无需用户交互,并且在应用代码执行之前触发。这最后一个性质意味着:应用层的认证、WAF 规则、输入过滤全部无效。这条性质在 10 月 20 日 15:00 之前只有几十个人知道,之后它成了每个 SOC 值班员的失眠理由。

所以那一天流出的不是漏洞,是日程。

泄密的真正内容,是“10 月 22 日”这个日期,以及“有两个 Critical 正在路上”这个事实。一整套协调了两个月的安排,其时间维度在这一刻变成了公开信息,而内容维度仍然是秘密。一个只能被看见轮廓、不能被看见细节的秘密,是所有秘密里最危险的一种。

1.2 从预告到提前发布:PHP 项目在四小时内做的决定

15:00 UTC 编号流出。大约 19:30 UTC,PHP 项目发布了提前预告:把发布从 10 月 22 日 14:00 UTC 提前到 10 月 21 日 14:00 UTC。

中间四个半小时。

这四个半小时里发生的事情,材料没有记录会议过程,也没有对外披露决策细节,所以我们只能从结果反推决策的形状。但结果本身已经足够清晰:一个以谨慎著称、以共识制决策著称、过去两个月在这个问题上反复停滞与重启的项目,在四个半小时内同意把时间表整体前移一天。

这个反应速度本身值得记一笔。它和后面第 11 章要讲的“两个月修不好”并不矛盾——恰恰相反,两件事放在一起才完整:PHP 项目在“决定什么时候发布”上可以很快,在“决定怎么修”上必须很慢。前者是日程问题,后者是判断问题。这套系统对日程的响应是灵敏的,对判断的响应是迟缓的。这次事件把这两种速度同时暴露了出来,而公众看到的只有前一种。

提前一天,是一个被精心计算的折中,不是恐慌。

先看它为什么不立刻发布。补丁没有验证完。四个版本线要同步,发行版的打包者还在等源码 tag,容器镜像还没构建,企业变更窗口还没排。在一个编号已经流出、但技术细节尚未公开的窗口里,立刻发布固然能缩短“知道有洞”到“能修”的距离,但代价是把一个未充分验证的补丁交到几百万台服务器手上——而这两个漏洞的根因在内存管理核心,几乎每个请求都会触及,一个验证不足的补丁造成的大面积故障,会比漏洞更快地打垮信任。

再看它为什么不干脆按原计划 10 月 22 日。因为编号流出改变了一件事:外部已经知道有两个 Critical 在路上。这时候多等两天不再是“给下游多两天准备”,而是“给攻击方多两天准备”,同时把“项目知道但不发”变成一种可被指责的姿态。在编号已经公开的前提下,等待的成本从“信息不对称的保护”变成了“信息不对称的责任”。

于是就剩下一个窄得可怜的选项:提前一天,用 24 小时窗口替代 48 小时窗口,让补丁尽早可用,同时给下游留一口气。

这个决定有一个被低估的代价。提前一天,意味着所有下游的准备时间被砍半:Linux 发行版的打包者、容器基础镜像的维护者、共享主机的面板厂商、企业内部的变更评审委员会。对一个需要跨团队审批的组织来说,48 小时和 24 小时的差别不是“少一点余裕”,而是“能不能排进这个月的变更窗口”。这个代价没有出现在任何公告里,但它会在二十小时后以另一种形式回来找所有人——这一点留到 1.3 节。

还有一个更微妙的副作用:提前预告这个动作本身,把“有洞”从传闻升级成了官方确认。

15:00 的帖子可以被当成谣言。19:30 之后不行了。项目自己承认了时间、承认了版本号、承认了要发补丁。这在信息上是必要的(否认会让谣言更凶),在防御上却是残酷的:它确认了紧迫性,却没有提供任何防御手段。这一天剩下的时间里,全球所有跑 PHP 的组织的处境是同一句话——你知道要来,你不知道来的是什么,你也没有东西可以挡。

Datadog 后来把这个处境写得很干净:“应用层认证无法阻止攻击:PHP 在任何应用代码运行之前就处理了恶意请求。只有打补丁才能修复。”

这句话是 10 月 20 日晚上所有人处境的准确描述,只是当时还没人说出口。

用一句话定性这一节:项目用四个半小时修正了日程,但没有、也没办法修正那个已经流出的轮廓。

这里值得插一句到第 9 章才会展开的观察。漏洞响应有三个速度:发现、披露、修复。10 月 20 日那四个半小时里,被加速的是第二个,而且是外部事件推着加速的,不是项目自己选的。失去节奏控制权的响应,通常会在下一个环节付出代价。

1.3 三段泄露:编号流出、PoC 视频、Chainguard 打破 embargo

这一节要把三十个小时里的三次泄露分开看。把它们混成一句“披露失败了”,会丢掉最重要的东西:三次泄露的性质完全不同,责任主体完全不同,可修复性也完全不同。

第一段,元信息泄露,10 月 20 日约 15:00 UTC。

这一段最像“结构性的必然”。CVE 编号要在 NVD、GHSA、发行版追踪系统、企业资产库里被互相引用,就必须提前分配;一旦分配,它就存在于多个系统的视野里。参与者越多,缝隙越多。两个月里,这条线上至少有四家研究机构(Calif、OpenAI、Wiz、以及后来加入的 Trail of Bits)、一个基金会安全团队、一个生态系统安全团队、多个发行版打包者、以及若干镜像与托管厂商接触过这套信息。这不是“谁不小心”,这是“参与面本身就是攻击面”。

把这一段的性质说清楚很重要:它是制度成本,不是道德问题。一套只让三个人知道的披露流程会非常安全,也非常无效——因为它没法让下游在发布当天就绪。协调披露的全部价值,建立在“适度扩大知情圈”这个前提上。而一旦知情圈扩大,编号的可见性就不再完全可控。

第二段,操作事故,10 月 20 日约 20:00 UTC。

编号流出五小时后,Calif 误发了一段展示 PoC 的视频。按材料记述,视频包含“有限的额外细节”,随后被删除,但副本已经扩散,并加速了外部的 PoC 开发。

这一段和第一段性质完全不同:它是操作失误,主体明确,可归因,理论上可避免。但它的后果比第一段严重,因为它泄露的不是元数据,是内容——哪怕只是“有限的额外细节”。

为什么一段视频比一段文字更危险?因为视频把“可行性”视觉化了。研究者证明一个漏洞能打穿,最难的一步从来不是技术本身,而是说服自己和别人“这条路走得通”。一段能跑起来的演示,替所有观看者完成了这个心理门槛的跨越。它没有给出完整的利用链,但它给出了一个更稀缺的东西:确认。确认之后,剩下的是工程问题,而工程问题是可以被并行解决的——成千上万个攻击者同时开工,比一个团队慢慢摸索快得多。

删除动作在这里没有意义:一段视频在发布后几分钟内就已经存在于无法被撤回的地方。这是一个复制成本的问题,不是平台配合的问题。

还需要说一句不客气的话。研究者误发 PoC,在这类事件里不是罕见事故,而是职业激励结构的副产品。研究团队需要证明自己的工作有价值,“我们找到了、我们证明了、我们做出了能跑的利用”是这套价值的核心叙事,而演示视频是这套叙事最有力的载体。这不是为失误开脱,而是说明:只要“证明”本身是荣誉的来源,证明物就一定会被生产出来,而生产出来的东西就一定有泄露的可能。第七、第八章会讲 AI 如何让“证明”的成本塌陷;当证明变得便宜,需要被管住的证明物就会成倍增加。这是后话,但伏笔在这里。

第三段,单边打破 embargo,10 月 21 日 07:41 UTC。

在协调发布前六个多小时,Chainguard 单方面打破 embargo,提前发布了包含修复的构建镜像。

前两段的主体是“信息”,这一段的主体是“产品”。这个区别很关键。编号和视频泄露的是知识,而镜像发布的是可执行的修补件——它意味着补丁的二进制在官方公告之前就已经在公网上可下载。

材料没有给出 Chainguard 的理由。这一点必须老老实实承认:我们不知道它是出于客户压力、SLA 承诺、内部流程误判,还是对 embargo 范围的另一种理解。任何具体的动机叙述都会是编造。

但结构性解释是可以讨论的,前提是明确它只是解释框架,不是对动机的判断。镜像与托管厂商的商业模式建立在一件事上:客户拉取镜像的时刻,就要拿到已经修好的东西。这个承诺与“等上游统一发布”在时间上天然冲突。上游的 embargo 约束的是“源码与公告的公开时间”,而镜像厂商面对的是“我的客户现在就在问什么时候能拉到安全镜像”。当这两件事撞在一起,先满足客户是一个商业上极其自然的选择,而它是错的——因为提前发布修复镜像,等于提前公开了补丁的二进制,而对二进制做差分分析、定位被改动的函数、反推漏洞成因,是漏洞研究的常规手段。六个小时的提前量,足够一个有经验的人找到那个被改的字段。

也就是说,第三段泄露是三次里技术上最有价值的一次。前两段泄露的是“有事”,第三段泄露的是“在哪”。

还有第四条证据需要放在这里:Beazley Security Labs 称“技术细节与 PoC 从 escrow 提前泄露”。escrow(托管)是协调披露里的一个标准装置:把 PoC 或技术细节托管给第三方,约定时间统一释放,用来在“研究者要证明自己发现了”和“厂商要争取修复时间”之间搭一座桥。它的全部效力建立在一个假设上:托管方会守时。而托管链条上的每一环都是一个可能提前释放的节点。这条证据与前面三段放在一起,说明的不是“某一方失职”,而是“这套装置在参与者变多之后,其守时假设变得非常脆弱”。

三次泄露,三种性质,可以这样归纳:第一次是制度的必然成本,第二次是个人的操作事故,第三次是商业逻辑对约定的越位。第一种无法消除,只能缩小知情圈来换;第二种无法预测,只能靠文化约束;第三种可以被合同与惩罚约束,但需要有人愿意执行——而在开源世界里,几乎没有人有执行权。

embargo 不是防火墙,它是一个约定;约定只能约束愿意被约束的人。

这句话听起来像抱怨,其实是一个结构性判断,它会在第 9 章变成对“三个速度”的正式论述:发现速度被 AI 拉快,修复速度被人才与共识锁死,而披露速度——唯一一个靠人际约定维持的速度——在传播结构改变之后,最先失控。

1.4 十月二十一日:全球的下线与声明

10 月 21 日的时间轴是这样的。

07:41 UTC,Chainguard 已发布含修复的镜像。13:30 UTC,CERT-EU 的记录里补丁已可用。14:00 UTC,计划中的正式发布时间。四个版本同时出:PHP 8.5.12、8.4.27、8.3.36、8.2.35。受影响区间是 8.2 低于 8.2.35、8.3 低于 8.3.36、8.4 低于 8.4.27、8.5 低于 8.5.12。两个 CVE 编号在这一天正式公开,与补丁同步。

从编号流出到补丁可用,大约二十三个小时。这个数字在后来的复盘中很少被提起,但它其实是这次事件里唯一一个好消息:从“世界知道有洞”到“世界能修”,只用了不到一天。这个速度是项目、基金会、研究机构与托管方在压力下共同完成的,值得记住。

然后是响应。这一天的响应密度,是判断事件量级最直接的指标。

美国方向:FBI 与 NSA 都发出了警告,CISA 有相关通告;Cisco 发布安全公告(cisco-sa-php-october26-Jj384Daq),在公告里写了一句非常干脆的话——无可用的 workaround(no workarounds available)。这句话的分量比它的字数大得多。厂商公告的标准写法是“升级到修复版本,在无法升级时可采取以下临时缓解措施”。而“没有缓解措施”意味着:不打补丁,你唯一的办法是把服务关掉。

欧盟方向:CERT-EU 发布安全公告 2026-018,建议必要时隔离面向互联网的应用。这是一句在安全公告里极少出现的建议。“隔离面向互联网的应用”对一个以 Web 为主业的组织来说,等于要求它停业。

日本方向:首相高市早苗公开呼吁相关机构尽快部署补丁,必要时暂停网站服务;部分政府部门与企业进入紧急维护。一个国家首相对一个 CVE 发声,这件事本身就是一个量级信号。漏洞响应通常是 CERT 层面的技术事务,上升到首相层级,说明影响面已经越过了“技术系统”进入了“公共服务”。

荷兰与芬兰:部分政府网站预防性下线。这是整件事里最值得停一下的细节。下线是一个纯粹的成本决定:用可用性换安全。一个政府网站下线,意味着公民在那一刻办不了事。没有哪个运维团队愿意做这个决定,做了就说明他们认为另一种风险更高——在补丁刚发布、PoC 开始流通、自己又不确定有没有隐藏实例的时候,把服务关掉是唯一能确保不被利用的手段。

厂商方向:Cloudflare、Fastly、Google、AWS、Microsoft、Akamai、Imperva、Vercel 发布指引或开箱的缓解规则。中国方向:阿里云、长亭安全应急响应中心等发布预警与复现分析,奇安信等参与应急。

这里有一个必须处理的张力,否则这一节会自相矛盾:一边是 Cisco 说“无 workaround”,一边是云与 CDN 厂商发布了“开箱缓解规则”,这两句话同时成立吗?

同时成立,但说的不是一件事。边缘缓解规则挡的是已知的利用形态——特定的超长表单字段名、特定的请求结构、特定的 OOM 触发模式。它的价值是真实的:在补丁铺开的这几天里,把最省事的那批攻击挡在门外。但它不关闭根因。攻击者换一种构造、换一条路径、换一个触发点,边缘规则就可能漏。Datadog 那句话在这里又适用一次:漏洞在应用代码执行之前触发,所以任何在应用层或边缘层做的判断,本质上都是在猜攻击者会怎么写请求。

PHP 项目自己给出的临时方案同样有限。session.upload_progress.enabled=0 只能切断 109619 的一条路径(upload processing 那条),另一条 fatal-error backtraces 依然在;memory_limit=-1 不能根除问题,因为 109618 的根因不是内存耗尽本身,而是内存耗尽后的处理顺序;RLIMIT_AS 与 RLIMIT_DATA 这类系统级限制也不可用,因为它们会连正常请求一起打死。结论只有一句:只有打补丁是完整修复。

这一天还有一个数字值得一提:php.net 的公告在 Hacker News 上拿到 1497 分。这个分数不代表技术判断,它代表的是一件更朴素的事——有大量工程师在同一个下午刷新同一个页面。这种同步的注意力,是 2014 年 Heartbleed 时代没有的规模。

危险也在这一天开始兑现。CERT-EU 的表述是:补丁发布后技术细节进入公开讨论,初始 PoC 开始流通,“攻击者已在利用的可能性上升”。有活跃利用的报告出现。媒体估算超过百万台公网 PHP 主机存在潜在风险,这是媒体估算,不是官方数字,官方从未给出总量口径——但这个量级与 Wiz 后来给出的暴露面估计(约 70% 的互联网)方向一致。

把这一天压缩成一句话:这一天最响的声音不是警报,是下线。

警报是提醒,下线是决定。当多个国家的政府选择后者,说明它们对“能不能在时间窗内修完”这件事没有信心。而这个不信心,正是这本书要追问的东西——不是“为什么洞这么严重”,而是“为什么修得这么难”。

还有一层需要放在这里。10 月 21 日的全球响应,从补丁可用到多国政府声明不到二十四小时。这个速度证明:当“该做什么”是明确的(打补丁或下线),这套机器运转得非常好;它做不到的,是在“该怎么修”不明确的时候提供帮助——而这次事件的根因,恰恰卡在那里,卡了两个月。

1.5 为什么这次不一样:与 Heartbleed、Log4Shell 的结构对比

材料里唯一一处直接类比来自 Coalition 的一句话:“每隔几年就会出现一个深埋在互联网里的漏洞,几乎每个组织同时受到影响。Heartbleed 是一次,Log4Shell 是一次,这次是 PHP2Shell”。

这句话是对的,但它只说了“同样严重”。同样严重不能解释任何东西。要看清这次的不同,需要换三个维度做结构对比:受影响面、修复难度、披露方式。这里需要先声明一个方法论前提:本次事件的事实以 materials 为准,而 Heartbleed 与 Log4Shell 的部分属于公开史实,本书只在“结构类比”的意义上使用它们,不把它们当作本次事件的证据。

第一个维度,受影响面。

Heartbleed(2014 年,OpenSSL)的受影响面在协议栈的一层:TLS 心跳扩展的实现。它的宽度极广——几乎所有暴露在公网的 HTTPS 服务都依赖 OpenSSL——但深度有限:它依赖心跳扩展被启用,且它是一个越界读,泄露的是内存内容,攻击者拿到的是密钥、会话、凭据,而不是直接的命令执行。

Log4Shell(2021 年,Apache Log4j 2)的受影响面在应用依赖层。它的特征是“无处不在的库”:几乎所有 Java 应用都会打日志,所以它的触达面极广;但它是“依赖树上的一个节点”,理论上只要你不用这个版本,你就不在射程内。

PHP2Shell 的受影响面在语言运行时核心。这个位置的特殊性在于三件事同时成立:一是它在解释器里,不在任何框架或插件里,所以“所有 PHP 应用”一律在射程内,包括没有 session、没有上传功能的最小应用;二是它在应用代码执行之前触发,应用层的一切防御手段全部失效;三是它不需要任何功能开关被启用,不存在“我没开这个特性所以安全”这种退路。

用一句话概括这一维的差别:Heartbleed 打的是通道,Log4Shell 打的是依赖,PHP2Shell 打的是地基。前两者可以靠“我有没有用它”来收缩范围,第三者不能。

第二个维度,修复难度。

Heartbleed 的修复在技术上是收敛的:一处边界检查,改完之后重启服务。真正昂贵的是后续动作——因为泄露可能包含私钥,大量组织必须换发证书,这是运维成本,不是开发成本,而且是可以并行、可以外包、可以在几天内批量完成的。

Log4Shell 的修复在技术上是分层的:升级依赖是最彻底的,禁用 lookup 是有效的临时手段,WAF 规则能在一定程度上减缓。它的难点在“找”——找出所有依赖链深处的 Log4j 实例,这件事在当年逼出了整个软件物料清单(SBOM)讨论。也就是说,它的瓶颈在资产可见性。

PHP2Shell 的修复难度是另一种形状。根因在核心内存管理,几乎每个请求都会触及;方案存在竞争取舍——改哈希表的写入顺序会破坏大量依赖现行为的行为与扩展,加检查会引入新的拒绝服务风险,改错误处理语义会要求大量第三方扩展修改,而那几乎无法协调;决策方式是共识制,在多方案之间难以抉择;具备深厚引擎知识的核心开发者人手不足。结果是两个月,基金会自己承认这是一次“结构性失败”。而且它没有可用的临时缓解——Cisco 的公告写的是“无可用的 workaround”。

这个维度上的差别,是最容易被误读的。很多人会说:只有四个版本线要修,比 Log4j 那种满天飞的依赖树简单多了。这个说法把“修复对象数量”和“修复难度”混成了一件事。修复对象少,意味着分发简单;修复难度高,意味着每一次改动都要过兼容性与安全性的双重关。前者是物流问题,后者是判断问题。物流可以并行,判断不能。

第三个维度,披露方式。这是三者差别最大的地方,也是本章的重点。

Heartbleed 属于“embargo 边界模糊”的类型。公开史实中,它在披露当天就出现了公开的概念验证与检测工具,边界在数小时内被抹平。也就是说,它的 embargo 从来没有真正存在过足够长的时间,来让下游从容准备。

Log4Shell 属于“没有 embargo”的类型。它最早在游戏社区被公开演示,然后在社交媒体上扩散,厂商是在被公开之后才开始响应的。这不是协调披露的失败,这是协调披露的不在场。

PHP2Shell 属于第三种,也是最值得写进教科书的一种:embargo 存在,运行了两个月,然后被分段瓦解。它不是没有制度,它是制度被直播。

这个区别的意义在于:前两种情形下,“制度失败”的叙事是站不住的——因为根本没有足够的制度可供失败。而这一次,有。9 月 21 日确认,10 月 22 日预定发布,四个版本线同步,发行版打包者在窗口内,PoC 托管在 escrow 里。这是一套完整的、教科书式的安排。然后它在三十个小时里被三次不同性质的泄露击穿:元数据(结构性必然)、误发视频(操作事故)、提前发布镜像(商业越位)。

这三段泄露还揭示了一个前两次事件里不存在的渠道:镜像。Heartbleed 时代没有容器镜像供应链,Log4Shell 时代有镜像但没有形成“镜像厂商可以抢在公告前发布”的惯例。到 2026 年,镜像已经是补丁分发的实际末端,而它的发布节奏由商业 SLA 决定,不由 embargo 决定。协调披露的框架设计于上世纪九十年代到本世纪初,它假设的参与者是“厂商与研究者”;而 2026 年的实际参与者里,多了一整层既不是厂商也不是研究者的中间商。

一个制度最脆弱的地方,通常不是它制定的规则不合理,而是它没有覆盖到后来长出来的那批参与者。

这句话可以看作本章的第二个定性句。它也为后面第 11 章埋了线索:开源治理的所有难题,最后都收敛到“谁在链条上、谁承担成本、谁有决定权”这三个问题上。

还有一条反证必须放进来,否则这一节的对比会变成单方面的渲染。如果只看“受影响面”和“修复难度”,PHP2Shell 确实像 Heartbleed 与 Log4Shell 的合体。但如果看“修复执行”这件事本身,这次的表现比两次前任都要好:两个月的协调期没有断,四个版本线同日发布,从编号泄露到补丁可用只用了二十三个小时,多个云厂商在当天就出了缓解规则。Heartbleed 披露后的几天里,大量组织的状态是“不知道该不该换证书”;Log4Shell 披露后的几天里,大量组织的状态是“找不到自己有哪些实例”。而这次,绝大部分组织在二十四小时内就知道该做什么——升级到四个版本之一,或者下线。

所以“这次不一样”的正确说法不是“这次更糟”,而是“这次的压力落点不同”。Heartbleed 的压力落在运维(换证书、重启),Log4Shell 的压力落在资产可见性(找依赖),PHP2Shell 的压力落在维护者与制度(决定怎么修、决定谁先知道)。

压力落点不同,意味着解法不同。而在很长一段时间里,行业的投入是按前两种压力来配置的:更多的扫描器、更好的 SBOM、更快的证书轮换。这些都对,但它们不解决“只有几十个人能决定这段代码该怎么改”的问题。

1.6 本章的结论不是“披露失败了”

先把这一节要否定的三句话摆出来。

第一句:本章的结论不是“协调披露失败了”。协调披露在这次事件里完成了它最主要的功能——补丁在公开披露的同一时刻可用。这是它的核心承诺,它做到了。崩掉的是它的时间安排,不是它的产出。

第二句:本章的结论不是“Chainguard 是坏人”。材料没有给出它的理由,我们也不知道它的内部决策过程。把它当成一个道德案例来写,会让人舒服,但会让人学不到东西。它是结构里长出来的一个位置,那个位置换了谁都会承受同样的张力。

第三句:本章的结论不是“CVE 编号制度该废掉”。编号是这套体系能够互相引用的基础,废掉它,协调披露会退化成一堆互不相干的邮件。它的脆弱性不在设计,在“元数据必然先于内容流通”这个物理事实。

那么真正的结论是什么。

真正的结论是:这套制度第一次被全程直播,而直播暴露的不是某个人的失误,是一个新的信息结构——“知道有事”与“知道是什么”之间的时间差,已经可以被单独利用。

在编号、镜像、社交传播三者叠加之后,协调披露所依赖的核心假设——“在补丁可用之前,外部不会知道这件事”——已经不再成立。可以成立的是一个弱得多的假设:“在补丁可用之前,外部不会知道细节。”这不是一个可以靠纪律维持的假设,因为它取决于几十个参与者的几十个动作,而其中任何一个动作都可能被复制、被转发、被截图。

这也解释了 10 月 20 日那个下午的真实结构:15:00 之后,项目的选择权已经被压缩到两个窄选项里,而两个都算不上好。提前一天,是这两者中损失较小的那个。这个决定做得很快,也做得对;但它是在一个已经不由自己设定的时间表上做的决定。

再补一层自我限定,避免这一章被读成“制度无用论”。如果这次事件里没有那两个月的协调,10 月 21 日不会有补丁可用,只会有更多的下线、更早的 PoC、更长时间的裸奔。泄露没有摧毁制度,它只是让制度的边界露了出来。而边界被看见,本身是有价值的——2014 年的行业从 Heartbleed 学到了“内存安全要重视”,2021 年的行业从 Log4Shell 学到了“依赖要清点”,2026 年这次,该学的东西在流程这一侧:当发现成本塌陷之后,披露流程的成本结构也必须重新设计,否则它会变成整个链条里最薄的一环。

最后一句话,作为本章的收束判断:

这次事件的第一个裂缝不在 php-src 里,而在那张写着 10 月 22 日的时间表上;代码里的洞修了两个月修好了,流程上的洞到今天还没有补丁。

而流程为什么会这么脆,答案不在流程里。流程之所以脆,是因为它试图把一段它并不理解的修复过程,包装成一个可以被排期的日期。要让“10 月 22 日”这个日期成立,前提是有人知道该在哪一行改什么——而这件事,从 2024 年到 2026 年,一直卡在那里。

下一章,我们进引擎。看看一个 PHP worker 是怎么活过一个个请求的,以及为什么“进程复用 + 请求级内存池 + bailout 跳转”这三件好事叠在一起,会长出一道必须用内存破坏来描述的缝。

第 2 章 洞在引擎里:一段被复用的内存

Datadog Security Labs 在 2026 年 10 月 22 日的复现分析文末写下过一句判断:“先更新元数据、再分配它所描述的内存”这个模式本身仍有风险。他们没有把这句放在漏洞细节旁边,而是放在全文最后,像是对整台引擎的提醒。

本章要回答一个问题:为什么 PHP2Shell 的两个漏洞——CVE-2026-109618 与 CVE-2026-109619——不能被理解成“某一行代码写错了”。这不是一句安慰性的说法。如果只是写错一行,修复应该是几个小时的事;而实际上,从 2026 年 9 月 21 日 PHP 安全团队确认,到 10 月 21 日发布修复版本,中间是整整一个月的全职推进,跨了周末,动了核心内存管理。慢的原因不在打字速度,在结构。

所以我必须先带读者看清楚三个东西:PHP 进程是如何被复用的,请求级内存池是如何工作的,以及一次 fatal error 是如何把执行栈整段丢掉的。三者单独看都是合理的设计,甚至可以各自写进教科书;叠在一起,就出现了本书要反复讨论的那个词——缝隙。

如果你此前没有读过 php-src,这一章是为写的。我不会假定你知道 zend_bailout 长什么样,也不会假定你见过 Bucket 结构体。凡涉及引擎内部实现、而我又无法从公开材料中逐字引证的细节,我会明确标注为背景知识,或用“通常”“在多数实现中”这样的限定语。这不是免责声明,是技术写作应有的边界感。

2.1 五种 SAPI,一个共同前提:PHP 如何寄生在 Web 服务器里

PHP 不自己监听端口。

这句话值得多看一会儿。你访问一个 WordPress 站点时建立的那条 TCP 连接,是 Apache 或 Nginx 接的;PHP 从来没有在那个端口上 accept 过任何东西。PHP 拿到的,是一个已经被 Web 服务器解析完毕的请求,以及一个用来写回响应的输出通道。中间这层接口叫 SAPI(Server API)——PHP 寄生在宿主里的方式。

SAPI 的形态决定了进程归谁所有,而这又直接决定了漏洞的影响范围。2026 年 10 月被确认受影响的部署有五类:Apache 的 mod_php、PHP-FPM、php-cgi、FrankenPHP、LiteSpeed。它们不是五个版本,是五种“寄生关系”。

mod_php 是 Apache 的一个模块,编译进去之后,PHP 就不是一个独立进程了,它就是 Apache 子进程内部的代码。Apache 用 prefork 或 worker 模型管理自己的孩子,PHP 的请求生命周期跑在这些孩子体内。没有中间层,没有协议边界。

PHP-FPM 完全相反。它是独立的一组常驻进程,Nginx 或 Apache 通过 FastCGI 协议把请求投递过来。进程池由 FPM 主进程自己管,Web 服务器只管把字节写进 socket。这是今天最主流的形态,也是云上和容器里最常见的那一种。

php-cgi 是上一代的东西:每来一个请求,起一个进程,处理完就死。听上去是最干净的模型,代价是进程创建成本摊到了每一次请求上。它在现代部署里已经边缘化,但老系统、嵌入式面板、一些构建流水线里仍然有它的影子——这也是为什么它会出现在受影响清单里。

FrankenPHP 走的是另一条路:把 PHP 运行时嵌进 Go 写的服务器进程,用 Go 的并发模型来调度。LiteSpeed 有自己的 lsapi 接口,形态上更接近 FPM 的常驻池,只是协议和进程管理都由厂商自己实现。

| SAPI | 形态 | 进程归谁所有 | 请求之间是否复用同一块内存 |

| --- | --- | --- | --- |

| mod_php | Apache 模块 | Apache 子进程 | 是 |

| PHP-FPM | 常驻进程池,FastCGI | FPM 主进程 | 是 |

| php-cgi | 每请求一个进程 | CGI 网关 | 否 |

| FrankenPHP | Go 内嵌运行时 | Go 进程 | 是 |

| LiteSpeed | lsapi | LiteSpeed 进程 | 是 |

五种形态,四行“是”。这张表最右边那一列,才是这一章真正的主题。

把 SAPI 说成一个“接口”是容易的,但它实际决定了三件事,而这三件事在后来的事故里全都用得上:请求从哪里进来(是 Web 服务器递过来的一段已解析数据,还是进程自己去读 socket),状态能活多久(跟着进程,还是跟着请求),出错时谁看得见(是 PHP 的日志,还是宿主服务器的日志)。第 1 章里那些形态各异的现场报告——有的报 Allowed memory size,有的报 Apache 的 AH00052——差别不在漏洞,在这三层归属。

那为什么材料里反复强调 mod_php 风险最高?公开事实给了两条直接证据。第一,109619 的两个可利用案例中,fatal-error backtraces 这一条(仅 PHP 8.5)被 PHP 确认可以在 mod_php 上导致命令执行;upload processing 这一条虽然在 mod_php 与 PHP-FPM 上都能触发,但在 FPM 上还需要应用在启动成功后执行某个合适的操作,条件更苛刻。第二,mod_php 出问题时的可见形态是 Apache 的日志:AH00052: child pid ... exit signal Segmentation fault (11)——你看到的是 HTTP 服务进程在段错误。

这两条之外,还有一层结构性理由可以推理,我把它明确标为推理而非事实:在 mod_php 里,PHP 的“请求级状态”与“处理这条 HTTP 连接的进程状态”共享同一个生命周期边界。FPM 至少还有一层 FastCGI 协议在那里——请求的解析、投递、响应回写各是一段独立动作。mod_php 没有这一层,一次 fatal error 崩掉的是正在服务 HTTP 的那个进程本身。所以同样的状态复位缺陷,在 mod_php 上更容易变成“打一次就中”,在 FPM 上更容易变成“打很多次才中”。第 4 章会展开这个概率问题。

这里要防一个很自然的误读:既然 mod_php 最危险,那换掉它不就行了。不行。php-cgi 那种“每请求一个进程”的模型在理论上确实切断了复用,但它的性能代价在今天不可接受,而且已经在生态边缘;FrankenPHP 和 LiteSpeed 一样是常驻复用,只是换了实现者。换句话说,复用不是某一家的设计选择,而是整个 PHP 部署生态的共同前提——为了让 PHP 跑得动真实流量,进程必须被反复使用。

五种 SAPI 的差别,在于谁持有那个进程。

它们的共同点,在于那个进程会被下一个请求接着用。

这就是全部问题的入场券。接下来要看的是,这张入场券在 worker(工作进程)的一生里被兑现成了什么。

2.2 一个 worker 的一生:请求从进入到退出的七个阶段

先问一句:为什么要复用?

因为不复用的成本高得离谱。一个 PHP 进程启动时要做的事包括:加载并初始化所有扩展模块、建立内部符号表、初始化内存管理器、把 opcode 缓存挂上去。这些是固定开销,与请求大小无关。相比之下,一个简单的 API 请求可能只执行几千条 opcode,几毫秒就结束了。如果把上述固定开销摊到每个请求上,PHP 的性能会掉一个数量级。所以“一个请求一个进程”这条路在 Web 场景里走不通,业界的选择是反过来:让一个进程连续处理成百上千个请求,直到它被回收。

在多数 FPM 配置里,进程处理到一定请求数之后才会被主进程回收重建(这个阈值常被设成几百到几千,属于背景知识,不在本次材料范围内)。在此之前,它是同一个进程、同一块地址空间、同一组全局变量。

复用在性能上换来的是摊销。付出的代价只有一句话:上一个请求留在内存里的任何东西,下一个请求在物理上都能碰到。

材料把请求生命周期拆成五个阶段。为了让“哪些状态属于进程、哪些属于请求”这件事更清楚,我按实际执行顺序把它展开成七步,其中第二步与第七步是我补出来的分界(属于多数 SAPI 实现中的实际动作,标注为背景知识):

  
1  module startup      进程启动时执行,仅一次          ← 进程级
  
2  请求到达与解析       SAPI 读请求,填充 $_GET/$_POST/$_FILES  ← 请求级
  
3  request startup     建请求内存池、session 状态、上传状态   ← 请求级
  
   / activate
  
4  执行脚本            opcode 逐条执行,用户代码在这里       ← 请求级
  
5  request shutdown    调析构函数、释放请求内存池、写 session、
  
                       清理上传状态、关闭输出              ← 请求级
  
6  deactivate          模块级复位,worker 回到等待状态      ← 边界
  
7  module shutdown     进程退出时执行,仅一次              ← 进程级
  

逐行说。

第一行,module startup。进程刚刚被 fork 或 exec 出来,所有扩展在这里注册自己的钩子、初始化自己的全局表。这一步之后,进程里有了一批“活到进程死亡为止”的数据结构。它们不属于任何请求。

第二行,请求到达与解析。SAPI 从 socket 里读出 HTTP 请求,解析头部,然后处理 body:如果是 application/x-www-form-urlencoded 或 multipart 表单,就把它拆成字段名与字段值,插入哈希表,最后交给超全局变量 $_POST、$_GET、$_FILES、$_COOKIE。注意这一步发生在任何用户代码执行之前——这正是第 3 章那个 PoC 能生效的位置。日志里那句 Allowed memory size ... exhausted ... in Unknown on line 0,Unknown on line 0 的意思就是“没有任何脚本文件、没有任何行号”,因为失败发生在请求初始化阶段、脚本执行之前。

第三行,request startup 与 activate。引擎为这个请求建立独立的内存池(下一节详述),初始化 session 机制、上传进度跟踪、错误处理状态。这一步是“请求级状态”的诞生点。它诞生的方式,是把一批进程级的全局字段指向刚刚分配的请求级内存。

第四行,执行脚本。用户代码在这里跑,所有的 include、所有的数组操作、所有的函数调用都在这一步。这一章后面讲的所有内存操作,都发生在这一行。

第五行,request shutdown。顺序大致是:调用析构函数、释放请求内存池、把 session 写回存储、清理上传状态、关闭输出缓冲。请记住这个顺序里有一个隐含假设:清理程序信任元数据。它不会去验证一个桶里装的到底是不是一个合法的值,它只看元数据说“这个槽位已用”,就按已用去析构。第 3 章的全部戏剧性就压在这个假设上。

第六行,deactivate。模块级复位,worker 回到等待队列,等下一个请求。这一行是“缝隙”变成“事故”的转换点:如果第五行没有把某个进程级字段清干净,第六行不会替它清。它只是把进程交还给池子。

第七行,module shutdown。只有进程真正退出时才走。注意这一行在绝大多数情况下不会被执行——worker 是被复用的,不是被销毁的。

七步里,真正把状态分成两类的,是第一步和第三步。第一步建立的东西活到进程结束;第三步建立的东西活到请求结束。把所有状态按这个轴排一遍,得到下面这张表:

| 状态 | 归属 | 生命周期 | 请求结束后谁负责清理 |

| --- | --- | --- | --- |

| 扩展注册的模块全局表 | 进程级 | 进程 | 无人(本就不该清) |

| opcode 缓存 | 进程级 | 进程 | 无人 |

| 错误状态、异常状态 | 进程级字段,指向请求级内存 | 错位 | request shutdown |

| 会话与序列化解码器状态 | 进程级字段,指向请求级内存 | 错位 | request shutdown |

| 上传进度跟踪状态 | 进程级字段,指向请求级内存 | 错位 | request shutdown |

| 请求内存池 | 请求级 | 请求 | 整池释放 |

| 超全局变量 | 请求级 | 请求 | 随池释放 |

| 用户脚本变量 | 请求级 | 请求 | 随池释放 |

这张表第三列写着“错位”的那三行,是整本书的技术起点。它们的物理形态是一个进程级(也就是活到进程死亡)的字段,指向一块请求级(也就是请求结束就被整池释放)的内存。只要 request shutdown 把它们清空,错位就被消除;只要有一次没清空,进程里就留下一个指向已释放内存的指针——这就是第 4 章要讲的“脏状态”(stale global state)。

而复用在概率上放大了这件事。一次请求留下的脏状态,只有被“下一个打到同一个 worker 的请求”踩到才会发作。worker pool 越大,单次尝试命中的概率越低;但漏洞不消失,只是需要更多请求。这个概率性质很重要,它决定了这个漏洞在真实攻击里是什么手感:不是一发入魂,是反复摩擦。

复用的收益属于性能,复用的代价属于隔离。两者写在同一个设计决定里,没法只取一半。

2.3 Zend 内存管理器:请求级内存池是个好东西,直到它不是

现在该解释那个池子了。

PHP 不用系统的 malloc 直接服务脚本里的每一次数组插入。它自己有一层内存管理器,叫 Zend Memory Manager(ZEND_MM)。这层管理器向操作系统申请比较大的块(chunk),然后在块内部切成小块分给引擎用。在多数实现中,chunk 的尺寸是 2MB 量级,内部再按 small、large 两类尺寸分档,这不是本章要讨论的内容,属于背景知识,列出来只是为了让你知道“池”不是比喻,是真实存在的连续内存段。

关键在于“请求级”这三个字。每一个请求进来,引擎为它建一个独立的内存池;请求结束,整池一起释放。不是逐个 free 每个变量,是把整段还给管理器,一次清空。

这个设计极其划算。写 PHP 的人都知道,你从来不用操心释放内存;不是因为有引用计数(虽然有),而是因为绝大多数内存根本不需要逐个释放——请求结束时,池子整体消失,剩下的事与语言层无关。这也是 PHP 在共享主机时代能活下来的原因之一:一个写得很烂的脚本,最坏也只是把它自己那个请求的内存耗光,不会永远占着机器。

memory_limit 就是这个池子的上限。它限制的是“引擎在这个请求里累计向池子申请了多少字节”,不是进程的常驻内存(RSS),不是操作系统的配额,也不是 ulimit 那种进程级资源限制。默认值常见是 128M;容器镜像里常见的配置是 128M、256M 或 512M,也有一些老系统保留着 16M。请记住 16M 这个数,第 3 章那个 3.5MB 的字段名就是在这个量级上撞墙的。

这里有一个区分,是本章最需要读者带走的一句:内存池保证的是“地址不跨请求共享”,不保证“指针不跨请求残留”。

这是两个不同的命题。第一个命题由池子的生命周期实现:请求 A 的地址,请求 B 拿不到,因为池子在 A 结束时就整段释放了,B 拿到的是另一段。第二个命题需要另一套机制:所有指向池内地址的进程级字段,必须在池子释放之前被清空或改写。前一个机制是结构性的、自动的、几乎不可能出错;后一个机制是约定性的、手写的、逐字段维护的。

事故从来不出现在第一个机制上。

那么很自然的一个反应是:把 memory_limit 调大,甚至设成 -1(不限制),不就不会撞墙了吗?

材料对这个想法给了明确否定,而且理由值得完整复述。第一,memory_limit=-1 只是让这一次 OOM 不发生,攻击者可以用别的方式把系统内存耗光,还可能破坏主机稳定——你把一个“进程内可控的失败”换成了一个“机器级的不可控失败”。第二,不能用 RLIMIT_AS、RLIMIT_DATA 这类操作系统级的地址空间限制来替代,因为它们触发的分配失败,恰恰就是漏洞所依赖的那个条件。第三,所有配置层的缓解都必须写在“PHP 解析请求之前就加载”的配置里,并且重启每一个 worker 才生效——在一个有几百个 worker 的池子里,这意味着一次滚动重启。

Cisco 在公告里直接写了“no workarounds available”(无可用的临时缓解)。这句话不是偷懒,是诚实。

所以,memory_limit 不是安全边界,它是资源边界。把资源边界当安全边界用,是这场事故里最普遍的一类误判:大家都在讨论“上限设多少合适”,而真正的问题在于“撞到上限的那一刻,引擎做了什么”。

这个池子本身没有错。它的设计目标——用整池释放换取零成本的内存管理——是本世纪服务端语言里最成功的取舍之一。它只是在兑现这个取舍的时候,把“请求结束时一切都会消失”当成了公理,而没有为“请求没能正常结束”准备一条路径。

那条路径,就是 2.5 节要讲的 bailout。

在讲它之前,还得先把池子里装的那样东西看清楚:PHP 的数组,物理上到底长什么样。因为第 3 章要讲的那个顺序问题,发生在数组插入的第四行代码里,不看结构,那四行代码读起来毫无异常。

2.4 哈希表:PHP 数组的物理形态

PHP 的数组是这门语言最被滥用的数据结构,也是被误解最深的一个。它不是链表,也不是平衡树,它是一个“带顺序的哈希表”——材料里的说法更朴素:底层是 HashTable,arData 是一段连续的 Bucket 数组,nNumUsed 记录已用槽位,nNumOfElements 记录有效元素数。

先看结构。下面的结构体是示意性的,字段顺序与命名按公开源码的常见形态做了简化,属于背景知识,不在本次材料范围内;但 arData、nNumUsed、nNumOfElements 这三个名字是材料原文,第 3 章要讲的那四行代码也直接用它们,所以必须认准。

  
/* 结构体示意:Bucket 与 HashTable 的物理形态(背景知识,简化版) */
  
typedef struct _Bucket {
  
    zval         val;      /* 值:一个类型标记 + 一个载荷 */
  
    zend_ulong   h;        /* 键的哈希值,用于查找 */
  
    zend_string *key;      /* 字符串键;整数键时为 NULL */
  
} Bucket;
  

  
typedef struct _zend_array {
  
    Bucket   *arData;          /* 连续的 Bucket 数组,从堆上一次性分配 */
  
    uint32_t  nNumUsed;        /* 已用槽位数:下一个可写位置的下标 */
  
    uint32_t  nNumOfElements;  /* 有效元素数:真正能读到的元素个数 */
  
    /* ... 哈希索引表、标志位、迭代器计数等,此处省略 ... */
  
} HashTable;
  

逐行说。val 是 PHP 里所有值的统一表示:第一个字段是类型标记,后面的载荷按类型解释——可能是整数、可能是指针、可能是引用计数头。h 是键的哈希值,插入时算一次、之后复用,查找就靠它定位。key 是字符串键的指针,如果是整数键就是空。arData 指向一段连续内存,这是 PHP 数组“快”的根源:遍历是顺序访存,对 CPU 缓存友好。nNumUsed 是“已经用掉多少槽位”,它同时是写指针的下标。nNumOfElements 是“用户视角的元素个数”。

最后这两个字段为什么要分开?因为 PHP 的数组支持删除。删除一个元素时,PHP 不搬动后面的元素,只在那个槽位留下来一个“已删除”的标记(通常是把值改成 IS_UNDEF,这一行为属背景知识),然后把 nNumOfElements 减一,nNumUsed 不动。

这就是整个设计的核心取舍:用空间换稳定。槽位一旦分配就不再回收,下标永远不变,所以遍历不会因为删除而错乱,遍历的复杂度是线性的。代价是数组会“留洞”,一个反复增删的数组,实际占用的槽位可能远多于有效元素。

而留洞这件事,把整个引擎推到了一个必须信任元数据的位置。所有遍历都以 nNumUsed 为边界,遍历过程中跳过标记为已删除的槽位。于是有一条隐含约定:凡是被 nNumUsed 圈进来的槽位,要么装着一个合法的值,要么被显式标记成已删除。 没有第三种情况。

第 3 章要讲的,就是第三种情况。

先看插入。材料给出了受测版本 PHP 8.5.11(commit 678778973)里 _zend_hash_str_add_or_update_i() 的顺序:

  
idx = ht->nNumUsed++;
  
ht->nNumOfElements++;
  
p = ht->arData + idx;
  
p->key = key = zend_string_init(str, len, GC_FLAGS(ht) & IS_ARRAY_PERSISTENT);
  

四行,逐行说。

第一行,idx = ht->nNumUsed++。把当前的已用槽位数存进 idx 作为“我这一格在哪”,然后把计数器加一。注意这是一个后置自增:idx 拿到的是旧值,计数器变成的是新值。所以从这一行执行完的瞬间起,nNumUsed 已经把这个新槽位算作“已用”了。

第二行,ht->nNumOfElements++。有效元素数也加一。到这一行为止,从元数据角度看,这个元素已经存在了。

第三行,p = ht->arData + idx。算地址。纯算术,不涉及内存分配,不可能失败。

第四行,p->key = key = zend_string_init(str, len, ...)。到这一行才真正分配内存,用来拷贝那份键字符串。这里的 str 就是表单里的字段名,len 是它的长度。如果这是一个 3.5MB 的字段名,这次分配就是 3.5MB 量级。

顺序读下来,四行代码没有任何一行是错的。第一行到第三行是记账,第四行是干活。单据先开,活后干——这在工程上甚至是一种常见写法,因为先占位再填充可以避免中间状态被别处看到。

问题在于第四行会失败。

zend_string_init 可能把内存池推过 memory_limit,于是引擎触发 fatal error,然后 bailout。此时的状态是:nNumUsed 已经把新槽位算作已用,nNumOfElements 也加了,但那个槽位的 key 和 val 从来没被写过——它里面留着的是上一位租客的字节。

而 request shutdown 会遍历“所有已用槽位”。在这次事故里,两个计数器都已经加过一,所以无论清理循环以哪个计数作边界,都会走到那个槽位。清理循环的形状大致是这样(伪代码,示意清理逻辑,不是源码原文):

  
/* 伪代码:request shutdown 遍历并析构一个数组的形状 */
  
for (idx = 0; idx < ht->nNumUsed; idx++) {
  
    p = ht->arData + idx;
  
    if (Z_TYPE(p->val) == IS_UNDEF) {
  
        continue;               /* 已删除的槽位:跳过 */
  
    }
  
    zval_ptr_dtor(&p->val);     /* 其余一切:按合法值析构 */
  
}
  

第一行,边界是 nNumUsed。这个数字现在包含了那个半成品槽位。第二行,定位槽位。第三行,检查标记。第四行,如果没有标记成已删除,就当合法值处理——调用 zval_ptr_dtor 去析构它。

zval_ptr_dtor 会看类型标记。如果标记是字符串,就减引用计数、必要时释放;如果是数组,就递归析构每个元素;如果是对象,就调它的析构方法。它不会问“这个值到底是被谁写进来的、写完整了没有”。

所以,一个从未被初始化的槽位,被当作真实的 PHP 值处理了。材料把这个槽位叫“半初始化 bucket”:计数器已经把它算进去,key 和 value 还没落定。

这里可以提炼一句:数组的性能来自“让槽位的存在与元素的有效性解耦”,而漏洞也来自同一个解耦——系统必须靠元数据判断有效性,而元数据可能领先于数据本身。领先的那一小段时间,在正常路径上只有几条指令,谁也看不见;在 bailout 之后,它会一直保留到清理开始。

顺带解释一个第 3 章会用到的细节:为什么必须是数组语法。材料说得很清楚,普通字段名走的是另一条“已建 key 的路径”,不易触发;只有 zzz[x]=1 这种带数组语法的字段,才会走到 _zend_hash_str_add_or_update_i 这条“先计数、后分配”的路上。一行代码的书写顺序,只有在特定的调用路径上才会变成漏洞。

2.5 bailout:当 memory_limit 被撞破时,PHP 选择了跳出去

现在讲那条路径。

内存耗尽的时候,PHP 不能抛异常。原因很实在:抛异常要构造一个异常对象,构造对象要分配内存,而要分配的那块内存正是当前不够用的东西。在池子已经满的那一刻,你需要一条不需要分配内存的错误通道。这是设计约束,不是我加的修辞(属背景知识,材料只给了结论形态)。

材料给出的通道是这样的:memory_limit 耗尽时,PHP 调用 zend_error_noreturn(E_ERROR, ...),最终走到 zend_bailout(),用 longjmp 跳出当前执行栈,直接进入 shutdown 流程。函数名里的 noreturn 已经从命名上说明了它的性质:它不返回。

先看控制流的形状。下面这段是伪代码,用来示意 setjmp 与 longjmp 的配对关系,不是 php-src 的原文:

  
/* 伪代码:请求执行的骨架,示意 bailout 的落点 */
  
ZEND_API void php_request_startup(void) {
  
    if (setjmp(EG(bailout)) != 0) {
  
        /* 从 zend_bailout() 落回这里:注意,本函数剩下的初始化不会执行 */
  
        return;
  
    }
  
    zend_activate();     /* 建请求内存池、session 状态、上传状态 */
  
    /* ... 其余初始化 ... */
  
}
  

  
ZEND_API ZEND_NORETURN void zend_bailout(void) {
  
    longjmp(EG(bailout), 1);   /* 把执行栈整段丢弃 */
  
}
  

  
/* 分配路径上,记账之后发现超限 */
  
if (UNEXPECTED(allocated > memory_limit)) {
  
    zend_error_noreturn(E_ERROR,
  
        "Allowed memory size of %zu bytes exhausted (tried to allocate %zu bytes)");
  
    /* 到这里不会往下走了 */
  
}
  

逐行看。

第一段里,setjmp(EG(bailout)) 在请求初始化开始时记下一个“落点”:它把当时的寄存器状态、栈指针存进 EG(bailout) 这个缓冲区,然后返回 0。所以正常情况下,if 条件为假,继续往下初始化。

zend_activate() 那一行是请求级状态诞生的地方——池子、session、上传状态都在这里建起来。从这一行起,进程里开始存在“指向请求内存的进程级字段”。

zend_bailout() 里只有一条 longjmp(EG(bailout), 1)。它做的事是:把栈指针和寄存器恢复到当年 setjmp 记下的那一刻,然后让 setjmp 返回 1。于是控制流回到第一段的 if,这一次条件为真,执行 return。

关键在于那句注释:本函数剩下的初始化不会执行。如果 bailout 发生在 zend_activate() 内部,那么已经建好了一半的状态,后面一半就不建了;php_request_startup 会直接返回,返回给它的调用者,而调用者收到的是一个“看起来正常返回”的结果。

最下面的三行是触发点。判断超出上限,报错,不返回。注意报错文本是 Allowed memory size of ... exhausted (tried to allocate ...)——这正是第 1 章检测信号里那条日志。日志里还会带 in Unknown on line 0,含义是失败发生在请求初始化阶段,当时没有任何脚本文件和行号可用。这也解释了为什么这条日志在真实环境里那么难读:它看起来不像一个应用错误,像是引擎自己在说话。

现在回答本节的核心问题:为什么 bailout 会跳过正常清理顺序?

因为 longjmp 不是栈展开,是栈丢弃。C 里的正常返回路径上会发生什么?每一层函数在 return 前做自己的局部清理,上层函数收到返回值后判断成败、决定是否回滚、再继续往上返回。这条路径上分布着几十层调用,每一层都有机会说“我这里做了一半,先收拾干净”。longjmp 把这些层一次性抹掉,直接跳到落点。它不调用析构函数,不执行局部清理代码,不给任何中间层留下反应的机会。

所以我才说它是一次没有回滚的中止——注意这个类比只在控制流意义上成立,PHP 这里并没有事务机制。

而更麻烦的是下一步:bailout 之后,系统并不会跳过 cleanup。它仍然要跑 request shutdown——池子要释放,session 要写回,输出要关闭。这些动作必须做,否则进程立刻就废了。

于是整个流程变成:在不完整的状态上,执行正常的清理流程。

这是把异常当正常处理。它是最省事的做法,也是最危险的做法。因为清理代码不知道自己正在处理一个被截断的中间状态,它按既定顺序走,按元数据判断,按标记析构。

这里要处理一个合理的反驳:longjmp 本身不是原罪。它是 C 语言里标准的错误恢复手段,几十年来被无数运行时用着,很多解释器和虚拟机都用类似机制跳出深层递归。如果 longjmp 本身是错的,这套设计早就该被推翻了。

我同意这个反驳的前半段,但结论相反。longjmp 不是 bug,问题出在跳转之前的那本账上。如果被跳过的中间状态在元数据里仍然是“未生效”的,那么任何跳转都安全;反之,即使你把 longjmp 换成最优雅的异常机制,只要异常之前已经写了计数器,状态一样是坏的。第 3 章会看到,官方补丁最后动的不是跳转,而是“什么时候允许跳”。

这也是理解修复为什么要两个月的一把钥匙。想修这个缝隙,你有两条路:改中断策略(什么时候、以什么方式打断),或者改清理逻辑(怎么判断一个槽位是否可信)。第一条路要动内存管理的核心,第二条路要动被几十个扩展依赖的公共数据结构语义。两条路都有兼容性代价,都不是“删掉一行”。(补丁最后选了第一条路,并为此付出了杀进程的代价——细节留给第 3 章。)

Bailout 不是错误,它是一次中止。而一次中止,如果没人负责回滚,就会变成一份不完整的账。

2.6 缝隙的形状:为什么“半成品状态”必然存在

现在可以把三个部件拼起来了。

第一个部件是进程复用。它提供了载体:任何残留都会留在同一个地址空间里,等着被下一个请求碰到。如果没有复用,残留会随进程消失,代价是性能不可接受。

第二个部件是请求级内存池。它提供了语义:一块内存的生命周期严格等于一个请求,请求一结束,整块失效。这让内存管理变得极其便宜,也让“失效”这个词变得极其绝对——不是慢慢过期,是一刀切。

第三个部件是bailout。它提供了时机:在执行到一半的时候,把栈丢掉,直接跳到清理。

三者叠加,缝隙的形状就出来了。我把它写成一句话,这句话是本章唯一需要被记住的判断:

一个 fatal error 在操作中途打断,随后系统把不完整状态当作有效状态处理。

拆开看,这个形状需要两个条件同时成立。

条件一,中断点必须落在“元数据已更新、数据未落定”之间。这是 109618 的情形:nNumUsed++ 已执行,zend_string_init 失败了。四行代码里,这个窗口只有一行宽。而在正常的错误处理里这个窗口无关紧要,因为失败会沿着返回路径逐层上报,每一层都会拒绝继续。是 bailout 把这个转瞬即逝的窗口冻住了。

条件二,后面必须有一段代码,信任这份被冻住的元数据。这是两个漏洞分道扬镳的地方。

109618 把这段代码放在同一个请求的 shutdown:清理循环信任 nNumUsed,把半初始化的槽位当合法值析构。这是一次“就地发作”——错误发生的位置和后果发生的位置在同一个请求里,中间只隔了一次 bailout。

109619 把它放在下一个请求的初始化:进程级的全局字段(错误状态、会话与序列化解码器状态、上传进度跟踪状态)在上一个请求结束时没被清空,仍然指向已经被整池释放的内存;下一个请求进来,顺着这个指针读,就是 use-after-free。这是“延迟发作”——从受害者的角度看,攻击者甚至不需要在那个请求里做任何越界的事,只需要让上一个请求以 fatal error 收场。

同一副骨架,两种走法。第 3 章和第 4 章分别走完一条。这也是为什么这两件事必须被合起来叫 PHP2Shell:它们不是两个独立的 bug,是同一个结构在两个方向上的投影。

现在回到本章开头那个问题:为什么修复要拖两个月?

因为缝隙不是一行代码。你可以把计数器顺序改过来,但材料说得很清楚,那是核心数据结构的语义,改动风险太大,官方补丁没有动它。你可以改清理逻辑,但那要动所有依赖 HashTable 语义的代码。你可以让 OOM 不再 bailout,但异常需要内存,而内存正好不够。每一条路都通向一个取舍,而取舍需要有人做决定、需要有人承担兼容性后果——第 11 章会讲这套决策是怎么做的。

还有一个更不客气的推论,我认为它比漏洞本身重要:只要还存在“另一种中断初始化的方式”,这个模式就会再来一次。Datadog 的判断值得原样记住:“先更新元数据、再分配它所描述的内存”这个模式本身仍有风险;研究社区预计会发现更多该模式的实例,而只存在于旧版本里的实例,甚至可能永远不被修补。

这里必须加一句自我限定,否则这一章会被读成对 PHP 的控诉。本章的结论不是“PHP 写得很烂”。恰恰相反,整池释放、进程复用、用 longjmp 做无分配的错误通道,这三个决定各自都是对的,加起来让 PHP 在共享主机时代跑赢了同期的所有竞争者,也让它今天还能承载那 70% 的份额(这是 Wiz 的暴露面估计,第 5 章会细谈)。真正的问题在于:这套设计把“正常路径”当成了唯一的路径,异常路径从第一天起就是二等公民。这不是某一个项目的债,是所有高性能运行时共同的债——只是这一次,账在 PHP 这里到期了。

看清楚结构以后,接下来要看的是一件很小的事:那四行 C 代码里,第三行和第四行之间到底发生了什么。缝隙有多细,决定了它能被证明得多快——而 2024 年,有人看见了这条缝,却没有走过去。

第 3 章 先加一再分配:109618 与半初始化的世界

“一个从未被初始化的槽位,被当作真实的 zval 处理。”

——Datadog Security Labs 对 CVE-2026-109618 的机理概括,2026 年 10 月 22 日

本章要回答的问题只有一个:两行 C 代码的书写顺序,怎么可能把一个 HTTP 请求变成一条 shell 命令。

这个问题值得单独用一章来讲,因为它的答案违反工程直觉。我们习惯认为内存安全漏洞来自“忘记检查”——少一次边界判断、漏一个空指针校验、没验证一个长度。109618 不是这样。检查都在,边界判断都成立,函数也按设计完成了自己的职责。它的全部戏剧性来自次序:先把计数器加一,再去分配计数器所描述的那块内存。在这两步之间,数据结构在说谎,而下一个读它的人把这个谎当真了。它证明了一件让工程界不安的事:内存安全的漏洞可以不由“忘记检查”产生,而由“更新元数据的时机”产生。

3.1 两行 C 代码:nNumUsed++ 在 zend_string_init 之前

PHP 的数组不是数组,是哈希表。第 2 章拆过它的物理形态,这里只需要记住三个字段:arData 是一段连续的 Bucket(槽位)数组,nNumUsed 记录这段数组已经写到第几个槽位,nNumOfElements 记录其中真正有效的元素有多少个。

两个数字一般不相等。PHP 删除元素时不搬动后面的数据,只在原位留下一个 UNDEF 标记,所以 nNumUsed 是“写到哪儿了”的水位线,nNumOfElements 是“里面有几件真货”的统计。这个区别在 3.4 节会决定一个槽位的命运:清理代码遍历时的上界,用的是水位线。

在受测版本 PHP 8.5.11(commit 678778973)里,往哈希表插入一个字符串键的核心函数是这样写的:

  
idx = ht->nNumUsed++;
  
ht->nNumOfElements++;
  
p = ht->arData + idx;
  
p->key = key = zend_string_init(str, len, GC_FLAGS(ht) & IS_ARRAY_PERSISTENT);
  

四行代码,两种性质。

前三行是算术。取当前水位当索引,水位加一,元素计数加一,用索引算出槽位地址。算术不会失败,也不申请任何资源,它只是把一个整数写进结构体,再把一个指针加上偏移。

第四行是唯一可能失败的操作。zend_string_init 要为这个键复制一份字符串,向 Zend 内存管理器申请空间。如果申请不到,它不会返回空指针让调用者判断——PHP 在这个位置上的约定是直接报致命错误并 bailout,用 longjmp 跳出整个执行栈,落进 shutdown 流程。这个函数没有“失败返回”这条出口。

于是次序问题出现了:计数器的自增发生在唯一可能失败的操作之前,而失败路径不做回滚。

要注意这不是一次自增,是两次。nNumUsed 和 nNumOfElements 在同一个位置先后加一,两个计数器同时越过了那个槽位。这意味着这个谎言有两个见证人:一个负责让遍历走到那里,一个负责让统计认为那里有货。如果只有前者,后续的许多逻辑还会因为元素数不匹配而显得可疑;两个一起加,数据结构在内部就是自洽的,自洽到没有任何一致性检查能发现异常。谎说得越整齐,越难被戳破。

写这段代码的人当然知道这件事。合理且几乎是唯一的解释是,他认定这条路径不需要回滚,因为 bailout 意味着这个请求已经死了,此后不会有人再来读这张表。这个假设极其合理。它成立的条件只有一条:bailout 之后,没有任何代码遍历这张表。

这个条件在绝大多数情况下成立。只是在请求的末尾,有一个例外。

还有两个细节值得记住,它们会在后面反复出现。第一,函数名末尾的 _i 表示这是 inline 版本,调用者已经替它做过前置检查,它处在最热的路径上,任何改动都要被性能和兼容性同时审视。第二,被提前加一的 nNumUsed 不是给当前操作看的,而是给下一个读者看的——哈希表用它对所有更慢的路径宣告“这一段已经用过了”。元数据一旦写下,就代表事实成立,这是全世界的索引结构共同的默契。

顺带说明这条顺序的适用范围,因为它常被误读为“整个数组都有问题”。这个函数处理的是“按键插入或更新”的两类情形:键已存在时,它只更新值,根本不分配内存,顺序无关;键不存在时,才走分配。所以出事的永远是新增键的那条分支,也永远是“分配失败”的那一次。正常完成插入的请求,一个字节都不会错。这个漏洞不需要目标系统处于某种异常状态,它只需要一次分配失败;而分配失败是外部输入可以稳定触发的事件,不是一个罕见的环境故障。

这个顺序也被看见过。2024 年,PHP 核心开发者在一次外部审计之后注意到过这个位置的最初迹象,判断理论上可利用,但找不到攻击路径,于是搁置了。他们的判断没有错:这段代码确实不违反任何一条明文契约。缺陷的定义权,在这个案子里不在代码里,在能不能把它走通的人手里。

问题不在少写了一次检查,而在多写了一次自增。

3.2 三个字段的分工:一个 3.5MB 的字段名如何撞破内存墙

Datadog 公布的 PoC 请求体结构如下,其中伪造结构与命令部分已被原文省略:

  
=[forged PHP structures and command omitted]&a=1&ZZZZ...[约 3.5MB 的超长字段名]...ZZZZ[x]=1
  

三个字段,三件完全不同的工作。看懂这三件工作的分工,就看懂了 109618 的工程面。

第一段是空名字段,= 前面什么都没有,后面跟一段二进制值。它的任务是堆整形(heap grooming)。表单解析器会把这段字节复制进请求级内存池,落在一个由解析顺序决定的偏移上。攻击者要的不是这段字节被使用,而是它被“放下”——它躺在池子里,等着某个不会被初始化的槽位落到这个位置。堆整形这个词听起来高深,做的事却很朴素:把椅子摆好,等下一个客人坐下。客人就是 3.4 节那个半初始化的槽位。

第二段是 a=1。这个赋值本身没有意义,它的作用是保证 $_POST 这张哈希表已经被创建出来。PHP 的超全局变量是懒初始化的,第一次需要时才建表。攻击者必须先让表存在,才谈得上让表里出现一个空槽。

第三段是主角:一个长度约 3.5MB 的字段名,末尾挂数组语法 [x]=1。解析器要为这个字段名复制一份 3.5MB 的字符串,这一次分配就是撞墙的那一下。

数字关系需要说清楚,因为它容易被误读。日志里留下的证据是这样的:

  
PHP Fatal error: Allowed memory size of 16777216 bytes exhausted (tried to allocate 3500032 bytes) in Unknown on line 0
  

16MB 的池子,一次要 3.5MB。3.5MB 显然小于 16MB,所以触发的不是“单次分配超过上限”,而是“池子里剩下的连续余量已经不够 3.5MB”。这就要求攻击者先用前面的字段把池子填到临界,让最后一击恰好跨过余量而不是撞在天花板上。堆整形在这里承担了双重职责:既要铺好残留字节,也要抬高池子的水位。

3.5MB 这个数字本身也是被两侧夹出来的窗口。太小,不足以在一次分配里跨过剩余余量,bailout 不发生;太大,可能在更早的环节就被拒绝或截断,比如 SAPI 对请求体大小的限制、代理层的缓冲、或者解析器自身对字段名的处理路径。它必须落在一个“能通过前面所有关卡、又能一次压垮池子”的区间里。这个区间不算窄,所以 3.5MB 后来直接成了检测规则里的特征值。

池子的水位也要分情况看。memory_limit 的常见配置是 128M,容器镜像里常见 128M、256M、512M,也有 16M 这类老配置。日志里那个 16777216 字节就是 16M。水位不同,攻击参数就要重算:池子越大,前置的堆整形字段就要填得越满,撞墙的那一次分配也要相应地更大或更靠后。这不是理论上的麻烦,而是野外利用必须处理的适配问题——同一个 payload 打不动全部目标,攻击者需要为目标探出配置,再决定填多少、撞多重。第 7 章讲“跨部署适配”时,指的就是这件事的具体工程量。

最反常识的一点藏在日志的最后一段:in Unknown on line 0。它表示致命错误发生在请求初始化阶段,任何脚本代码都还没有开始执行。没有文件,没有行号,因为当时还没有文件被执行。这意味着攻击面是 PHP 解析请求本身,与目标应用写了什么、用了什么框架、有没有漏洞都无关。一个只输出静态 HTML 的 PHP 文件和一个功能复杂的内容管理系统,在这一点上风险相同。

这也解释了“约 70% 的互联网”这类暴露面估计为什么有底气。它说的不是 70% 的应用有漏洞,而是 70% 的部署里存在这个无需应用配合即可触达的入口。

一个字段名,撞开的不是应用的门,是语言自己的门。

第三个字段还承担了一个隐性的作用:它必须让这一次分配发生在“哈希表已经建好、但键还没有复制”的那个瞬间。早一步,表还不存在;晚一步,键已经在了,走的是更新分支,不分配。攻击者能控制的是次序与长度,不能控制的是这个瞬间本身——它由代码的书写顺序固定下来,一次写定,十年不变。

3.3 为什么必须是数组语法

请求体里写 a=1 和写 a[x]=1,在应用看来差别不大,都是往 $_POST 里放东西。在解析器看来,这是两条不同的代码路径。

PHP 解析 application/x-www-form-urlencoded 时,按 & 切分字段,按 = 切分键值,然后把键值对插入 $_POST 这张哈希表。对普通字段名,解析器很早就可以确定键的字节内容,键字符串在到达插入函数之前就已经构造完毕,插入函数拿到的是一把已经配好的钥匙,直接找位置放,不需要再复制字符串。

字段名里带方括号时,事情不一样。解析器要先按方括号切分,得到根名 z 和后缀 x,判断这是一次嵌套写入:先确认 $_POST['z'] 存在,不存在就新建一个空数组,再往这个子数组里插入键 x。在这一步,插入的是一个字符串键,而要插入的这张子表刚刚建好,键还没有被复制过,于是解析器走的是按字符串插入的那条路径——也就是 3.1 节那段代码所在的函数。

这就是必要条件。攻击者不是偏爱数组语法这种写法,他需要的是那条路径。素材的表述更谨慎:普通字段名“走另一条已建 key 的路径,不易触发”。这个措辞值得尊重——不是绝对不触发,而是没有被作为攻击路径给出。技术写作里区分“不可能”和“没有被走通”很重要,前者是结论,后者是当前的认知边界。

这条路径依赖还有一个副产品,它直接决定了检测规则怎么写。规则里写的是“约 3.5MB 的字段名”,不是“约 3.5MB 的字段值”,因为走分配路径的是键,不是值。值的长度对这一次撞墙不起作用,值只在前面的堆整形里起作用,而且用的是二进制内容而不是长度。检测规则里一个字之差,背后是一条路径之差。

更具体地说,被复制的“字段名”是方括号之前的那一整段。ZZZZ……ZZZ[x]=1 里,解析器先切出 ZZZ……ZZZ 作为根名去查表,查不到就复制这一整段作为新键;方括号里的 x 是第二次插入的键,它很短,与撞墙无关。所以攻击者把长度堆在根名上,把数组语法当作触发路径的开关,两件事分开做,互不干扰。这也解释了为什么这个字段名可以长到荒谬而不影响请求的其他部分:它在语义上只是一个没人用过的键名,长得再离谱也没有代码会去读它。

顺带说清一件事:这条路径不只属于表单解析。任何往哈希表里插入新字符串键的地方都会走到同一个函数——反序列化、配置装载、HTTP 头解析、JSON 解码成关联数组。表单只是最容易从外部触发的那一个入口,也是唯一一个不需要应用执行任何代码的入口。

入口的编码格式也值得区分。application/x-www-form-urlencoded 与 multipart/form-data 都会填充 $_POST,但前者的解析完全在内存里逐个字段进行,字段名是一段可以任意长、不需要落盘的字节串;后者要在解析过程中处理分隔符与临时存储,字段名同样可控,路径上的限制却更多。检测规则之所以盯住前者,是因为它最省事:一个超长字段名、一段空名字段里的二进制值,就够了。攻击者不会只用一种形态,但防御是从最省事的那种开始建的。

路径选错了,什么都撞不出来。

从防御的角度看,这一节的启示比看起来要大。如果只看 payload 的表面特征,超长字段名只是一个畸形输入;但如果知道它是在选路径,那么这个特征的含义就变成了“有人在试探解析器的内部分支”。前者可以用长度阈值挡掉,后者需要理解为什么偏偏是这种写法。检测规则里最难维护的一类,就是只知道特征不知道为什么的规则——攻击者换一种同样能走到那条分支的写法,规则就失效了。

3.4 shutdown 的误判:把残留字节当成合法 zval

bailout 不是 return。它是一次长跳转,从分配失败的那一行直接跳到 shutdown 的入口,跳过中间所有的清理语句、所有的 finally、所有的错误处理分支。

请求进入 shutdown 之后要做的事里,有一件是销毁超全局变量。销毁 $_POST 就是销毁那张哈希表:遍历 arData,从第 0 个槽位走到第 nNumUsed - 1 个槽位,把每个槽位里的内容当作一个 PHP 值来处理,该释放的释放,该减引用计数的减引用计数。

遍历上界是 nNumUsed,不是 nNumOfElements。这符合哈希表的设计:水位线以内都是“被写过”的槽位,删除留下的 UNDEF 标记也是写过的一种状态,必须被访问到才能被正确跳过。设计本身没有问题。问题在于水位线此刻已经被加过一,而它所指向的那个槽位,从来没有被写过。

那个槽位里是什么?是上一位租客留下的字节。同一个 worker(工作进程)、同一块内存池、同一个地址,上一个请求在这里放过别的东西;池子整体释放时不擦除内容,只把整块内存标记为可复用。于是清理代码读到的,是一段完全合法、完全可以被攻击者安排的内存内容。

接下来的一次判断决定生死。zval 是一个联合体加一个类型标记:同一块 16 字节的空间,可以是长整数、浮点数、字符串指针、数组指针、对象指针,具体是什么由类型标记的那几个位决定。析构器不看历史,只看标记。标记说是长整数,它就什么都不做;标记说是字符串或数组,它就去读指针、看引用计数、继续往下释放。

所以真正决定这个槽位命运的,是那八个字节里的类型标记。攻击者通过前面的堆整形,让这里躺着的字节恰好构成一个“看起来像数组”的值:类型标记合法、指针指向池子里另一段他铺设的数据、引用计数看起来合理。剩下的事,引擎会替他做完。

这也解释了同一个漏洞为什么在不同目标上表现不同。残留字节恰好构成整数类标记时,析构器看一眼就走了,请求静默结束,什么都没发生;构成一个引用计数异常的值时,引擎可能在减计数那一步踩到野指针,worker 段错误;构成一个恰好合理的引用结构时,才走到最后一格。三种结局的差别不在代码里,也不在补丁状态里,而在那块没人擦过的内存里躺着什么。一个漏洞有如此大的行为方差,说明它的可控性完全交给了攻击者的工艺,而不在引擎手里。

这里需要一个可以复述的模型,因为它比任何术语都更接近事情的本质。

把哈希表想成一间仓库。仓库有一排货架,编号从 0 开始;门口有一本登记表,登记表上写着“已用到第几格”和“其中有几件真货”。正常的入库流程是两步:先在登记表上把水位加到第 N 格,再去取货、把货放到第 N 格。两步之间有先后,但因为仓库通常不会在取货时突然没地方,这个先后顺序从来没被当成风险。

现在仓库满了。管理员在“取货”这一步发现没地方放,按规定拉响警报,直接锁门走人,不再执行任何后续的入库手续——这就是 bailout。警报之后,清点员进场,按登记表办事:登记表写着已用到第 N 格,那就从第 0 格清点到第 N 格。他走到第 N 格,伸手一摸,摸到了东西。那是上一批货的残留,也许是一箱没搬走的旧物料。清点员不会怀疑,因为登记表从不说谎,它只记录“这一格被用过了”。

在这个模型里,登记表没有错,货架也没有错,错在两份真相之间没有任何一致性保证。登记表代表元数据,货架代表实体,两者必须同步更新;而运行时允许在这两步之间被强行打断,于是同步被打破,且没有任何人事后复核。

这就是“半初始化 bucket”的字面含义:计数器认它,内容不认它。它不是垃圾,也不是空位,它是一个被宣告存在却从未被建造的槽位。

这个漏洞不是“忘记检查”的产物,而是“检查点放错了位置”的产物。

3.5 从崩溃到 system():析构回调如何变成命令执行

Datadog 把利用链条总结成四段:

  
请求初始化失败(fatal error)
  
  → request shutdown 析构 $_POST
  
  → PHP 把未完成的 bucket 当作数组
  
  → 析构这个“假数组”
  
  → 数组元素的析构回调(destructor callback)调用 system(command)
  

四段全是 PHP 自己的正常动作。没有一段是攻击者插入的代码,没有一段是异常的控制流。这是 109618 最刺人的地方:整个执行链条由清理逻辑驱动。

第一段是既成事实。请求在初始化阶段撞破内存上限,bailout,业务代码一行未跑。此时攻击者已经完成两件事:往池子里铺好了字节,让水位线越过了未初始化的槽位。

第二段是引擎的例行公事。请求结束时销毁超全局变量,销毁 $_POST 这张表。这一步在任何请求的末尾都会发生,区别只在于这次表里多了一个不该被访问的槽位。

第三段是误判的发生点。引擎读那个槽位里的类型标记,认为它是一个数组。注意这里连“数组”也不需要是真的数组,只需要类型标记指向数组类别,并且指针字段指向一段结构布局看起来像哈希表的内存。引擎不会去验证这张“表”是谁分配的、在不在请求内存池的范围里、arData 指向的地址是否合理。

第四段是引爆点。销毁一个数组需要做两件事:知道它的元素在哪里,知道该怎么销毁它的元素。前者由伪造结构里的指针给出,后者由哈希表结构里的一个析构回调字段给出——这张表被销毁时,每个元素都要经过这个回调过一遍。正常情况里,这个字段指向引擎内部一个清理函数,它的工作是减引用计数、释放字符串、递归销毁子数组。

如果整张表都是伪造的,这个字段也由攻击者决定。

为什么非要选“数组”这个类型?因为数组提供了难得的第二层间接。如果只伪造一个字符串,引擎会直接释放那段内存,攻击者能控制的只有“读哪里、放哪里”;而伪造一个数组,引擎必须先去读一张表的结构,再从这张表里读出元素的位置,最后才调用表上挂的回调。多出来的这一跳,正好是一次把“数据”换成“代码指针”的机会。类型标记选数组,不是因为数组好用,而是因为销毁数组这个动作需要一次额外的函数调用,而调用目标的地址可以被写进结构里。

于是指针被换成 system() 的地址,被销毁的“元素”是一段构造在池子里的字符串,内容是一条命令。PHP 在清理阶段调用了它。以 worker 的身份,实验中是 www-data。

这个权限不高。它不能改内核,不能读别人的家目录。但它能做三件足够的事:读应用自己的配置文件与数据库口令,向内网横向,建立一个出站连接。对一个已经拿到 Web 根目录读写权的攻击者来说,从 www-data 到更有价值的位置通常只是时间问题。

关于这一段,本书有一个刻意的留白。攻击者构造的具体命令、伪造结构里的字段取值、函数地址的来源、载荷的字节序列,这些内容一个字都不写。素材在给出 PoC 结构时就把这部分标为 omitted,本书保持同样的处理:可以讲清原理与链条,不提供可直接运行的利用。这不是谨慎过度。109618 的补丁在 2026 年 10 月 21 日发布,而未修补的部署在本书写作时仍然存在,其中相当一部分永远不会被修补。技术细节的传播成本在这样的事件里不是抽象概念。

最后要交代一个容易被忽略的差别:能利用,和能稳定利用,是两件事。攻击者猜错地址,worker 会因为段错误直接死掉,日志里留下 AH00052: child pid ... exit signal Segmentation fault (11),运维会看到进程反复重生。这种状态下的利用是研究性的证明。而经过精炼的利用可以返回 HTTP 200,worker 继续正常服务,不留崩溃痕迹。同一个漏洞,两种工艺水平,防御侧的可见性完全不同。

执行的动因是清理,载荷的运载工具是防护逻辑本身。

3.6 ASLR 与 PHPSESSID:地址从哪来

伪造结构需要两个地址,缺一不可。

第一个是伪造结构自身的地址。引擎顺着那个半初始化槽位里的指针去读“哈希表”,它必须先知道读哪儿。这个地址来自堆整形的可预测性:请求内存池的分配是顺序的,字段按解析顺序落位,偏移量在给定版本的给定配置下相对稳定。

第二个是目标函数的地址。system() 在共享库或二进制里的位置,每次进程启动都可能不同。

挡住这两件事的是 ASLR(地址空间布局随机化)。进程启动时,栈、堆、共享库的基址都被随机化,同一份二进制在两次启动里的布局不一样。没有地址,伪造的指针只能靠猜;猜错的代价是 worker 崩溃一次,攻击者重来。

Wiz 给出的答案是内存泄露:用 PHPSESSID cookie 把进程内存的字节带回攻击者手里。做法是构造一个越界读,把 worker 内存中的内容通过 session 相关机制返回给请求方。如果泄露出来的字节里包含仍然有效的指针,攻击者就获得了这次利用所需要的地址。

选择 cookie 作为泄露通道有几个实际好处。cookie 在每个请求里都存在,不需要额外的接口,不需要应用配合,也不需要写文件。它同时也解释了另一件事:为什么 109618 的野外利用形态里,PHPSESSID 会成为一个值得关注的信号——正常的会话标识不需要承载这种东西。

Datadog 的表述更保守,也更准确:ASLR 是一个独立的障碍,属于地址发现问题,但内存泄露消除了大部分猜测工作。这句话的分量在于“大部分”。泄露不一定给出全部答案,它把爆破式的盲猜变成了有限次的验证:读一次、算一次、试一次,失败就再读一次。防御方要面对的不是一次精准打击,而是一个可以重试的循环。

重试的成本结构在这里被彻底改写。对一个手工研究者来说,一次失败的尝试意味着一次崩溃、一次重新分析、一次重新布置,可能是一整天;对一个自动化工具链来说,失败的尝试只是一条日志、一次重连、一次参数微调。同一个“可以重试”,在两种工作方式里的重量相差几个数量级。ASLR 的设计初衷正是靠这个数量级差距来消耗攻击者,而当重试变得几乎免费,这个假设也就跟着松动了。

到这里,109618 的完整链条就闭合了:堆整形提供内容,超长字段名提供中断,半初始化槽位提供入口,伪造结构提供能力,内存泄露提供地址。五个环节,每一个都是独立的技术门槛。第 7 章会回到这一点——把这五个门槛全部跨过,正是“可利用性证明”这一步真正的成本所在,也是人工审计在这里停下、模型在这里继续的地方。

对防御方来说,这套链条留下三条彼此独立的痕迹,缺一不可地看。

HTTP 层:application/x-www-form-urlencoded 请求体里出现单个约 3.5MB 的字段名,或者空名字段的值里含有原始空字节。这一层的坑在于中间设备:WAF 与代理经常对大 body 做截断或丢弃,字段名恰好长到会被截断的长度,规则就永远不匹配。检测规则必须确认自己看到的是完整的 body。

日志层:Allowed memory size ... exhausted ... in Unknown on line 0。Unknown on line 0 是关键特征,它说明失败发生在请求初始化阶段、任何脚本执行之前。这个错误在正常业务里几乎不会出现,因为正常请求不会在初始化阶段就耗尽全部内存。配合 Apache 的 AH00052 ... Segmentation fault (11),可以区分“猜错地址的失败尝试”和“猜对的成功利用”。

进程层最直接:web worker 派生出了 shell。这条信号不依赖任何猜测,但它需要主机侧的可见性,而大多数以应用为中心的安全建设恰好缺这一层。

一条链越精炼,留下越少;留下的部分越分散,越需要关联。

3.7 补丁绕开了那两行

补丁的 commit 是 31c0ea7d。它没有把那四行代码的顺序改过来。

这是一个值得停下来看的决定。既然根因是“先加一再分配”,那么把两行对调、分配成功之后再自增,不就解决了吗?技术上当然可以。但在一个被上亿部署使用的语言运行时里,nNumUsed 的语义不是某个函数内部的局部约定,而是整个引擎与所有扩展共享的契约:水位线一旦越过某个槽位,所有更慢的路径都会认为那个槽位有效。改变写入时机,等于改变一条从未被明文写出、却被所有代码依赖的不变量。谁也不敢保证没有第三方扩展、没有某条序列化路径、没有某个缓存机制在依赖当前的时序。

这就是“修复成本不动”的一个具体形状:技术上能改,制度上不能改。修复的成本不由代码行数决定,而由依赖这套语义的生态规模决定。

于是补丁改的是另一件事:内存耗尽时如何中断执行。

它引入了一个软上限(soft limit)。到达软上限时不立即 bailout,而是把错误推迟到一个“安全点”再报,让进行中的操作有机会把数据初始化完成。换句话说,给“记账”与“供货”之间的那段间隙一次收尾的机会——不是取消这段时间,而是保证在这段时间里被打断的时代结束了。

如果内存继续增长,越过一个更高的硬上限(hard limit),引擎立即调用 _exit(137) 杀掉 worker,不再尝试在可能已经无效的状态上跑正常的清理流程。137 是一个立即退出的状态码(按 POSIX 惯例,它对应被信号九终止),对上层来说,这个 worker 是“没了”,而不是“出错了”。

这个方案的本质是一次交换:用杀死进程,换取不留半成品状态。它牺牲可用性——请求失败、进程重启、连接池抖动、缓存重建——换内存安全。这是一个典型的工程折中,也是这个位置上唯一能落地的折中。值得记住的是折中的方向:它没有让代码变正确,它让代码不可能再走到那个错误的状态里。

Datadog 的判断值得原样记录:“先更新元数据、再分配它所描述的内存”这个模式本身仍有风险。补丁修掉了已知的利用方式,但如果攻击者找到另一种“中断初始化”的方式,让周围的元数据仍然把未分配的内存算作有效,同类漏洞会再来一次。研究社区预计会发现更多该模式的实例,而只存在于旧版本中的实例甚至可能永远不被修补。

顺着这句话往外看,可以得到一个更有用的提问方式。这个反模式的一般形态是:一个结构里存在两份真相,一份是“记账”(计数器、位图、索引、长度字段、目录项),一份是“实体”(它描述的那块内存、那个对象、那份数据)。两份真相必须一起更新,而运行时又允许在中间被打断——异常、信号、OOM、超时、取消、断电。只要这三件事同时成立(两份真相、非原子更新、可中断),这个模式就有可能出现。成熟的做法只有两条:要么先供货后记账,让记账成为“已经发生”的确认而不是“即将发生”的宣告;要么引入中间态,先记账但标记为待定,让读的人知道这一段还没准备好。

这类缺陷之所以少见而难修,是因为它不违反任何单行的直觉。逐行读代码,每一行都对;只有把时序放进去,错的才是那个间隙。代码审查看的是语句,这类问题长在语句之间。

本节要明确一点边界:以上是关于“哪些设计里值得问这个问题”的清单式讨论,不是任何具体系统的指控。本书的材料里没有任何第二个系统的案例,此处也不虚构一个。给出模式的形状,比给出一个可疑的名字更有价值。

本章的结论不是“PHP 的代码写得烂”。这段代码在它被写下的年代是合理的,在之后的十年里也没有被当成缺陷,因为它的前提——“bailout 之后没人再读这张表”——在绝大多数路径上成立。2024 年 PHP 核心开发者看见过这个位置的最初迹象,也正是在这一点上停住的:理论上可利用,找不到攻击路径。他们要的不是“这里有可疑之处”,而是“这里可以被走通”。本章用七节篇幅把这条路径一步步走完,目的不是证明代码糟糕,而是说明一件事:从“可疑”到“确定”之间的距离,是可以被丈量、被分解、被逐段跨越的。谁有能力跨越它,谁就决定了这个距离值多少钱。

109618 的故事在这一次请求里闭合了:它花光了预算,留下一个空槽,在清理时炸了。但 shutdown 只是请求的结束,不是进程的结束。worker 还在,它还连着下一个请求。如果连“清理”这个动作本身都没做完呢——下一个请求会继承什么?

第 4 章 上一个请求的幽灵:109619 与脏状态

“请求之间保留的全局状态(错误、会话、对象、扩展状态)在请求内存释放前未被完全重置。”

——Rapid7 与 PHP 官方公告对 CVE-2026-109619 的成因表述

本章要回答的问题是:一个从来没有被任何脚本写过的变量,怎么能变成一次 use-after-free。109618 的答案是“写了一半的数据结构”,109619 的答案是“清了一半的状态表”。前者发生在一次请求之内,后者发生在两次请求之间。这个区别看起来只是位置不同,其实是两种完全不同的失守方式:一个失守于赋值的次序,一个失守于复位的完整性。

先说结论。CVE-2026-109619 不是某一处代码忘了写检查,而是“进程复用”这项性能设计的账单。它与 109618 同一天被修,同一个根因区域,但它的逻辑要绕一层:它不需要攻击者往任何地方写入恶意内容,只需要让 PHP 自己在一次异常中断之后,留下一个指向已释放内存的指针,然后等下一个请求来读它。本书把这种状态叫脏状态(stale global state)。

4.1 worker 级全局字段:性能优化的必然产物

第 2 章讲过 worker(工作进程)的一生。这里只需要重述那个结构性前提:为了性能,PHP 的进程不在请求之间销毁重建,而是一个进程连续处理成百上千个请求。请求级的内存池每次进出都换一个新的,进程本身不换。

于是有了两套生命周期。一套是进程的,从 module startup 到进程退出,可能持续几天。一套是请求的,从 request startup 到 request shutdown,通常几十毫秒。整个 PHP 运行时的设计,就是把所有东西分装进这两套生命周期里:能跟着请求走的,放进请求内存池;不能跟着请求走的,放进进程级结构。

后者的数量比一般开发者想象的多。PHP 里有几组全局表,各自承担一块状态:PG() 是 php.ini 的核心配置,SG() 是 SAPI 层状态,包括请求方法、请求头、上传进度;EG() 是执行器状态,包括当前异常、错误处理上下文、正在执行的函数栈;CG() 是编译器状态。这些表都是进程级的静态结构,每个请求进来时被初始化一遍,每个请求结束时被复位一遍。

为什么要这样设计?因为如果不这样,每个请求都要重做一遍代价高昂的准备工作:重新解析一遍配置、重新初始化每个扩展的运行时状态、重新建立错误处理与异常处理的上下文、重新构造序列化器和 session 的默认状态。这些工作与请求内容无关,重复做纯属浪费。把结果留在进程里,下一次直接拿来用,这是所有长驻服务模型的共同起点——不只是 PHP,任何以“常驻”换“吞吐”的系统都会走到这一步。

所以 worker 级全局字段的存在不是疏忽,是优化的必然产物。它的形状通常是这样的:一个进程级的槽位,里面存着一个指针或一个结构体,指向某个“当前生效”的对象。EG(exception) 指向当前未捕获的异常对象,EG(error_reporting) 记录当前生效的错误级别,session 模块有一个全局的会话状态,序列化器有自己的默认配置。这些指针在请求开始时被赋值,指向刚刚在请求内存池里构造出来的东西。

问题就在这里:字段的生命周期是进程的,字段所指向的内容的生命周期是请求的。两者不匹配。

这种不匹配在正常情况下不会出事,因为 request shutdown 会按照一张清单把这些字段一一复位:清掉异常、清掉错误、关掉会话、释放上传状态。清单跑完,字段就不再指向请求内存。设计者的假设很朴素也很合理——每个请求结束时,指向请求内存的字段都会被清干净。

这个假设的脆弱之处,在于它把安全性完全押在“复位代码每次都完整执行”这一件事上。不是“大致执行”,不是“执行到 99%”,是完整。少一步,就有一个字段还指着已经不属于它的地方。

而请求内存池的释放方式,让这个后果格外隐蔽。Zend 内存管理器给每个请求建一块独立的内存池,请求结束时整池释放,而不是逐块 free。整池释放是它最大的性能优势:不用遍历、不用维护空闲链表、不用为每个对象跑一遍回收路径,把整块内存标记为可用就结束了。但它同时意味着没有任何“逐块释放”的时刻可以挂钩。没有人会在某个对象被释放的瞬间,顺手把指向它的全局字段置空。字段不会变成空指针,它只是变成了悬垂指针。

悬垂指针和空指针的区别,是这本书里反复出现的一道分界线。空指针会立刻崩,崩在出错的地方,留下清晰的栈。悬垂指针不会崩,它会安静地指向一块内存,那块内存可能已经被下一个请求的分配器重新分配给了完全不同的对象,也可能还留着上一个请求的字节。读它的人不知道自己读到的是什么。

一句话定性:内存池保证的是“不泄漏”,它从不保证“不指向”。

这里还有一个更细的层次值得说清楚,因为它决定了 109619 为什么难以被发现。悬垂指针本身不构成漏洞,它只是“可能有问题”。要变成漏洞,必须有一次读,而且这次读必须发生在“这块内存已经被释放”之后、“这个字段已经被重新初始化”之前。这个时间窗口在正常路径上是不存在的:request startup 会把字段重新赋值,任何正常的读取都会读到新值。

所以 109619 的全部技术难度,不在于“字段会悬垂”,而在于“有代码会在重新初始化之前读它”。这就要求出现一次顺序异常:复位没做完,或者复位做完了又被重做了一遍。这正是下一节的内容。

4.2 两种产生脏状态的路径:被打断的重置,与被重建的诊断

PHP 官方与 Rapid7 的口径给出了两条路径,表述很简短,但每一条都对应一种具体的工程失误。

第一条:fatal error 在 PHP 重置状态的过程中打断它。

重置不是一个动作,是一串有序动作。request shutdown 里的复位代码按固定顺序执行:先跑析构函数,再清理上传状态,再写 session,再复位各扩展状态,最后释放内存池。这串动作没有事务性,没有回滚,也没有“完成标记”。它靠的是“一路执行到底”这个默认预期。

而 bailout 路径恰好破坏了这个预期。第 2 章讲过,memory_limit 耗尽时 PHP 不抛异常,而是调用 zend_error_noreturn,最终走到 zend_bailout(),用 longjmp 跳出当前执行栈,直接落进 shutdown 流程。这个“跳”会跳过所有中间的清理步骤。如果 fatal error 恰好发生在复位代码自己正在跑的时候,那么复位就被拦腰截断了。

被打断之后的形态是可以推演的:清单上前面的字段清掉了,后面的没清;某个遍历走到一半,剩下的元素没被处理;某个计数器已经减了,对应的指针还在。这和第 3 章的半初始化 bucket 是同一个病,只是换了个器官——109618 是“写了一半的哈希表”,109619 是“清了一半的状态表”。同一个 bailout 机制,一头制造了不该存在的结构,另一头留下了不该存在的引用。

  
/* 复位是一串有序动作,没有事务性 */
  
request_shutdown()
  
  ├─ 调用析构函数
  
  ├─ 清理上传状态
  
  ├─ 写 session
  
  ├─ 复位各扩展状态        ← bailout 若落在这里,后面的全部跳过
  
  └─ 释放请求内存池
  

第二条路径更微妙,也更值得记住:shutdown 之后的“诊断”逻辑,在清理完成之后又把状态重建了。

这条路径的次序是这样的:清理已经跑完,请求内存池即将或已经释放;此时 PHP 需要报告这次 fatal error,为了让人看懂发生了什么,它要输出错误信息、生成 backtrace、写日志。生成 backtrace 需要保留一份调用栈的现场,这份现场要放进某个结构里,而那个结构的句柄,落在一个 worker 级字段上。

于是有了一个荒谬的闭环:诊断代码为了说明“这里出错了”,必须保留出错时的现场;保留现场的方式,是把一个指针留在进程级的槽位里;而那个指针指向的,是马上要被整池释放的请求内存。

这条路径的讽刺意味值得停一下。清理代码和诊断代码各自都是对的,各自都在履行自己的职责,写它们的人也都是为了把系统做得更好——诊断信息是排障的第一手材料,没有它,运维只能看到一个没有上下文的 500。但它们之间的顺序错了。清理在前、诊断在后,而诊断会写状态。一个只读的诊断是安全的,一个会写状态的诊断就成了漏洞的制造者。

一句话定性:清理与诊断的顺序错了,比不清理更糟。

把两条路径放在一起看,会发现一个共同特征,这个特征是理解 109619 的关键:攻击者不需要往任何地方“写”恶意内容。

109618 需要攻击者精心构造字节——空名字段做堆整形、3.5MB 的字段名撞破内存墙、伪造的地址在特定偏移上等待被误认。109619 不需要。攻击者只需要做一件事:让 PHP 自己出一次致命的错。触发一次 memory_limit 耗尽就够了,触发方式是普通的、合法的、任何 Web 应用都可能遇到的——一个超大的表单字段、一段失控的递归、一次意外的内存膨胀。剩下的工作由 PHP 自己完成:它会把状态写到不该写的地方,会把指针留在不该留的地方。

这是 109619 在方法论上比 109618 更让人不安的地方。109618 的输入特征是怪异的,3.5MB 的字段名在正常流量里几乎不存在,检测规则有明确的锚点。109619 的第一个请求可以伪装成一个普通的 DoS 尝试,甚至伪装成一次正常的资源耗尽。异常本身是真的异常,只是它被当成了终点,而实际上它只是第一步。

所以补丁的方向也跟着分叉。109618 的补丁改的是“如何中断”——引入软上限把错误推迟到安全点,越过硬上限就直接 _exit(137) 杀掉 worker,用进程死亡换取不留半成品。109619 的补丁改的是“如何复位”——在 shutdown 期间把请求所属的全局状态彻底清零,并且在早期请求处理能触及之前,就把序列化与 session 的状态初始化好。一个改中断,一个改复位,两者都是把“异常路径”从隐式假设变成显式处理。

4.3 两个可利用案例:fatal-error backtraces 与 upload processing

从“存在脏状态”到“可以命令执行”,中间还差一步:必须有代码去读那个字段,而且读到之后要做一件有后果的事。PHP 官方确认了两个案例,分别对应两种读取者。

第一个案例:fatal-error backtraces,仅在 PHP 8.5 上。

机理是上一节第二条路径的实例化。fatal error 的报告在 shutdown 之后保留了 backtrace 状态;下一个请求进来时,某个配置诊断逻辑会在 PHP 初始化该状态之前读取它。PHP 确认这条路径在 mod_php 上可以导致命令执行。

这里有三个限定词需要逐一交代,因为它们决定了这条路径的实际影响面。

“仅 PHP 8.5”——这是版本维度的收窄。backtrace 的保存与报告行为在版本演进中变过,材料只确认 8.5 在受测范围内成立。要注意的是,这不等于 8.4、8.3、8.2 不受影响:四个分支在同一天都发布了修复版本,说明根因在所有分支上都存在,只是这条具体的读取路径被确认的范围是 8.5。混淆这两件事,会得出“老版本没事”的错误结论,而老版本恰恰是打补丁最慢的那一批。

“配置诊断”——这是读取者的身份。触发点不在应用代码里,而在 PHP 自身检查配置、检查扩展状态的诊断逻辑里。这意味着应用层做任何事情都无法规避它:你可以不调用任何危险的函数,可以不用 session,可以不用上传,但只要 PHP 在处理你的请求时跑到了这段诊断,它就会去读那个字段。

“mod_php 上可导致命令执行”——这是部署维度的收窄。PHP 的确认口径是分部署形态给出的,没有把 FPM 一并写进这条路径。为什么 mod_php 更容易走到命令执行,材料没有展开到指令级,但方向是清楚的:mod_php 是 Apache 的模块,与父进程同体,worker 是 Apache 的子进程,进程内的状态纠缠比独立进程池更深。这条路径的利用链在 mod_php 上闭合了,在别的部署形态上没有闭合。

第二个案例:upload processing,mod_php 与 PHP-FPM 都可以触发。

机理是:fatal error 之后,上传处理留下了 session 或 decoder 状态;下一次上传在正常初始化之前读取了它。需要注意的是最后半句的补充条件——在 FPM 上,还需要应用在启动成功之后执行某个合适的操作。

这个补充条件把利用门槛抬高了一档。在 mod_php 上,脏状态的读取由 PHP 自身的上传处理路径完成;在 FPM 上,需要应用在启动成功后做一件合适的事,才能把这条链闭合。也就是说,FPM 上的 109619 不是纯 PHP 层的问题,它需要应用层提供一个“动作”。这个动作不需要恶意,它可能是任何会触及上传状态或会话数据的正常业务逻辑。攻击者要做的是找到那个动作,而不是写那个动作。

但门槛抬高不等于风险消失。它只是把“所有 FPM 部署”缩小成“有合适应用的 FPM 部署”,而后者在真实互联网上依然是一个很大的集合——任何一个带文件上传功能、并且在上传后做了某些后续处理的应用,都在这个集合的候选范围内。

为什么上传路径天然容易脏?因为上传是 PHP 生命周期里最“多阶段”的一段流程:接收请求体、解析 multipart 边界、把文件落到临时目录、记录进度、与 session 联动、在脚本结束后清理临时文件。每一步都要写状态,每一步都可能成为中断点。而且上传本身就是一个可能被中断的长操作——大文件、慢连接、客户端提前断开,都会让流程停在中间。中断点越多,脏状态的产生面越大。

把两个案例与 109618 放在一起,可以看清这两兄弟在工程属性上的分工。

| 维度 | CVE-2026-109618 | CVE-2026-109619 |

| --- | --- | --- |

| 触发点 | 哈希表插入时“先计数后分配”,一次键分配失败 | worker 级全局字段在请求结束后未被完全复位 |

| 所需请求形态 | 单个请求:空名字段做堆整形、a=1 建表、约 3.5MB 的字段名配数组语法 | 至少两个请求:第一个制造脏状态,第二个读取它 |

| 命中条件 | 撞破 memory_limit 即触发,单请求内确定性 | 第二个请求必须落在同一个 worker 上,是概率问题 |

| 权限 | 未认证远程 | 未认证远程;FPM 上另需应用存在合适操作 |

| 可靠性 | 单次可成功;猜错地址时表现为 worker 段错误 | 依赖复用与概率;pool 越大越需要重试 |

| 补丁思路 | 改中断方式:软上限推迟报错,硬上限 _exit(137) 直接杀进程 | 改复位语义:shutdown 期间清零 error 与 exception 状态,提前初始化序列化与 session |

六个维度里,最值得盯住的是“所需请求形态”和“命中条件”这两行。109618 是一个请求就能完成的事,109619 是至少两个请求之间的事。这一行差异,把 109619 从一个内存破坏问题变成了一个状态机问题——它的攻击面不在某一次请求里,而在两次请求之间的那个缝隙里。而那个缝隙,正常情况下应该被复位代码填满。

4.4 session.upload_progress 的默认开启:一个被忽视的默认值

有一个背景必须单独说,因为它解释了一个矛盾现象:为什么“完全不用 session”的应用也会中招。

session.upload_progress 是 PHP 在上传场景下提供的一个便利特性。它要做的事情很朴素:让应用能在文件还在上传的过程中,向用户显示一个进度条。做法是应用在表单里放一个约定名字的隐藏字段,PHP 在接收上传的过程中,把已接收的字节数、总字节数、当前状态写进会话数据,应用再用另一个请求轮询读出这些数字。

这个特性的实现位置很关键。它必须在目标脚本开始运行之前就启动,因为进度条的意义就在于“文件还没传完的时候就能查”。如果等到脚本跑起来才开始跟踪,上传早就结束了,进度条毫无意义。所以它的执行点在 SAPI 层处理请求体的阶段,在脚本被加载之前。

而它的默认值是开启的。材料对这一点的表述很明确:session.upload_progress 会在目标脚本运行之前跟踪上传进度,默认开启,即使应用从不调用 session_start() 也会运行。

最后半句是整件事的关键。“即使应用从不调用 session_start() 也会运行”——这意味着这个特性的启动不需要应用的任何配合。session 模块在上传处理路径上被自动激活,不需要脚本发起会话,不需要 $_SESSION 被访问,不需要任何一行应用代码。应用作者可能一辈子没听说过这个特性,而它一直在他的每一个上传请求里运行。

后果直接而具体:一个从不使用 session 的应用,一个在架构上刻意保持无状态的应用,一个把“我们不存会话”写进安全白皮书的服务,全部在这条路径的射程内。

这一点真正冒犯的,是我们评估风险时用的那张清单。

常规的应用安全评估是这样做的:先列出应用用到的技术特性,再逐项评估每个特性的风险面。这张清单上有 session、有文件上传、有反序列化、有模板渲染。如果应用不用 session,评估者会在“session 相关风险”一栏写下“不适用”。这个推理在绝大多数情况下成立,因为大多数 session 漏洞确实需要应用真的使用 session。

109619 把这个推理打断了。它说明真正的攻击面不是“应用代码调用了哪些特性”,而是“运行时在处理你的请求时用到了哪些特性”。后者是前者的严格超集,而且大出来的那部分完全不在代码库里。

那部分在哪里?在 php.ini 里,在编译选项里,在发行版的默认配置里,在基础镜像的作者随手留下的一行设置里。代码评审看不到它,静态扫描看不到它,依赖清单也看不到它——因为它不是一个依赖,它是运行时的默认行为。

一句话定性:一个默认值不等于一个决定。

这里要说清楚责任的位置。写出这个默认值的人没有做错什么,默认开启是为了让这个特性可用——一个默认关闭的特性,等于一个不存在的特性。基础镜像的作者也没有做错什么,他沿用上游默认值是最合理的选择。应用作者更没有做错什么,他甚至不知道这件事存在。三方都做了合理的选择,结果是一个所有人都没意识到的暴露面。这不是某一方的失误,这是默认值机制本身的结构性特征:默认值的作用就是让人不必思考,而“不必思考”与“必须评估”天然冲突。

这也解释了为什么临时缓解方案长成那个样子。session.upload_progress.enabled=0 是最直接的一刀,它把这个特性的自动激活关掉。材料对它的评价很克制也很准确:它只切断 109619 的一条路径,也就是 upload processing 这一条;它不阻止 109618,也不阻止 PHP 8.5 上的 fatal-error backtrace 路径。

原因不难理解。backtrace 那条路径跟上传、跟 session 一点关系都没有,它是错误报告机制的副产物,关掉上传进度对它毫无影响。至于 109618,它根本不经过 worker 级字段,它是哈希表的半初始化,是另一个器官上的病。所以这行配置的覆盖面,是 109619 的一半,是全部漏洞的四分之一左右(如果按路径数粗算)。把它当成解决方案,是把一条腿的止痛药当成全身的治疗。

更麻烦的是这行配置怎么才能生效。所有配置层面的缓解,都必须写在 PHP 解析请求之前就已经加载的配置里,并且要让每一个 worker 都重启。这一条限制看起来是运维细节,实际上是致命的:如果只改了配置文件而没有重启 worker,那些还活着的旧 worker 会继续按旧配置运行,而且它们身上可能已经带着上一轮留下的脏状态。改配置这个动作本身,不会清理任何已经存在的脏状态。第 9 章会展开这条线索,这里只留一句:配置缓解的正确性问题,比配置缓解的有效性问题更难。

4.5 概率问题:worker pool 越大越难打,但漏洞不消失

109619 有一个 109618 完全没有的属性:它是概率性的。

材料对这一点的表述只有一句话:攻击者的请求必须打到同一个 worker;worker pool 越大,单次尝试命中脏状态的概率越低,但漏洞不消失,只是需要更多请求。

这句话值得展开成工程语言:“概率性”三个字在报告里常被读成“不太严重”,在实际攻防里却意味着完全不同的东西。

先看概率从哪里来。脏状态存在于某一个具体的 worker 进程的内存里,它是那个进程的私有财产。攻击者的第二个请求如果被分派到另一个 worker,那个 worker 的内存是干净的,什么也不会发生。所以两个请求必须落在同一个进程上。

而进程池的分派方式决定了这个概率的量级。常见配置下,一个 PHP-FPM pool 有几十个 worker,容器化部署里可能是个位数,Apache 的 prefork 模型下也是类似的数量级。如果分派近似均匀,第二个请求命中第一个请求所在 worker 的概率,大致是 worker 数量的倒数。

但这个数字还不是成功率。真正的成功率是三个概率的乘积:第一个请求是否真的制造出了脏状态;第二个请求是否落在了同一个 worker;落上之后,是否真的走到了那个会读脏字段的代码路径。三个环节任何一个断掉,整条链就断了。

这就给了攻击者三种可以调的手段,每一种都对应不同的成本结构。

第一种是重试。一次不行就再来一次,成本线性增长。这是最笨也最可靠的办法,对自动化工具来说几乎没有成本——它只需要把“发送两个请求”循环 N 次。这也是为什么概率性漏洞在自动化时代变得危险:人工做这件事,重试二十次是二十次的时间成本;机器做这件事,重试二十次只是把循环上界从一改成二十。

第二种是广度。并发地把请求铺开,让多个 worker 同时被污染,然后逐个探测。这一步的意义不在于提高单次命中率,而在于把概率问题转换成覆盖问题:不去赌“这一次能中”,而是先把 N 个 worker 都弄脏,再对着 N 个 worker 各发一次读取请求。代价是请求量翻倍,收益是不确定性大幅下降。这是从“抽奖”到“扫地”的转换,而扫地的成本是可以预算的。

第三种是等,但这个手段有反作用。制造脏状态需要一次 fatal error,而 fatal error 会让 worker 进入异常状态,甚至直接死掉,池子会补一个新的进来。攻击者刚污染的那个 worker,可能在他发第二个请求之前就已经被回收了。也就是说,攻击者要跟进程池的生命周期赛跑。

这场赛跑的另一面是防守方的礼物:worker churn 本身就是检测信号。

一个正常的 PHP 服务,worker 会因为超时、内存超限、部署重启而轮换,但轮换率是稳定的。如果某个服务的 worker 重启率突然异常升高,配合日志层出现的 Allowed memory size ... exhausted ... in Unknown on line 0——注意 Unknown on line 0 这个细节,它表示失败发生在请求初始化阶段、任何脚本执行之前,正常业务代码几乎不会产生这种形态——再接上 Apache 的 AH00052: child pid ... exit signal Segmentation fault (11),这三层关联起来,就是一条相当有指向性的证据链。第 10 章会讲这条链怎么被做成流水线,这里只需要记住:109619 的检测不能靠单请求特征,因为它的单个请求看起来是普通的资源耗尽,必须跨请求、跨进程地看。

这也回到一个更根本的判断。在安全工程的语境里,“概率性”经常被当成一种缓冲:因为它不是每次都成功,所以可以降一级。这个降级在人工攻防时代是合理的,人的时间贵,一个需要几十次尝试才有一定成功率的漏洞,在渗透测试报告里往往被写进“低危”那一栏。但它在一个自动化工具可以把重试成本压到接近零的时代,就不再合理了。

概率只影响尝试次数,不影响风险等级。这句话的推论是:概率性防护的有效性,取决于攻击者的单位尝试成本。当那个成本趋近于零时,概率就只是一道算术题,而不是一道屏障。

但概率也不是毫无意义的变量。它让 109619 在野外的利用更可能以“大量请求、长时间、低噪声”的形态出现,而不是一次精准打击。这对防守方是双面的:量大更容易被统计发现,单点规则却抓不到。所以它既不是好消息,也不是坏消息,只是一个新的检测假设。

4.6 补丁的语义:在 shutdown 期间把状态清零

补丁的口径很短:在 shutdown 期间重置请求所属的全局状态;清除 error 与 exception 状态;在早期请求处理能触及之前,初始化 serialization 与 session 状态。

三个动作,两个方向。

“清除 error 与 exception 状态”是往后清。它对应的是 4.2 节的第一条路径和第二条路径:fatal error 之后留在 EG() 里的异常上下文、错误上下文、backtrace 现场,全部在 shutdown 期间被抹掉。抹掉之后,下一个请求进来时,那些字段不再指向任何东西。这一步的语义是“不留”。

“在早期请求处理能触及之前,初始化 serialization 与 session 状态”是往前挪。它对应的是“读取发生在初始化之前”这个问题——不是把读取点删掉,而是让初始化点跑到所有读取之前去。这一步的语义是“先建”。

为什么用“提前初始化”而不是“延后读取”?因为读取点分散在扩展代码的深处,是一个配置诊断、一段上传处理、某处序列化调用,改动它们要动很多处,每一处都要重新审视上下文,风险大而收益不确定。把初始化点前移,改的是一处,覆盖面却是全部。这是一个典型的收敛式改法:不动读者,动作者的时序。

这个选择和 109618 的补丁是同一种风格。109618 的补丁没有去改哈希表“先计数后分配”的顺序,因为那是核心数据结构的语义,改动会波及整个引擎;它改的是内存耗尽时如何中断——软上限把错误推迟到安全点,硬上限直接 _exit(137) 杀掉 worker。两个补丁都在做同一件事:绕开语义层面的改动,去调整边界条件的时序。

但也要说清楚这个补丁的边界,否则会误读它的强度。它堵的是“已知的读取点”,而不是“让脏状态不可能存在”。彻底的做法有两条路,都走不通或者代价太大:一条是让复位不可被中断,那要动 bailout 的语义,影响全局的错误处理模型;另一条是给每个 worker 级字段加一个“是否已初始化”的状态位,让每次读取都先检查,那要在最热的路径上加判断,性能与内存的代价都不小。这两条都不是能在一个补丁窗口里完成的事。第 11 章会讲这个取舍为什么必然存在。

到这里,本章的技术部分讲完了。但 109619 值得单独占一章,不是因为它难,而是因为它是一个通用工程问题的极端样本。这个问题叫状态机复位。

所有以“复用”换“吞吐”的系统,都要在两次使用之间做一次复位。PHP-FPM 复用进程,应用服务器复用线程,数据库连接池复用连接,浏览器复用渲染进程,容器运行时复用底层资源。它们的共同结构是:一个长生命周期的载体,加一个短生命周期的租户,中间夹一段复位代码。

这段复位代码有四个共同的脆弱点。

第一,复位是一串有序动作,不是原子操作。它有中间状态,而中间状态是不合法的。任何在这个窗口内的中断、超时、异常、外部信号,都会把系统留在一个“半清理”的位置上——前半段已经清了,后半段还没有。这就是 109619 的形态。

第二,复位点选在“正常结束”。但结束从来不止一种方式。正常返回是一种,抛异常是一种,被 fatal error 打断是一种,超时被外部杀掉是一种。复位代码通常只在第一种方式上被充分测试,其余三种都依赖“反正进程要死了”这个假设。109619 证明这个假设不成立——进程不一定死,它可能继续接下一个请求。

第三,复位失败没有即时反馈。这是最要命的一点。复位没做干净,出问题的不是当前租户,是下一个租户。当前请求顺利结束,日志干净,监控平稳,一切看起来都对。受害者在一个完全无关的时间点、以完全无关的症状出现。这意味着复位缺陷在测试环境里几乎不可能暴露——单元测试不复用进程,集成测试的并发度不够,CI 里的进程活不过几个请求。它只在生产的高负载下、在进程活得足够久的时候才显形。

第四,症状是“随机、不可复现、只在高负载下出现”。这是运维最难定位的一类问题。重启一下就好了,压测一下又不出现,日志里只有一行内存耗尽的报错。109619 之所以要靠 AI 辅助审计才能在两年里被真正看清,有一部分原因就在这里:这不是一个读代码就能看出的“缺失检查”,这是一个要靠时序推理才能看见的“顺序缺陷”。

所以工程上的正确方向,不是“更小心地写复位代码”。小心是一种人力投入,而人力在复位这件事上并不可靠——它会随着代码演进、人员流动、需求压力而衰减。可做的有三件事。

一是减少需要复位的东西。把状态尽量放进请求作用域,让它在请求结束时随内存池一起消失,而不是留在进程级字段里等下一次复位。这需要付出性能代价,因为它意味着放弃“一次构造、多次使用”的优化。这是一个真实的取舍,不是一个免费的改进。

二是让复位失败变成显式失败。109618 的 _exit(137) 就是这个思路的样本:宁可杀掉进程,也不在一个可能无效的状态上继续跑清理流程。杀进程是一种粗鲁但诚实的处理方式,它把“状态可疑”转换成了“进程没了”,而后者是监控能看见的。

三是在关键场景里回到一请求一进程。serverless 与短生命周期容器做的就是这件事,它把这类风险换成了冷启动成本。要强调的是,这是交换,不是进步——它消灭了一类缺陷,同时引入了另一类代价。

一句话可以收在这里:一个系统对脏状态的免疫力,取决于它有多少东西需要复位。

本章的结论不是“PHP 的状态管理写得很差”。恰恰相反,PHP 的状态划分在同类运行时里算得上清晰:请求级与进程级的边界是明确的,复位清单是存在的,问题出在清单的执行没有被当作安全属性来对待。换成任何一个用同样方式换取性能的运行时,只要它把“复位一定完整”当成默认前提,就会长出同一类缝隙。109619 只是第一个被大规模看见的样本。

十年之洞的这个“洞”,在 109619 这里有了第二层含义。109618 的洞在一个数据结构里,看得见摸得着,能画出内存布局。109619 的洞在两次请求之间,在那段本应把一切擦干净的代码里,它没有形状,只有时序。前者是一个可以被审计的对象,后者是一种需要被推理的关系。

所以真正需要记住的不是“worker 级字段很危险”,而是“任何一次异常中断,都可能让复位不完整”。这一句比漏洞本身活得更久。

而这套推理之所以在 2026 年被大规模地跑通,是因为它面对的不是一行代码,而是一整个运行时的时序——这正是需要机器去遍历、去假设、去反复试错的那类问题。至于代价落在谁身上:当一个洞位于语言层而不是应用层,受影响的范围就不再由应用决定,而由语言的渗透率决定。那个“约 70% 的互联网”的数字,到底是怎么被算出来的,乘数有几层,每一层的口径差在哪里——这是第 5 章要算的账。

第 5 章 影响面的算术:70% 是怎么算出来的

“70% 的互联网受影响,PHP2Shell 的影响无法被夸大。它标志着 AI 的拐点。”

——rdintel 对事件的评价(媒体转述口径)

本章要回答的问题是:当一条安全公告说“70% 的互联网”,这个 70% 是谁算的、按什么分母算的、它的误差可能有多大。这个问题看起来只是统计细节,实际上是决策细节——所有关于“要不要停掉网站”“要不要抽调一半人力”“要不要把预算从别的项目挪过来”的判断,都建立在某个数字上。如果这个数字的口径没被说清,后面的每一次决策都在放大误差。

我的判断是:影响面从来不是一个数字,而是一组叠加的乘数——语言渗透率乘 CMS 集中度乘托管形态乘 EOL 债务乘资产可见性。其中任何一项被低估,风险预算就会错得离谱。而这一次,被低估的恰恰是最难看见的那两项。

5.1 从语言份额到暴露主机:三层估算的口径差异

在公开报道里,有三个数字被反复引用,而且经常被混在同一句话里:约 70% 的互联网暴露、PHP 长期占据服务端语言约 70%–77%、WordPress 驱动约 40% 的网站。看上去它们互相印证,实际上它们的分母、统计单位和误差来源全都不一样。混用这三个数字,是这次事件里最普遍的一次分析失误。

第一层是 Wiz 的暴露面估计。Wiz 是安全厂商,它的口径接近“技术指纹抽样 + 推断”:通过 HTTP 响应头里的 X-Powered-By、Cookie 名称(PHPSESSID 是最典型的)、默认路径、favicon 哈希、报错页特征等信号,判断一台主机的服务端语言,再从样本里外推到整个可观测互联网。这个方法有三个天然偏差。其一,样本是有响应的主机,不响应、只在内网、藏在反代后面的主机不在样本里,所以它测的是“可被动识别的部分”。其二,指纹只能识别“留下了指纹的”,把 X-Powered-By 关掉、把 PHPSESSID 改名、把报错页替换成一个空白页,在统计上就不存在了。其三,它的目标是“暴露面”,不是“可利用性”——一台被算进 70% 的主机,可能因为配置原因完全打不进去,也可能早就打了补丁。因此这个数字的合理读法是:在 Wiz 能观测到的那部分互联网里,约七成主机呈现出 PHP 特征。它是一个量级提示,不是一个统计量。

第二层是 W3Techs 类的语言份额,约 70%–77%。它的方法很不一样:取一份站点排名列表(通常是前 1000 万站点的量级),逐站抓取首页,用响应头、Cookie、HTML 结构判定服务端技术,然后按站点数量计算占比。这个方法的问题在分母——“站点数”不是“主机数”,也不是“流量”或“资产价值”。一个个人博客和一家银行的核心业务系统,在这个口径里权重完全相同。它还只抓首页,看不到那些只有 /admin 目录才由 PHP 渲染的站点;子域归并、站点去重、CDN 遮蔽都会显著影响结果。它是一份极有价值的长期趋势指标,但它衡量的是“语言驻留面积”,不是“受影响主机数量”,更不是“脆弱资产数量”。

第三层是 WordPress 驱动约 40% 的网站。它同样基于站点排名列表,用 wp-content、wp-includes 路径、meta generator 标签、/wp-json 接口等特征识别,单位仍然是站点。它的偏差集中在三处:这类统计会把大量小站、个人博客、活动页、已经无人维护的站点一起算进来;托管平台批量部署的站点会让同一套代码被计数很多次;它完全不区分站点是否承载交易、是否存有用户数据。所以“40% 的网站跑 WordPress”和“40% 的有价值资产跑 WordPress”是两句完全不同的话。

三层之间最关键的一点是:它们不可相乘。有人会写“70% 的互联网是 PHP,其中 40% 是 WordPress,所以 28% 的互联网面临最高风险”——这个算法是错的,因为 40% 的 WordPress 站点本身就是那 70% 的子集,两者是包含关系,不是独立事件。相乘的前提是独立,这里恰恰不独立。

那么这三个数字应该怎么用?我的用法是:把它们当作三个不同方向上“面很大”的证据,而不是一个可以代入预算公式的百分比。Wiz 的约 70% 说明“在可观测范围内这一类占多数”;W3Techs 的 70%–77% 说明“这门语言的驻留面积十几年没有缩小”;约 40% 说明“其中一大块是同一份代码”。三条合起来的结论是影响面在“互联网的多数”这一量级上,而不是某个具体到小数点的比例。

需要说明的是,这三个数字都不包含“已经修好”的信息。补丁发布之后,暴露面不会自动缩小——指纹还在,版本可能已经更新。这正是为什么在事件后期,同一批数字会被反复引用却越来越没有解释力:它描述的是事件开始时的地形,不是事件结束时的战果。

一句话定性:70% 不是一次测量,是一个量级判断在传播中被压缩成的一个数字。

5.2 为什么是“语言层漏洞”而不是“应用层漏洞”

前面三层的口径差异,还只是“面有多大”的问题。这一节要说的是更根本的一件事:这次的受影响集合,定义方式变了。

我们习惯的应用层漏洞,触发点在应用代码里。一个插件忘了校验参数、一个控制器把用户输入拼进了反序列化调用、一个上传接口没有检查扩展名——这类漏洞的共同点是:要触发它,必须先走进应用的业务逻辑。因此防护手段是对得上的:加认证、加输入校验、写一条针对该参数的 WAF 规则、把那个功能关掉。这些措施在应用层有效,因为漏洞也在应用层。

语言层漏洞完全不同。触发点在解释器处理请求的过程里:SAPI 解析请求体、填充超全局变量、往哈希表里插入字段名与字段值、分配请求级内存池、撞破 memory_limit、走 bailout 长跳转、进入 shutdown。这些动作全部发生在应用代码被加载进执行器之前。Datadog 的表述很直接:应用层认证无法阻止攻击,PHP 在任何应用代码运行之前就处理了恶意请求,只有打补丁才能修复。

这就是为什么“没有 session、没有上传功能、代码只有十行”的应用也在射程内。一行 <?php echo "hello"; 的页面,仍然是 PHP 在解析请求、仍然是那张哈希表在接收表单字段、仍然有请求级内存池与 memory_limit。攻击者不需要这个页面提供任何功能,他需要的是那个进程在解析请求时留下了半初始化的 bucket(第 3 章),或者在两次请求之间留下了脏状态(第 4 章)。应用写了什么,决定的是它会不会顺手把攻击面放大,而不是它是否在射程内。

具体到两个漏洞,路径可以这样对应:109618 依赖表单解析——字段名长约 3.5MB 撞破内存上限,于是任何接受 application/x-www-form-urlencoded 或 multipart 请求的入口都在射程内,而“接受 POST”几乎是所有 PHP 部署的默认状态。109619 有两条路径,一条是 upload processing,关闭 session.upload_progress.enabled 可以切断;另一条是 fatal-error backtrace,只要应用会抛错、会有异常处理,这条路就存在,而且关闭上传进度对它没有作用。换句话说,清理掉所有“用不上的功能”之后,射程内仍然有东西。

这件事还有第二层含义,针对的是“我可以先关掉点什么”这个直觉。在应用层,关掉功能是有效的:把上传接口下线、把评论区块注释掉、把某个参数从白名单里删掉,风险就真的少了。在语言层,这个直觉对应的动作是改配置,而配置缓解这次被逐条否掉了。会话上传进度的开关只切断 109619 的一条路径,对 109618 与 8.5 上的 fatal-error backtrace 路径都没有作用;把 memory_limit 设成负一只是让 OOM 不发生,攻击者换一种方式耗尽系统内存即可,而且这样做还会破坏主机稳定性;用 RLIMIT_AS、RLIMIT_DATA 替代更不行,因为那类限制触发的分配失败,正是漏洞所依赖的条件。所有配置缓解还必须写在“PHP 解析请求之前”就加载的配置里,并且重启每一个 worker 才生效——这意味着它既要求你摸到那台机器,也要求你重启它。Cisco 公告写“no workarounds available”,不是措辞谨慎,而是逐条试过之后的结论。

这条性质改变的不是“有多少应用受影响”,而是“受影响集合的定义方式”。应用层漏洞的受影响集合是可枚举的:有依赖清单、有 SBOM、有组件版本表,你可以列出“用了 Log4j 2.14.1 的服务”然后逐个修。语言层漏洞的受影响集合不可枚举,因为它不是“你装了什么”,而是“你在跑什么”。要枚举它,你必须知道每一台主机上每一个 PHP 进程的版本,包括那些没有资产编号的、没有负责人名的、没有人记得存在的。

这也解释了这次事件里一个让人不适的细节:没有任何一份“受影响组件清单”可以贴出来。清单本身就是“所有 PHP 进程”——这句话在技术上是精确的,在管理上是无用的,因为它不能帮你排出优先级。

不过要说清一点:射程内不等于同等脆弱。素材明确写了 mod_php 风险最高,因为它与 Apache 父进程同体,执行上下文与权限继承方式让利用更直接;PHP-FPM 次之;php-cgi、FrankenPHP、LiteSpeed 各有差异。同样是语言层漏洞,部署形态会决定“打进去之后剩多少空间”。这正好是下一节和 5.4 要接的地方。

一句话定性:应用层漏洞问的是“你装了什么”,语言层漏洞问的是“你在跑什么”。

5.3 集中度风险:WordPress、Drupal、Joomla 与长尾 CMS

语言层漏洞的射程覆盖所有 PHP 进程,但攻击者不会均匀地打。他会挑投资回报率最高的那一块,而那一块由集中度决定。

先看头部。WordPress 驱动约 40% 的网站(站点口径),这是任何攻击者都熟悉的生态:目录结构公开、插件生态庞大、扫描模板现成、批量利用工具成熟。素材记载,Wiz 在 WordPress、Drupal、Joomla 的最新容器镜像上演示过未认证 RCE;时间线里留下的两个日期是 8 月 20 日确认 Drupal、8 月 21 日确认 WordPress。这里要区分两件事:演示能打通,说明这条链条真实存在;但演示不说明“每个 WordPress 站点都会被优先攻击”,因为攻击者面对的是一个约束条件下的选择问题——流量多大、权限多高、能不能横向、有没有数据可拿。

集中度的好处和坏处是一枚硬币的两面。好处是修复的规模经济:同一份代码,一个补丁能覆盖大量用户。Chainguard 在 10 月 21 日 07:41 UTC 单方面打破 embargo 提前发布修复镜像,这件事在披露伦理上有争议,但它确实展示了集中度的正面:一次构建,同步覆盖所有用这个基础镜像的部署。坏处是攻击的规模经济:同一份代码,一个利用也能一次打穿大量目标。攻防双方拿到的是同一个杠杆。

再看中间层。Drupal 与 Joomla 的用户量远小于 WordPress,但用户画像不同——Drupal 常见于政府、高校、公共服务机构的门户,Joomla 常见于中小企业与地区性站点。这类站点的特点是“数量少、单点价值高、外包维护、内部无人懂 PHP”。它们的修复往往要过一个外包合同周期,而不是过一个运维窗口。

Magento 这一层要单独说,因为它直接连钱。电商系统里有支付凭证、订单数据、会员信息,被攻破的后果不是“首页被改”而是“数据被卖”。它的技术栈也更重(依赖多、扩展多、升级窗口紧),所以“打补丁”这件事在电商语境里的阻力比内容站大得多:任何一次升级都要评估下单链路。

Laravel 生态的特殊性在另一个方向。Laravel 本身不解决这次漏洞,但它代表了大量“自研 + 框架”的 PHP 应用,而这类应用的部署形态里有一类特别值得注意:队列消费者与常驻命令,即 php artisan queue:work 这类进程。它们长时间驻留、连续处理任务,恰好和 worker 复用模型同构。如果脏状态可以在请求之间存活,它在任务之间也有可能存活——这是我从模型出发的推断,素材没有直接证据,但它指出了“非 HTTP 的 PHP 进程也要一起排查”这个结论。同时,Laravel 生态的依赖极深(Composer 锁文件、几十个传递依赖),这意味着“升级 PHP 版本”在真实项目里不是换一个标签,而是一次兼容性工程。

最后是长尾,也就是真正决定修复完成率的那一部分。长尾包括国产论坛与建站程序、企业 OA 与 ERP 的 PHP 前端、各类表单与问卷系统、老旧的图片站与下载站、以及数量巨大的自研小系统。它们的共同点是:不在任何主流安全工具的默认扫描模板里,没有安全团队,没有 CVE 订阅,没有升级路径——它们的“升级”通常等于“整体迁移”,而迁移项目的周期是季度到年。

把头部和长尾放在一起看,能得出一个不对称的结构:集中度决定了攻击者的投资回报率,长尾决定了修复的完成率,而这两个方向正好相反。头部代码统一,攻击划算,修复也划算;长尾代码分散,攻击单个不划算,但修复它更不划算。于是修复完成率曲线会长成一种熟悉的样子:头部在两周内跑完,中段拖一个季度,长尾永远停在原地。

一句话定性:集中度让攻击者省力,也让头部用户获救;长尾既不被攻击者优先选择,也不被补丁优先照顾。

5.4 托管与镜像:共享主机、面板、容器基础镜像、Serverless PHP

如果 5.3 说的是“谁在射程内”,这一节说的是“谁有权限按下修复那个按钮”。答案是:在相当多的场景里,运行 PHP 的人和有权升级 PHP 的人不是同一个人。

共享主机是最典型的一层。一台物理机上跑着几百个站点,PHP 版本由主机商决定,租户只能选、不能升。主机商要升级,先要过兼容性测试——上面跑着上百个客户的老站点,任何一次小版本跳跃都可能让某个站点的旧代码直接白屏。所以主机商的真实升级周期通常按季度甚至按年计,而不是按天。这次补丁发布在 10 月 21 日午后(UTC),而大量共享主机上的 PHP 会在接下来的几个月里保持原样。这不是不负责任,这是商业模型决定的:主机商的成本结构里,“不弄坏客户的站”优先级高于“最新版本”。

控制面板是共享主机的操作界面。cPanel、Plesk 这类面板提供 PHP 版本切换能力,这既是好消息也是坏消息。好消息是租户可以自己切到修补版,不必等主机商。坏消息是面板本身默认停在某个旧分支,而且面板往往允许多个 PHP 版本共存——这意味着同一台机器上可能同时跑着三套 PHP,补丁要打进每一套里,任何一套漏了,那台机器就还是活的。多版本共存本来是为了兼容性,在这次事件里它变成了一个容易漏项的清单。

容器基础镜像的语义完全不同:镜像不可变,你不能给一个镜像“打补丁”,只能重新构建、重新部署。这条差别经常被低估,因为很多人以为容器化之后修复会更快。事实上,修复时间等于重建时间加依赖兼容时间加灰度时间加回滚预案准备时间。若你用的是别人维护的镜像,这条链路可以很快(Chainguard 就是例子);若你自建镜像,这些工作量全部要自己承担。

(这一点对“浮动的标签”是致命的,值得放在行内代码里强调:FROM php:8.4-fpm 会自动跟着上游走,FROM php:8.4.20-fpm 则把版本钉死。生产环境为了构建可重复性普遍选择钉死,于是补丁不会自己进来,必须有人主动改那一行。)

Serverless PHP 运行时是这次最不透明的一层。素材把 FrankenPHP 列为受影响部署形态之一,也把 Serverless PHP 运行时(如 FrankenPHP 方案)单列为一种托管形态。它的特殊性在于:你看不见进程。worker 复用由平台决定、实例由平台重建、日志由平台采集,你既不能 ps 也不能 gdb。如果平台不更新运行时,租户连确认自己是否受影响都做不到,只能等供应商发公告。素材没有给出各 Serverless 平台在这次事件中的具体更新行为,这一层目前是空白,我在本章里只标注它是“待确认的高风险层”,不替它下结论。

最后是 CDN 与 WAF。它们也在请求路径上,位置比 PHP 更靠前,所以“能不能拦”这个问题的答案不是简单的“不能”。要分成三层看。

第一层是可见性。检测这次利用需要看到请求体里的超长字段名(约 3.5MB)和空名字段的二进制值,而 WAF 出于性能考虑常常对大请求体做截断或直接丢弃。素材在检测建议里专门写了一句:需要确认 WAF 不会截断或丢弃大 body 中的字段。这句话本身就是承认——“我拦住了”和“我没看见”在告警上是同一个结果。

第二层是规则的时间性。规则要精确描述攻击输入,前提是有人已经写出了 PoC 并公开了形态。而这次的时间线是:编号在前一天流出,PoC 视频被误发又扩散,补丁发布后细节公开讨论。也就是说,规则的最优形态出现在补丁发布之后,而补丁发布之前的那个窗口,恰恰是最危险的窗口。

第三层是性质。Cloudflare、Fastly、Google、AWS、Microsoft、Akamai、Imperva、Vercel 提供的都是“指引”或“开箱缓解规则”。缓解不是修复。缓解的意思是:你暂时没有被打穿,但你仍然是脆弱的,只要攻击者换一个编码方式、换一条路径、或者等到有人把利用精炼到不触发规则的程度,你就回到原点。而 Cisco 在公告里写的那句话,把这层意思说得最直白——no workarounds available。

一句话定性:托管形态决定了谁有权按下那个按钮,而绝大多数租户的手里没有这个按钮。

5.5 EOL 债务:约一半部署没有官方补丁

素材里有一条第三方估算:约一半的 PHP 部署运行在已经停止支持的分支上。这个数字的方法并不透明(谁统计的、样本多大、怎么判定“部署”),所以我把它的地位写清楚——它是第三方估算,不是官方统计。但即使打折看,它指向的问题是真的,而且它是本章五个乘数里最容易被管理层忽略的一个。

为什么最容易被忽略?因为它不出现在任何一张现成的表里。资产清单通常按“业务系统”记,不按“运行时分支”记;CMDB 里写的是“官网系统:PHP”,不是“PHP 8.0.x,2023 年 11 月已 EOL”。风险登记册里写的是“高危漏洞需在 30 天内修复”,而不是“你有 187 台主机的运行时已经三年没人管”。EOL 是一种“没有事件的事件”:它不出故障、不发告警、不占预算,只在某一天突然变成全公司的成本。

这次 PHP 官方的做法是给 7.4、8.0、8.1 提供最小的源码 backport。这是一个善意但有限的动作,限制有三重。

其一,backport 是源码,不是二进制包。要把它变成可运行的修复,得自己编译,而运行 EOL 版本的组织通常恰恰没有编译 PHP 的能力——他们当初选 EOL 版本,就是因为不想维护这套东西。

其二,发行版有自己的节奏。Debian、Ubuntu、RHEL 的 PHP 包由发行版维护者 backport,覆盖速度不一;有的发行版会把这个修复并进安全更新,有的要等下一个点版本;商业发行版与虚拟主机厂商又各有各的排期。于是同一句“PHP 已发布修复”,在不同组织那里对应的落地时间可能相差几周。

其三,最小 backport 只包含针对这两个漏洞的改动,不包含该分支上的其他修复。把它打上去,等于在一个本来就缺乏维护的代码基上再叠一层非官方改动,兼容性风险由使用者承担。

所以 EOL 债务的实质不是“没有补丁”,而是“有补丁但没有分发渠道”。这是一个供应链问题,不是技术问题。它也无法用钱一次性解决——你可以买支持服务,但你买不到“知道那台主机在哪”。

下面做一次算术演练。这里所有数字都是假设,目的是让乘数之间的关系可见,不是给出统计结论。

假设一:公网可识别的 PHP 主机为 100 万台。这个基数取自媒体估算的“超百万台公网 PHP 主机存在潜在风险”,我取整为一千万的下一位,以便计算。

假设二:单台主机平均修复人工为 30 分钟,包括确认版本、备份、执行升级、回归验证。这个假设偏乐观——对单应用单机的小站勉强成立,对跑着多应用、多扩展、有自定义编译选项的企业环境,30 分钟连备份都不一定够。

乘法:100 万台乘 30 分钟等于 3000 万分钟,等于 50 万工时。

把它换算成人:50 万工时除以 8 小时,等于 6.25 万人日;若要求在 5 个工作日内完成,需要约 1.25 万人同时投入。这个数字的现实含义是:没有任何一个组织能靠加班解决它,只能靠分级——先修暴露的、先修有数据的、先修能横向的,剩下的排队。

再加上 EOL 与自建镜像的额外成本:假设其中一半主机(50 万台,对应“约一半部署 EOL”这一第三方估算)需要额外 4 小时用于编译 backport、重建镜像、做兼容测试,那么再加 200 万工时,总计约 250 万工时。这是假设推演,不是统计。

这组数字最值得注意的地方不是总量,而是结构:在 30 分钟里,真正执行升级的动作可能只占 3 分钟,剩下 27 分钟都在“决定怎么修”和“验证没修坏”。这和全书的三成本框架是同构的——修复成本的主体从来不是动作,是判断。换掉任何一个假设,总量会同阶变化(比如把人工改成 15 分钟,总量减半),但“人工与判断占绝对多数”这个结论不会变。这也是为什么我在本章开头说,影响面是一组乘数:乘数之间不是加法关系,你把任何一个乘数从 1 改成 0.5,总量就砍一半;你把它从 1 改成 2,预算就直接爆掉。

一句话定性:EOL 债务不会在事件当天产生成本,它在事件当天变成成本。

5.6 看不见的资产:内网、固件、CI 镜像、被遗忘的测试环境

前面五节讨论的都是“能数出来的东西”。这一节讨论数不出来的,因为这一次真正决定损失分布的,很可能是它们。

内网系统。它们不暴露公网,所以不在 Wiz 的样本里,也不在“70%”的分母里。但语言层漏洞的价值在于它是最省事的一段执行路径:一旦攻击者通过边界设备、VPN、钓鱼、或者一次 SSRF 进入内网,任何一台跑着旧版 PHP 的内部系统就是现成的执行点与横向跳板。而内网系统的补丁节奏通常比生产更慢——它们没有对外承诺,没有例行维护窗口,很多连负责人都不明确。

固件与嵌入式管理界面。路由器、NAS、打印机、监控录像机、工控 HMI、门禁控制器,其中相当一部分的管理界面是用 PHP 写的。这类设备的 PHP 版本通常极老(远早于 5.4 之后的所有分支更新),厂商的商业模式不包含为五年前的型号发固件,用户的实际选择是“换设备”。这一层构成“永远不修”的部分:它不在资产清单里、不能打补丁、通常也不能下线,因为下线意味着某扇门打不开。

CI 镜像。CI 里跑 phpunit、composer install、静态分析,用的也是 PHP 镜像。如果 CI 会对不可信输入运行代码——最典型的是对来自外部的合并请求跑测试——那么 CI 本身就是一个可达的攻击面。这是我从模型出发的推断,素材没有这次事件中的 CI 侧案例,但它是一个必须提出的问题:修完生产环境不等于修完所有 PHP 进程。

被遗忘的测试环境。staging、demo、旧版灰度、上一个外包团队留下的验证机、为了某次活动临时开的域名。它们的共同特征是:域名还在解析、证书还有效、没有 WAF、没有监控、PHP 版本比生产更旧(因为没人愿意为测试环境做升级)。这是最容易成为首杀目标的一类资产,因为它同时满足“可达”和“无人看”。

历史流量中的痕迹。漏洞的影响回溯到 PHP 5.4(2012 年,Wiz 口径;CERT-EU 的口径是 PHP 5 及之后均可能受影响,视为风险估计)。十余年的窗口意味着,如果历史上真的有人利用过,你可能没有任何证据:素材明确写了,精炼后的利用返回 HTTP 200、worker 不崩溃,所以不能依赖状态码或演示里的命令名做判断。日志里可能什么都没有,进程列表里可能什么都没留下。这就形成一个不可证伪的空白——你不能证明被利用过,也不能证明没有被利用过。

有了这一节,就可以做本章要求的反证了。

先说为什么“70% 受影响”可能高估。第一,WAF、反向代理、负载均衡确实挡住了大量自动化扫描与低质量利用,虽然这只降低“被打穿的概率”,不改变“脆弱”这个事实。第二,EOL 版本虽然有漏洞,但利用条件受配置影响很大:memory_limit 的大小决定撞墙难度,是否开启 upload progress 决定 109619 的一条路径是否存在,worker pool 的规模决定跨请求命中的概率,是否 8.5 决定 fatal-error backtrace 路径的可用性。配置差异会让“运行 PHP 的主机”和“可被实际利用的主机”之间出现一个明显的落差。第三,“70%”的分母是 Wiz 可观测的主机,不是全部资产,这个分母本身就偏向“有 Web 入口、留了指纹”的那一类。第四,还有一大批 PHP 进程根本不接受外部请求体——CLI 脚本、cron、队列消费者、批处理任务。它们运行 PHP,但它们没有 HTTP 入口;把它们算进暴露面,是口径上的重复计算。

再说为什么也可能低估。第一,不可见资产完全不在任何扫描样本里,而这部分恰恰是补丁覆盖率最低的。第二,镜像层的检测有天然滞后:扫描器看到的是运行中的容器和它的标签,而基础镜像里的实际版本可能早就与被标签描述的不一致。第三,“约一半 EOL”是第三方估算,方法不透明,很可能低估——因为 EOL 部署的定义本身就依赖清单完整性,而清单完整性正是这里最缺的东西。第四,“没有被利用”这个判断建立在“没有告警”上,而告警能力依赖你事先打了检测规则;没有规则的时候,攻击留下了痕迹但你没有眼睛。第五,影响回溯到 2012 年,而多数组织的日志留存是 6 到 12 个月,资产清单覆盖的是近五年。

这两个方向的偏差不是对称的,也不应该互相抵消。高估的部分主要在“数量”,可以通过扫描修正;低估的部分主要在“性质”,无法通过扫描修正。因此正确的读法是:约 70% 是暴露面的量级,不是受害者的比例;真正决定损失的,是那部分不可见、不可修、不可查的资产。

一句话定性:能统计的都在修,不能统计的都在等。

5.7 全球响应地图:从 CERT-EU 到首相声明

把各国的反应并排看,会发现一件有意思的事:响应的速度与它距离那台主机的距离成反比。

美国这一层是执法与情报机构发声:FBI、NSA 发出警告,CISA 有相关通告。真正值得记录的是 Cisco 的公告(cisco-sa-php-october26-Jj384Daq),它明确写“无可用的 workaround”。一家厂商在公告里承认“没有临时缓解”,这在这个行业里并不常见,因为它等于把责任推回给客户:你只能打补丁。这句话也是一个制度信号——当临时缓解的选项被官方否掉,所有“先观察一下”的拖延就失去了技术借口。

欧盟这一层是协调机构:CERT-EU 发布安全公告 2026-018,建议必要时隔离面向互联网的应用。注意它的用词是“隔离”,不是“下线”——隔离保留了服务,切断了暴露,这是一个技术上更聪明的建议,但它要求组织具备把应用从公网摘下来的能力,而很多单体系统没有这个能力。

日本这一层越过了技术部门。首相高市早苗公开呼吁相关机构尽快部署补丁,必要时暂停网站服务。一个国家的行政首脑对一个个软件漏洞公开发声,说明影响面已经溢出 IT 议题,进入公共服务连续性议题。日本的政务系统与地方自治体网站长期存在外包分散、供应商众多、老旧 PHP 占比高的结构问题——这是行业一般性观察,不是本次事件的素材事实,但它解释了为什么那里需要政治层面的推动。

荷兰与芬兰这一层是执行:部分政府网站预防性下线。预防性下线是一笔交易——用可用性换确定性。它同时也是对“缓解不可靠”的一次间接承认:如果 WAF 规则足够可靠,就不需要下线。

厂商这一层提供工具:Cloudflare、Fastly、Google、AWS、Microsoft、Akamai、Imperva、Vercel 发布指引或开箱缓解规则。这张名单的共同点是它们都坐在请求路径上。它们能做的是在自己的层里加一层过滤,不能做的是替你升级那个进程。

中国这一层的特点是复现。阿里云、长亭安全应急响应中心等发布预警与复现分析,奇安信等参与应急。这里要区分两种贡献的成本:预警是低成本的(转发一个 CVE 编号即可),复现是高成本的(要搭环境、要构造输入、要验证检测特征)。素材里检测建议的三层信号(HTTP 层的超长字段名与二进制值、日志层的 Unknown on line 0 与 AH00052、进程层的 web worker 派生 shell)能落地成可执行的东西,靠的正是复现。这是国内厂商在这次事件里最实质的贡献。

把这些层级竖起来看,可以画成一条从抽象到具体的链条:国家级(行政命令与公开呼吁)、协调级(CERT 通告)、厂商级(镜像、规则、公告)、组织级(补丁窗口与变更审批)、主机级(一次 ssh 与一次重启)。层级越高,决策越快、措辞越抽象;层级越低,动作越慢、越具体。这次事件的怪现象就出在这里:顶层在 24 小时内全部动完,而主机级要跑几个月。压力不是没有传导,而是传导到了一个吞吐量极小的出口上。

所以这张地图真正显示的不是“全球有多团结”,而是“全球的决策层比执行层快一个数量级”。而这个差距,恰好是下一章要处理的那件事的镜像:发现速度已经被机器拉起来了,修复速度还锁在人的手里。

本章的结论不是“影响面有 70%”。影响面的算术真正告诉我们的,是当一个漏洞的位置落在语言层,组织赖以做判断的三套系统会同时失效:资产清单(因为它按系统记,不按运行时记)、补丁分发(因为渠道掌握在别人手里)、风险预算(因为乘数被当成了加数)。三套系统失效之后,剩下的唯一依据就是“谁暴露、谁有数据、谁能横向”这三个很朴素的排序原则。

而这次事件里另一个更让人不安的事实,是三家互不通气的机构在两周内独立抵达了同一个根因。那不是运气。那是可以重复的能力第一次被公开看见——这件事的细节,从 7 月 30 日的一封审计立项邮件开始。

第 6 章 三支队伍,同一个洞

“三家不同公司的研究人员表示,他们用 AI 发现并处置了一组可能影响 70% 互联网的关键缺陷。”

——CNN 对该事件研究的报道

本章要回答的问题是:同一处根因,被三支互不隶属的队伍在两周内分别抵达,这件事到底能证明什么。说它是巧合,等于抹掉三家机构实打实的工作量;说它是「AI 比人强」,则跳过了中间所有关键环节。我的判断是第三种:它证明的是可重复性。

一个模型在某个下午报出一个真漏洞,可以是运气。两套工具链、两组人,在不知道对方结论的前提下,走到同一段代码的同一个顺序问题上,并且各自都能给出可运行的利用链——这是工程指标,不是运气。安全行业判断一个发现是否可信,从来不看发现者说了什么,而看有没有第二个人能重复它。所以这一章要做的,是把「别人能重复」这句话拆成可检视的部分:谁,在什么时间,用什么工具,走到了哪一步。

先把边界立好。以下所有日期与产品名都来自公开材料:PHP 基金会的官方时间线、Google Cloud 博客、Wiz 的披露(经 AIChatDaily 与 rdintel 转述)、OpenAI 的表述(经新浪财经转述),以及 The Hacker News 的发现者名单。材料没有披露的,我不补。三支队伍内部怎么分工、模型在第几次尝试上成功、提示词怎么设计,这些都不在材料里,也就不在这本书里。凡是涉及厂商自我评价的地方,我会在当时就标明「厂商口径」,而不是留到事后统一声明。

6.1 Calif 与 Patch the Planet:从 7 月 30 日开始的审计

2026 年 7 月 30 日,Calif(OpenAI 旗下的安全研究团队)开始对 PHP 代码库做安全审计,渠道是 Alpha-Omega,计划名是 Patch the Planet,使用的模型是 GPT-5.6 Sol。这条线上被公开提到名字的人,是研究员 Ryan A。

这四个要素值得逐个看清,因为它们决定了后面那十二天意味着什么。

第一,这是一次有计划的审计,不是一次即兴尝试。「计划」这两个字的份量在于:它有范围(PHP 代码库)、有预算(人力与算力)、有对接渠道(Alpha-Omega 是基金会一侧的资助与协调通道)、有披露路径。也就是说,在 7 月 30 日之前,负责「发现之后怎么办」的那套管道已经铺好了。这一点在讨论「AI 让发现变便宜」时经常被忽略:真正让发现产生影响的不是发现本身,而是发现发生时,有一条已经存在的路通向能修它的人。

第二,起始动作是「审计代码库」,不是「测试某个应用」。这个区别在技术上是决定性的。如果起点是「打一打某个 WordPress 站点」,那么能观察到的是现象:报错、超时、崩溃。从现象往回追到解释器里的哈希表操作顺序,中间隔着几层抽象。反过来,从代码库出发,起点就是那段顺序本身,剩下的工作是把「这里有问题」变成「这里有武器」。素材里的记录也正是这个走向:8 月 11 日报告的是「无条件远程代码执行」,并且明确指出根因与 2024 年那个被搁置的 OOM 问题相同。

第三,工具是单个前沿模型,不是一套封装好的安全产品。这一点是与 Wiz 那边最直接的对照,我在 6.4 节会展开。此处只需要记住差别:Calif 这条线公开的信息里,模型是主力,团队是人加模型。

第四,时间。从 7 月 30 日到 8 月 11 日,十二天。

十二天里发生了什么,材料给了一个更细的数字:8 月 1 日是 Calif 自述的发现日期。这个日期与 8 月 11 日之间隔着十天。我不打算把这十天解释成某件具体的事,因为材料没有说。但可以指出它落在哪一段流程里:「发现」在漏洞研究的语境里,通常指「确认某处存在可被利用的缺陷」;而「报告」指「写出一份让维护者能复现、能判断影响面的文件」,通常还附带 PoC。从 8 月 1 日到 8 月 11 日,正好是从「我知道了」到「我让你也知道」的距离。这十天不是浪费,它是把私人结论变成公共证据的成本。

然后是 8 月 11 日。这一天有两件事同时发生,而且第二件比第一件更值得注意。

第一件:Calif 的研究员 Ryan A 报告了「无条件远程代码执行」,根因与 2024 年的 OOM 问题相同。这里的「无条件」是关键词:不需要前置条件,不需要认证,不需要用户交互。它意味着这不是一串需要在特定配置下才成立的巧合,而是默认状态下就能走通的路径。同一份材料还记录了一个后果:此后,这个 OOM 问题被升为最高优先级。这句话看着平淡,其实是全书的一条暗线——2024 年它被搁置,不是因为没人知道它在那里,而是因为没人能证明它危险。8 月 11 日之后,优先级变了,变的不是代码,是「能不能证明」这个状态。

第二件:同一天,OpenAI 独立报告了相同根因。

关于这件事,材料只给了一句信息量很大的描述:两家口径并列出现在基金会的时间线里。也就是说,基金会的记录里,8 月 11 日这一天出现了两条来源不同的报告,指向同一个根因。最自然的解释是存在一条内部与外部的双重提报路径——团队在渠道内先报一次,公司层面再走一次正式的披露流程。但我必须停在这里:材料没有说明这两条路径的具体分工,也没有说明第二次报告是否携带了新的技术内容。任何更细的还原都是推测。

这个双重提报的意义要分清性质。它体现的是机构内部的冗余,不是独立第三方复现。同一家公司的两支力量在同一天得出同一结论,说明结论在公司内部被复核过——这是好信号,但它不能替代外部队伍的独立验证。真正让「这不是误报」这件事站得住的,是后面两支队伍:一支不隶属于 OpenAI,一支来自 PHP 项目之外的另一条资金线。所以我在这一节的判断是:Calif 这条线证明了一件事——一次有组织的、以代码库为起点的审计,可以在两周内从零走到「无条件 RCE + PoC」。

一句话定性:十二天不是奇迹的速度,是一个已经把管道铺好的组织,第一次把这条管道跑通的速度。

再补这条线的后续:8 月 14 日 Calif 向 PHP 提交 RCE PoC,8 月 15 日 PHP 确认,8 月 21 日报告第二个 bug。三个日期都落在 8 月下旬,与 Wiz 的窗口重叠——这意味着 PHP 安全团队面对的已经不是一条报告,而是至少两条并行推进的技术线。修复侧后来那两个月的混乱,源头在这里。

6.2 Wiz 的四天:8 月 19 日到 8 月 24 日

第二条线来自 Wiz Research(Google 旗下的云安全公司)。它的时间跨度看起来更短,工具组合更复杂,公开叙事也更完整。

先把日期排清楚:8 月 19 日,Wiz 使用 Wiz Atlas、Gemini 4 Argon 与 GPT-5.6 Sol 的组合,发现首个 PHP 漏洞;8 月 20 日,确认可对 Drupal 实现未认证 RCE;8 月 21 日,确认可对 WordPress 实现未认证 RCE;8 月 24 日,向 PHP 安全团队报告相同根因及攻击向量。

从 8 月 19 日到 8 月 24 日,五天。素材里的说法是「四天」,指的应该是 19 日到 22 日左右的工作窗口,或者把上报日排除在外。重要的是这五天里每一段在做什么,以及它为什么不能更快。

8 月 19 日,得到的是一个「首个 PHP 漏洞」。材料没有说明这一天定位到的是 109618 还是 109619,也没有说明这一天的成果是「可疑点」还是「可复现的崩溃」。我不会替它补。但从后续两天的走向可以反推:19 日得到的东西还不构成对外的证据,否则 20 日与 21 日的工作就没有必要。

真正的工作量在 20 日和 21 日。这两天做的不是「继续找洞」,而是「证明这个洞在真实部署上成立」。Drupal 与 WordPress 是两种不同的应用形态:请求进入解释器的方式、默认配置、可触及的输入面、错误处理路径,全都不一样。要在两者上都做出未认证 RCE,意味着同一个根因必须在两种不同的运行形态下各走通一遍。这是把「语言层缺陷」翻译成「应用层可利用」的过程,而这层翻译,恰恰是过去最贵的一段。

还有一个细节容易被读过去,但对这本书的论证很重要:演示环境是 WordPress、Drupal、Joomla 的「当时最新容器镜像」。这句话至少包含三层信息。第一,不是拿老版本凑出来的——最新镜像意味着补丁状态是当时的真实水平,攻击面没有被人为放大。第二,容器镜像是一个可分发、可复现的环境:别人拿到镜像与脚本,能重跑一遍。第三,镜像对应的是真实世界的部署形态,而不是手工搭出来的实验台。换句话说,Wiz 交付的不只是「一个漏洞」,而是一个可以被第三方重跑的实验装置。

同一天,材料还记录了另一件事:Wiz 把多个 bug 串成了单一利用脚本 php2shell。这个动作的技术含量常被低估。单个 bug 到单个 PoC,是研究;多个 bug 串成一条稳定利用链,是一个工程。读者如果做过任何一次真实渗透,会知道这两者的距离:前者要的是理解,后者要的是在别人的环境里也能跑。而这个脚本后来被整条攻击链的讨论反复引用——包括它被提前泄露、被第三方拿去改写的那一段。那是第 9 章的内容。

现在说一件必须说清的事:Wiz 不是首发。

Calif 的报告是 8 月 11 日,Wiz 的发现日是 8 月 19 日。中间隔了八天。但如果你只看这一轮的媒体报道,印象里往往是「Wiz 发现了 PHP2Shell」。这个错位的成因不难理解:Wiz 的公开叙事里有完整的工具链描述、有方法论、有一句好引用的效率对比,还有一句来自 Google DeepMind 高层的评价。Calif 那边公开的信息要少得多。叙事完整度不等于时间先后,而媒体是按叙事完整度选材的。

我把这个错位写在这里,是因为它本身就是本书要讨论的现象的一部分:当一次事件同时有技术维度与传播维度时,公众记住的往往不是最早的那一个,而是讲得最完整的那一个。这不影响 Wiz 的工作价值——它在跨部署适配与可复现演示上的贡献是独立的、不可替代的——但它影响我们对「谁发现了」这个问题的判断。谁先发现是一个事实问题,谁被认为是发现者是一个传播问题,两者不该混着用。

8 月 24 日,Wiz 向 PHP 安全团队报告了相同根因及攻击向量。注意这里的措辞:报告的是「相同根因」加「攻击向量」。根因不是新的——8 月 11 日已经有人报过;新的是攻击向量,是「这个根因能怎么被打」的具体路径集合。这一句措辞精确地说明了 Wiz 这条线的独立价值落在哪里:它不是在重复发现,而是在扩充可利用性的证据面。

一句话定性:Wiz 交出的不是「又一个洞」,是一份别人能重跑的攻击说明书。

6.3 OpenAI Codex Security 与 Gemini 团队:修复侧的协作网络

第三条线最不容易叙述,因为它在公开材料里几乎只有一个名字的位置:OpenAI Codex Security 团队出现在 The Hacker News 的发现者名单里,与 Calif、Wiz 并列,并与 PHP 安全团队、Google Gemini 团队共同推进修复。

这里需要一次谨慎的措辞。材料没有说明 8 月 11 日那次「OpenAI 独立报告」是不是由 Codex Security 这支队伍发出的,也没有说明 Codex Security 的独立发现发生在哪一天。所以我在表格里把它放在「修复侧与 OpenAI 一侧」这一列,而不是编一个日期给它。

先把三条线并排放一次,这是本节最有用的东西。

| 日期 | Calif(Patch the Planet) | Wiz Research | OpenAI Codex Security 与修复侧 |

| --- | --- | --- | --- |

| 2026-07-30 | 通过 Alpha-Omega 渠道在 Patch the Planet 计划下开始审计 PHP,工具为 GPT-5.6 Sol | —— | —— |

| 2026-08-01 | Calif 自述的发现日期 | —— | —— |

| 2026-08-11 | 研究员 Ryan A 报告“无条件远程代码执行”,根因与 2024 年 OOM 问题相同 | —— | 同日 OpenAI 独立报告相同根因;此后该问题升为最高优先级 |

| 2026-08-14 | 向 PHP 提交 RCE PoC(其口径) | —— | —— |

| 2026-08-15 | PHP 确认(其口径) | —— | —— |

| 2026-08-19 | —— | 用 Wiz Atlas + Gemini 4 Argon + GPT-5.6 Sol 发现首个 PHP 漏洞 | —— |

| 2026-08-20 | —— | 确认对 Drupal 未认证 RCE | —— |

| 2026-08-21 | 报告第二个 bug(其口径) | 确认对 WordPress 未认证 RCE | —— |

| 2026-08-24 | —— | 向 PHP 安全团队报告相同根因及攻击向量 | —— |

| 2026-09-18 | —— | —— | OpenAI 报告更多攻击向量 |

| 2026-09-21 | —— | —— | PHP 安全团队确认收到报告,启动协调修复 |

竖着读一遍,会看到三个不同的形状:Calif 是一条稀疏的长线,Wiz 是一段密集的短促脉冲,修复侧是一条平缓而被动的线。横着读一遍,会看到那个核心事实:8 月 11 日、8 月 19 日、8 月 21 日、8 月 24 日,不同的队伍在不同日期抵达同一个根因;而 9 月 21 日的协调修复,发生在至少三条独立的技术线汇合之后。

现在说这一段最容易被忽略的一半:修复侧本身也是一个由 AI 参与的网络。

PHP 基金会的披露给出了它的构成。2026 年初,基金会与 Alpha-Omega 合作,用 Anthropic 的 Claude Mythos 对 PHP 做安全扫描。之后引入 OpenAI 的 Daybreak Red,用途很具体:验证漏洞报告与 PoC,并按相同根因给报告分组。配合的手段是 sanitizer 构建,以及「与 PHP 各支持平台 CI 匹配的复现环境」。

这四件东西合起来是一台分拣机。上游倒进来的是海量报告,其中混杂着真漏洞、重复项、误报与无法复现的碎片;sanitizer 与 CI 匹配环境回答「这个说法能不能在我这里复现」;按根因归组回答「这些报告其实是同一件事吗」;最后剩下的少数几条,才进入人的判断。这个流程描述是我的概括,材料只列了组件,没写流程。

为什么需要这台分拣机?材料给了一个数字:自 2026 年 7 月 1 日起,PHP 已经发了 15 个 CVE,处理了 100 多份无效、重复或不可复现的报告。100 多份,在不到四个月里。这个数字是全章最有说服力的反面证据之一,我在 6.5 节还会用到它。

它说明什么?说明「发现成本塌陷」的后果不是均匀分布的。当发现变得便宜,第一个被压垮的不是攻击者,而是接收端的分拣能力。一个由少数志愿者与核心开发者组成的语言项目,突然要处理上百份质量参差的报告,每一份都要读、要判、要回。这活不产出任何代码,但它消耗掉的时间,正好是修复所需的那部分时间。

所以修复侧引入 AI 的动机与发现侧完全不同:发现侧是为了「看见更多」,修复侧是为了「不被淹掉」。前者是放大器,后者是过滤器。这两件事常被笼统写成「AI 提升了安全能力」,其实它们对行业结构的作用方向相反——放大器让供给变多,过滤器让供给变得可用;没有过滤器,放大器只是噪声源。

材料还明确了这台分拣机的边界,这一点很重要,因为它是全书对「能力边界」最直接的一手表述:AI 能验证报告、能归组,但不能决定引擎中长期设计问题的正确修复方案,不能权衡兼容性取舍,不能审查补丁。基金会的意思很直白——每一个判定与补丁都由人完成。

于是协作网络的结构清楚了:三条独立的发现线从不同方向抵达同一根因,一台分拣机把它们归并、验证、排序,最后落到少数几个懂 PHP 引擎的人手上,由他们决定怎么改。没有哪个节点能替代另一个。

这里我要放一段反证,因为「独立复现」这个词在本章被用得太多,容易变得廉价。

严格意义上的独立复现要求三个独立:人员独立、工具独立、信息独立。这次事件里,人员是独立的——三家机构互不隶属;信息大体是独立的——Wiz 的发现日晚于 Calif 的报告日,但材料没有证据显示 Wiz 拿到了 Calif 的技术细节;但工具并不完全独立。Calif 用 GPT-5.6 Sol,Wiz 的组合里也有 GPT-5.6 Sol,还有 Gemini 4 Argon。同一批前沿模型被不同的人调用,那么「两个团队独立得出同一结论」这件事,有多少是人的独立性带来的,有多少是模型内部相似的归纳路径带来的?

这个问题我没有答案,材料也不足以回答。但它会改变我们对「可重复性」的理解:如果多个团队用同一批模型、在同一时间窗、沿同一条资助渠道(Alpha-Omega)工作,那么他们得出的结论相似,可能部分反映的是这批模型的能力分布,而不是人类研究者的独立判断。这不削弱结论的正确性——漏洞是真的,PoC 能跑——但它削弱了「独立复现」这个词所暗示的那种制度强度。

一句话定性:三支队伍会合于同一个根因,证明的是「这个洞是真的」;它还没有证明「这套方法在换一批模型、换一个项目之后仍然有效」。后者才是可重复性的完整含义,而它需要下一次事件来验证。

6.4 工具链拆解:Wiz Atlas 是什么,为什么它不是「裸模型」

前面三节反复出现同一个词:工具组合。Calif 用的是 GPT-5.6 Sol;Wiz 用的是 Wiz Atlas 加 Gemini 4 Argon 加 GPT-5.6 Sol。如果只把这句话读成「他们用了更好的模型」,就错过了本章技术含量最高的部分。真正决定结果的不是模型本身,是模型外面那一层壳。

本书把 Wiz Atlas 这类把模型与工具、环境、反馈循环封装起来的产品形态叫「安全 AI agent」。与之相对的是「裸模型」:一个只能接受文本、返回文本的推理接口。两者的差别不在参数量上,而在模型能不能「动手」。这个差别可以拆成四层。

第一层是环境。裸模型读到的是代码字符串。安全 AI agent 拿到的是一个能跑起来的目标:容器镜像、可执行的 PHP 解释器、真实的 CMS 部署、可观测的系统日志。这个区别落到具体工作上非常实在——「这段代码可能在 fatal error 之后的 shutdown 路径上留下半初始化的 bucket」是一个判断,「在一个跑着 WordPress 最新镜像的容器里,这个判断导致了一次 worker 崩溃」是一个事实。前者要靠人相信,后者可以被人重跑。Wiz 演示用的是 WordPress、Drupal、Joomla 的当时最新容器镜像,这句话之所以重要,是因为它说明环境不是临时搭的,而是 agent 可以反复进入、反复试错的固定场地。

第二层是工具调用。裸模型不会自己跑 sanitizer,不会自己起 gdb,不会自己比对两个 PHP 版本的偏移差异。安全 AI agent 可以。材料没有列出 Wiz Atlas 的具体工具清单,我不编造,但可以指出工具调用的本质:它把「假设」变成「测量」。模型猜「残留字节落在偏移 X 处可能被当作 zval 解释」,这个猜想要变成结论,必须有一次内存观测。工具调用就是那次观测的入口。没有工具,模型的输出永远停在猜想层面,而猜想在漏洞研究里的价值接近于零。

第三层是反馈循环,也是最容易被忽略的一层。素材里有一句对 Wiz 方法论的描述,值得逐字读:「结合调试工具和测试结果,分析攻击失败原因、寻找替代路径、针对不同环境调整方案。」这句话描述的是一台机器在失败之后的行为,而不是它在成功时的输出。

一次性生成与循环迭代的差别,在漏洞利用这个任务上会被放大到决定成败。因为利用开发的第一版几乎必然失败:堆整形没生效、偏移算错、ASLR 没绕、目标版本的内存布局与预期不符。裸模型在这一步会给你一份看起来像模像样、实际跑不通的脚本,并且在你说「失败了」之后,生成一份同样像模像样的第二版,而第二版的改动与第一版的失败原因未必有关。有反馈循环的 agent 不同:它知道这次失败发生在哪一步,知道是泄露没拿到还是布局被打乱,第二版是基于这次失败的测量结果写的。素材点出这个循环「过去由人类研究员手工跑」——这是全章对成本结构最诚实的一句话:AI 不是替代了这件工作,是把这件工作的循环速度改了。

第四层是验证。一个被封装起来的安全 agent,终端产物不是一段解释,而是一个能被第三方重跑的东西:可执行的 PoC、可复现的镜像、可对照的日志。这一点决定了 Wiz 的成果为什么能直接进入披露流程。8 月 24 日它向 PHP 安全团队报告的,是「相同根因及攻击向量」,攻击向量是可验证的物件,不是论断。

四层合起来,就能理解为什么 Wiz 那条线上出现了三个模型名。材料没有说明三者各自负责哪一段,我不猜。但结构上的解释是现成的:当一个 agent 系统里放多个模型时,通常不是因为一个不够聪明,而是因为不同环节的最优解不同——读一个大代码库需要长上下文与语义推理,写一个跨版本利用需要对具体测量结果敏感,验证一条报告需要严格的比对倾向。把这三件事塞进一次调用,是不合理的工程选择。

这里必须引一句来自 Google 一侧的评价。

Google DeepMind Gemini Security 负责人 Raluca Ada Popa 对 Wiz 工作的原话是:“Using Gemini 4 Argon, the team at Wiz has shown why frontier AI is a game-changer for cyber defense.”

直译过来:借助 Gemini 4 Argon,Wiz 的团队展示了为什么前沿 AI 是网络防御的游戏规则改变者。

我不喜欢「游戏规则改变者」这个说法,本书也尽量不用这类词。但这句话值得原样引出,因为它精确暴露了一种视角:说话的人站在模型提供方一侧,他看到的是模型在一条真实攻击链上的表现;而站在整件事的另一端看,被改变的不是「防御」,是「从假设到证据」这段路程的成本。两句话不冲突,但范围差得很远。前者可以被写进发布会,后者需要拆成四层才说得清。读者以后每看到「改变游戏规则」这类表述,都可以用这一章的拆解问一句:改变的是哪一层,环境、工具、循环,还是验证?

所以这一节的结论要说清一件事:把 Wiz 的成绩记在 Gemini 4 Argon 或 GPT-5.6 Sol 名下,是一种误记。模型是这套系统里最容易被看见的部件,也是最容易被替换的部件。四层里任何一层缺失,剩下的模型再强也只能输出一份读起来很有道理的猜想。而这个世界从来不缺读起来很有道理的猜想。

一句话定性:模型提供判断,agent 提供让判断被证伪的机会——后者才是这次效率跃迁的来源。

6.5 效率的量级:一周对数月,这个对比该怎么读

Wiz 给出了两个数字,这是本次事件里被引用最多的效率表述:一名研究员用一周完成了过去需要「世界级团队数月」的工作;针对不同部署环境的适配,从数周压缩到数小时。

先说清来源性质:这两句是 Wiz 的自述,属于厂商口径。本书引用,是因为没有第二个来源能提供同等量级的对照,而不是因为它已被独立核实。

第一个偏差来源是选择性呈现。厂商叙事天然只会留下成功的那条路径:这一周之前有多少次失败的审计?这一周之内有多少条被模型提出又被推翻的假设?公开材料里没有。但我们手上有另一组数字可做粗对照:从 2026 年 7 月 1 日起,PHP 接收了 100 多份无效、重复或不可复现的报告,其中必然包含 AI 辅助审计的产物。一个只看成功案例的效率叙事,和一个把失败案例也计入的效率叙事,差着数量级。前者说「一周等于数月」,后者可能要说「一百次尝试里有三次值得看」。

第二个偏差来源是基线不明。「世界级团队数月」这句里的「数月」指的是哪一次?是 2024 年那次由公共资金资助、Quarkslab 执行的外部审计吗?那次审计跨度更长,但它覆盖的范围是整个 PHP 核心,并且产出的是审计报告,不是一个利用链。素材没有指明基线,所以这个对比无法被校准。没有基线的倍数是一个修辞,不是一个测量。

第三个偏差来源是任务定义不同。这一条最实质。今天这名研究员做的工作,是「在已有的代码库、已有的镜像、已有的模型、已有的披露渠道上,走完发现与证明」。「世界级团队数月」在历史语境里通常包含的还有:确定审计范围、搭建环境、写报告、跟维护者来回沟通、跨版本 backport、协调披露时间。这些事今天依然存在,只是被分给了别人——环境由容器镜像生态提供,渠道由 Alpha-Omega 提供,报告压力由基金会承担,修复由核心开发者承担。所以在同一个「数月」里,被比较的两件事其实不是同一件事。一个人一周完成的那部分,是从「假设」到「可复现 PoC」;剩下的部分没有消失,只是转移了。

三个偏差加起来,是不是说明这个对比没有价值?不是。偏差影响的是量级,不是方向。而且这两个数字里有一个明显比另一个更硬:「跨部署适配从数周压缩到数小时」。为什么它更可信?因为它的任务定义最窄、最机械、最容易做同口径比较。把一条利用链从一个 PHP 版本挪到另一个版本,需要处理偏移、结构布局、内存分配行为的差异,这是一件边界清楚、成败可判、耗时可直接计时的工作。在这种任务上,循环速度的提升会直接反映为耗时的下降,中间没有多少解释空间。相比之下,「发现一个零日需要多久」这种问题的答案,方差大到任何倍数都失去意义。

所以我给这个对比的读法是:把它拆成两层。机械层(适配、重试、偏移调整)的加速是可信的、可外推的,而且会持续。判断层(决定审哪里、判断什么值得追、决定报告怎么写、决定补丁怎么改)没有被加速,也看不出会被加速的迹象。Wiz 的数字之所以显得惊人,很大程度上是因为它把两层的成果合并成一个数字报了出来。

还有一件事必须补上:效率提升发生在供给端,而这次事件真正出问题的地方在接收端。发现侧的效率从「数月」压到「一周」,意味着单位时间内进入披露渠道的报告数量上升了一个量级;而接收端的分拣能力由「少数几个懂引擎的人」决定,没有变。这会带来一个反直觉的结果:发现效率越高,单个漏洞从报告到修复的平均时间可能越长。2026 年 8 月到 10 月的实际走向,正好是这个形状——8 月 11 日多方确认,9 月 21 日才启动协调修复,10 月 21 日才发布补丁。效率的账不能只在发现侧结算。

一句话定性:一周对数月这个对比,量级不可信,方向可信;而它真正的价值,是让我们看见成本从哪一端转移到了哪一端。

6.6 本章的结论不是「AI 比人强」

这一章讲了三支队伍,讲了工具链,讲了一个惊人的倍数。现在要把话说回去。

如果有人从这一章读出的结论是「AI 比人强」,那是因为中间有四个环节被跳过了,而这四个环节全部由人完成。

第一,问题由人定义。7 月 30 日开始的是一次审计,不是一次漫游。审计有范围、有目标、有结束条件。「审 PHP 代码库」这个决定,包含了选择哪个项目、审哪一类缺陷、用多少资源、结果交给谁——这些没有一个来自模型输出。模型不决定审什么,模型只能在被指定的范围内工作。这一点在评价任何 AI 安全成果时都成立,而它在本次事件里格外明显,因为被审的对象是一个积累了几十年的代码库,选它需要判断力。

第二,环境由人提供。容器镜像、CI 匹配的复现环境、sanitizer 构建、披露渠道——这些东西没有一样是模型在 8 月 19 日那天现场变出来的。它们是多年基础设施投入的结果。Wiz 之所以能在四天里完成跨部署验证,是因为 WordPress、Drupal、Joomla 的最新镜像本来就在那里,随时可以拉起来。AI 的高效率有一部分是「借用」了这些投入,把它全部记在模型账上是不对的。

第三,验证由人完成。8 月 15 日 PHP 确认 Calif 的报告,8 月 24 日 PHP 安全团队接收 Wiz 的报告,9 月 21 日启动协调修复。这三个动作都是人的判断。模型可以生成一份 PoC,但它不能判定这份 PoC 的严重性排序,不能判定哪个报告与哪个报告同源,也不能判定修复是否破坏兼容性。材料里基金会的表述是明确的三条禁令:不能决定引擎中长期设计问题的正确修复方案、不能权衡兼容性取舍、不能审查补丁。这三条不是谦虚,是它们试过之后划出的线。

第四,修复由人完成。这是最硬的一条,也是本章最该被记住的一条。整件事里唯一没有加速的环节是修复,而修复慢的原因不是没有人知道洞在哪,是没有人能替那几个懂 PHP 引擎的人做取舍:改哈希表的顺序,会破坏依赖现行为的大量代码;加一层检查,会引入新的拒绝服务风险;改错误处理语义,要求大量第三方扩展跟着改。三个方案都有代价,选择哪个是工程判断,不是计算问题。基金会自己把这段历程称为「结构性失败」。

把这四条排在一起,会得到一个与「AI 比人强」相反的结论:AI 在这条链上是一个放大器,而不是替代者。放大器的定义是,它需要一个已经存在的信号才能放大。信号是「这里可能有问题」的人类判断,放大之后是「这里有可以被证明的武器」。缺了输入端,放大器输出为零。

还有一个更锋利的反证。「AI 比人强」这个判断可以被证伪,只要问一句:如果 AI 真的比人强,那么修复应该也变快了。实际观察到的恰恰相反——发现从数月压到一周,修复从数月变成了两个月且伴随一次提前泄露。一个只在某一端更快的系统,不能被描述为「比人强」,只能被描述为「在某一端失衡」。失衡是本书的主题,而失衡不是一个褒义词。

所以本章的结论是这三句话。第一,三支队伍独立抵达同一根因,证明这件事是真的,也初步证明这套方法是可重复的——但要记住 6.3 节的那个保留:工具并不完全独立,可重复性的完整证明还在下一次。第二,效率的提升真实存在,但它的量级被厂商叙事放大了,而且集中在机械层,不在判断层。第三,整件事里最慢、最贵、最需要人的那一环,恰好是决定结果的那一环。

本章的结论不是「AI 已经能独立完成漏洞研究」,也不是「厂商在吹牛」。前者高估了模型,后者低估了已经发生的变化。

一句话收束:三支队伍在两周内抵达同一个洞,说明的不是机器比人聪明,而是「证明一个洞能打穿」这件事的成本,已经塌陷到一个人加一套工具链就能承担的程度。

而这个判断会带出一个必须追问的问题:如果证明成本已经这么低,为什么 2024 年没有人做到?是当时的技术不够,还是当时的人没有把这个洞当成一个问题?下一章的答案会让这一章的乐观情绪降一次温——因为那个洞的位置,在 2024 年就被人看见了,并且被明确地、有理由地搁置了。

第 7 章 2024 年他们就知道:被搁置的可利用性

“最吓人的细节不是 AI 发现了一个关键 bug,而是 PHP 项目早就知道这个 bug 住在哪里。”

——AINewsToday 对 PHP2Shell 事件的评论,2026 年

本章只回答一个问题:如果一个漏洞的位置在 2024 年就已经被人类看见,那么 2026 年 10 月那场全球应急,究竟是谁的失误?

我不打算把答案给成“维护者失职”。这个答案太便宜,而且它会让读者错过全书最重要的一件事。真正值得看的是:在 2024 年那间看不见的办公室里,有一群人做了一次完全正常的工程判断——他们看见了,他们写下了怀疑,他们找不到攻击路径,他们把这件事放下了。两年后,同一件事变成了 CVSS 9.2、影响了大约七成互联网的紧急事件。中间到底发生了什么变化,才是这一章要拆的东西。

7.1 一次由公共资金资助的审计:Sovereign Tech Agency、OSTIF、Quarkslab

要理解 2024 年,得先理解那次审计本身。它不是一次例行的代码走查,也不是某个大厂顺手做的公益扫描。按 PHP 基金会的官方叙述,这是 PHP 多年来第一次外部安全审计,三个角色分工清楚:Sovereign Tech Agency 出钱,OSTIF 组织,Quarkslab 执行。

这三个角色值得分别看一眼,因为它们代表了三种不同性质的资源。

出钱的一方,把公共资金投进了开源基础设施的维护。这类资金买到的不是补丁,是一段时间的专业注意力——几个真正懂 C 和引擎的人,被付钱去读一段已经被读过一万遍的代码,而且他们的任务不是修,只是找。这件事在开源世界里并不常见。PHP 从诞生到今天,代码的每一行基本都是社区写的,几乎每一个被修掉的 bug 都是使用者撞出来的。让“读代码”这件事本身变成一个有人付钱的岗位,是一次结构性的补充。

组织的一方,负责把资金拆成可执行的项目:定范围、找执行方、排期、推动结果公开。一个外部审计最容易死掉的地方不是技术,是流程——范围没有边界,报告写完没有人认领,发现的条目被塞进列表底部然后永远不再被打开。组织者的价值在于让一份报告最后真的落地成一组工单。

执行的一方,是真正坐下来读代码的人。这一点必须说清楚:外部审计的工作方式是阅读,不是运行。审计员把代码当作文本来看,追踪数据流、检查不变量、寻找路径上缺失的校验,然后写下一句判断。这种工作能看到“顺序”,但看不到“路径”。顺序是静态的,摆在文本里;路径是动态的,必须把程序跑起来,喂它具体的输入,看它落在哪一行。这个区别在本章后面会变成一切的关键。

时间线也该记住:审计在 2024 年执行,结果在 2025 年 4 月公开。这中间隔着的不是拖延,而是审计行业的标准节奏——报告先交给维护者,维护者要先修掉那些能修的,公开时才不至于把还没补的洞摊在桌面上。

现在到了这一节最重要的一句:CVE-2026-109618 的最初迹象,不是审计报告里的一条发现。它是核心开发者在修复审计发现的问题时,碰到的。

请仔细读这句话的语法。“修复审计发现的问题时”——也就是说,是副产品。审计报告里写的是一批别的东西;有人为了修那些东西,打开了哈希表插入的那一段代码,然后在那儿停下了。他看见的不是报告上的第几条,而是一段自己读出来的顺序:nNumUsed 先加一,zend_string_init 后分配。这个顺序在今天看是这整个故事的起点,在当时只是一个顺手发现的疑点。

这个细节把一句流行的误读纠正了过来。流行的说法是“2024 年的审计发现了这个洞,然后被忽视了”。准确的说法要弱一些,也要冷一些:2024 年的审计创造了让人有机会看见它的条件——一群被付钱的人,有理由长时间地盯着同一段代码,并且手上有权改动它。洞是被看见的,但它不是在议程上被看见的,它是在执行一项别的任务时被撞见的。这两件事的差别,决定了它后来为什么会被搁置:一个不在议程上的发现,天然缺少推动它的人。

反过来看,这也让那次审计的价值被低估了。它买到的东西不只是“被修好的若干问题”,还有一个被写下来的怀疑。前者会随版本消失,后者会留在某个人的记忆和某个代码库的注释里,等着两年后被重新打开。

公共资金在开源安全上的回报,往往不在它直接修掉的东西上。

还应该给这类审计划一条能力边界,否则我们会向它要求它做不到的事。审计是一种有终点的工作:范围定下来,报告写完,条目标记关闭,项目结项。而维护是没有终点的——它跟着代码库一直活下去,一直有新的提交、新的扩展、新的配置组合。这意味着审计最擅长的事是“在某一个时间切片上,把一批问题从隐含变成显式”,它最不擅长的事是“持续追踪那些没有办法在切片里被证实的东西”。2024 年那次审计把一批问题变成了显式的工单,这是它的成功;而它无意中让一个人看见了一条无法被写进工单的链,这是它的意外。成功和意外都不在项目章程里。

7.2 “理论上可利用”是什么意思:从代码顺序到攻击路径的距离

那一年的判断是:理论上存在利用可能,但找不到具体攻击路径。

这句话在今天被引用得太多,以至于失去了重量。要把它还原,得先承认一件事:在漏洞研究的语言里,“能利用”不是一个开关,是一条四级台阶。

第一级是可疑。你读到一段代码,觉得它的次序不对、它的错误处理不完整、它的状态在两个操作之间有一个窗口。这一级的判据完全是文本的,一个人读完就能下结论。

第二级是理论上可利用。你不仅觉得可疑,还能把“如果”串起来:如果这次分配失败,那么计数器已经越过了槽位;如果那个槽位里恰好残留着可控的字节,那么清理代码就会把它当成一个合法的值;如果那个值被解释成数组,那么析构时就会走到元素的回调。每一环都能说通。

第三级是可触发。你把程序跑起来,喂它一个具体的输入,它真的崩了,而且崩在你预言的那个位置。这一级需要实验,但只需要一次成功。

第四级是可利用。你不只让它崩,你让它按你的意思动——在 worker 权限下执行了一条命令,而且这次成功可以重复,可以换环境,可以不留下明显的痕迹。

2024 年的判断落在第二级和第三级之间:他们知道第二级成立,他们没有到达第三级。

现在把技术细节摊开,看这四级之间到底隔着什么。

走进那段代码,你看到的是四行。前三行是算术:取当前水位当索引,水位加一,元素计数加一。算术不会失败。第四行是唯一可能失败的操作——zend_string_init 要为这个键复制一份字符串,向 Zend 内存管理器申请空间;如果申请不到,PHP 在这个位置上的约定是直接报致命错误并 bailout,用 longjmp 跳出执行栈,落进 shutdown。这个函数没有“失败返回”这条出口。

于是“理论上可利用”说的是一件非常具体的事:存在一个时刻,一个槽位已经被计为“已用”,但它的内容从来没有被初始化;紧接着请求进入 shutdown,而 shutdown 的清理流程会遍历所有“已用”槽位,把里面的字节当作合法值来析构。这个推理一次完成,不需要实验,只需要读。

但要从这里走到命令执行,有四件事必须同时成立:分配必须恰好失败在这一行;未初始化的槽位里必须是攻击者控制的字节;shutdown 必须把那个槽位解释成一个数组类型、并且走到元素的析构回调;那个回调的地址必须是可知的。四件事缺一件,结果要么是段错误,要么是什么都没有发生。

这就是“理论”在这句话里的确切位置。它不是“猜测”,也不是“可能”。它是“一条没有闭合的推理链”:链上的每一环都被单独验证过,唯独链本身没有人跑通过一次。

这里还有一个容易被混淆的区分,值得单独说一句:未闭合不等于误报。

在一个成熟的漏洞管理流程里,报告会被归成三类:确认、误报、待定。误报是“推理链的某一环被证伪了”——你原以为分配会失败在这里,实际上那里有前置检查;你原以为类型位可控,实际上它被清零了。待定是“链上没有任何一环被证伪,但整条链没有被跑通”。2024 年的 109618 属于第三类。它没有被推翻,它只是没有被完成。这两件事在流程上的待遇完全不同:误报可以被关闭,待定不能被关闭,它只能被挂着。而挂着的条目会持续消耗一种很贵的心理资源——每一次翻到它,都要重新建立一次上下文,然后重新放下。

站在维护者的位置上,这种链条是日常。一个读了十年引擎代码的人,职业生涯里会积累几十条这样的链。绝大多数永远不会闭合,因为闭合它们需要的不是更多思考,而是大量实验。而实验需要时间,时间需要理由,理由需要有人相信它值得。

这里出现了本章第一个需要用力的地方:一条没有 PoC 的可利用性判断,在组织里几乎无法翻译成行动。

你可以想象这样一句话被写进一个邮件列表,或者被写进某个 issue 的最后一行:我们认为这里存在理论上的利用可能,但目前找不到具体路径。这句话在技术上是诚实的、有价值的、甚至是负责的。它在流程上则几乎是无声的——因为它没有编号,没有复现步骤,没有一个可以让下游升级的截止日期。任何一个有排期的团队收到这句话,都会礼貌地把它降级为“待观察”。

这不是因为团队不专业。恰恰相反,专业化的一部分就是按可验证的严重性排序。

一个无法被演示的风险,在流程里等于一个偏好。

7.3 为什么被搁置:优先级、人手、以及“无法证明”的沉默

“搁置”这个词在英文材料里写作 shelved。它是一个非常精确的工程词,而它经常被误读成两个更重的词:遗忘,或者掩盖。它不是这两个。

搁置的意思是:记录在案,判定成立,暂不推进。它承认了问题的存在,同时明确表示这件事的成本在当前无法支付。要理解 2024 年为什么会走到这个结论,得把当时的处境铺开,而不是站在 2026 年的后果上回头看。

首先是工作量的处境。审计交来一批已经确认的问题,每一个都要评估兼容性、写回归测试、跟扩展作者协调。这些条目有报告编号,有复现步骤,有明确的严重性评级,它们构成了一个自洽的队列——一个可以被排期、被完成、被交付的队列。在这个队列的末尾,突然多了一条没有编号的怀疑。

其次是人手的处境。能读懂 Zend 内存管理和 bailout 语义的人,在任何时候都是少数。理解这个洞需要一种很特殊的专注:你必须同时在脑子里装下哈希表的物理布局、bailout 的跳出语义、shutdown 的遍历顺序三件事,然后才能看见那个窗口。这种状态不能预约,也很难在被打断的半小时里建立起来。它需要连续的时间,而连续的时间正是开源维护者最缺的东西。

然后是判断本身的处境。PHP 基金会在复盘里写过一句很关键的话:缺乏已知的 RCE 路径时,优先级上不去。这句话是对 2024 年到 2026 年这段历史最精确的概括,也是第 11 章的引子。它说的不是懒惰,而是一条被普遍采用的排序规则——严重性由可利用性定义,而可利用性由证据定义。没有证据的风险,在排序算法里的权重接近零。

把这三件事放在一起,会得到一个让人不太舒服的结论:搁置是当时的最优解,至少是局部最优解。

这就进入了组织行为学的领域,而这里必须克制,因为材料里没有会议记录,没有谁说了什么,我们也不该替历史补台词。能说的是一般机制,不是具体情节。

第一个机制是优先级竞争。任何有限团队都在做同一件事:把稀缺的注意力分配给可证明的严重性。有 PoC 的问题自带重力——它会自己把资源吸过来,因为它能生成工单、能生成公告、能在下次评审会上被指着说“这个必须修”。没有 PoC 的问题不具备这种重力。它不会自己争到资源,它只能等有人想起它。这不是官僚病,这是有限理性的必然。

第二个机制是注意力稀缺。在开源维护里,最贵的资源不是钱,是“能连续想清楚一件难事的两小时”。这种时间无法批量采购,也无法外包。当一个人一天里要处理十几个上下文切换,那条需要三小时静坐才能闭合的推理链,就永远排在后面。

第三个机制,也是最深的一个,是“无法证明的沉默”。一个未闭合的怀疑不产生任何组织信号:它不触发 CVE 编号,不进安全公告,不生成客户工单,不在监控曲线上留下任何形状。它只存在于某个人的记忆里,和某个代码库中的一行注释。组织无法对一个非事件做出反应,因为组织只对信号做出反应。于是这条怀疑的处境是:它在技术上被承认,在制度上不存在。

这就是“被搁置的可利用性”这七个字的全部含义。它不是一个被压下去的决定,它是一个因为无法被记账而没有被记进去的资产。

搁置还有一层时间上的效应,这一层比它看起来重要。一条怀疑被放下时,写下它的人脑子里装着完整的上下文:他知道那个窗口在哪、为什么要先看哈希表的计数器、为什么 zend_string_init 是唯一会失败的那一步。两年之后,如果他还在,这些上下文需要重新建立;如果他不在,留下的可能只有一行注释和半句判断。搁置的成本不是零,它会随着时间缓慢升值——因为一项被搁置的风险,其“重新捡起的代价”在增加,而“被重新捡起的概率”在下降。这两条曲线交叉的时候,就是它事实上被遗忘的时候。

我在这里必须加一句限定,否则这一节会滑向廉价的指责:这套判断在它的信息条件下是理性的。搁置没有错。它唯一的问题在于它的前提——“目前没有人能证明”——而这个前提在 2026 年 8 月被推翻了。

当证明的成本下降到某个阈值以下,所有基于“无法证明”做出的搁置决定,都需要重新计算。这不是一次失误的清算,这是一整代安全决策的隐性负债被集中到期。

搁置不是遗忘,是把判断寄存到一个假设之上;假设变了,寄存的东西就得还。

7.4 可利用性证明的四道技术门槛:布局、整形、泄露、适配

现在进入技术核心。这一节要说清一件事:从“可疑”到“能命令执行”,中间那四道门分别是什么,为什么每一道都贵。

第一道门是内存布局理解。

要让 shutdown 把一个未初始化的槽位当成合法值来析构,你必须让那个位置的字节“长成”一个 PHP 认得的结构。这不是写一段 PHP 代码可以做到的事,这是在裸字节层面伪造数据结构:一个槽位里有键指针、有一个值、有一个哈希值;那个值本身是一个十六字节的 zval,里面有一个联合体和一个类型信息字段,类型信息用低位编码“这是整数、这是字符串、这是数组、这是对象”;如果它被声明为数组,那么它指向的地址必须是一个可信的哈希表,而那张哈希表的槽位数组又必须指向你控制的区域,其中一个元素必须带着一个析构回调。

这一串要求里,任何一个字段错一个字节,结果就从“执行命令”变成“段错误”。而这两种结局在表面上给同一个信号:worker 崩了。

所以第一道门的成本不是“想到”,是“摆对”。你需要在没有源码级调试信息辅助的地址空间里,把一个多层的结构摆到能被读到、能被正确解释、并且解释结果恰好指向你的位置。这一步的失败模式极其贫瘠——它只告诉你崩了,不告诉你错在第几个字节。

第二道门是堆整形:把受控的字节放到正确的位置。

这一道容易被低估,因为它听起来像“准备数据”,实际上是“安排内存”。那个未初始化的槽位里之所以会有内容,是因为那块内存在这个请求之前被别的东西用过;你要让那些“别的东西”恰好是你写的字节。Datadog 的复现给出了形态:请求体里有一个空名字段,值是一段二进制数据——它的作用不是传参,是把受控的字节铺进内存池。

难在这个铺的过程必须与后面那个结构将要复用的位置对齐。这要求你理解内存管理器怎么按尺寸分级分配、怎么复用已释放的块、按什么顺序回收。而且整形发生在请求解析阶段,目标结构要到 shutdown 阶段才被读,中间隔着一次 bailout——你必须保证那次跳出不会把你刚铺好的东西清掉。

这一步的知识无法从阅读里获得。分配器的行为只能通过实验校准:改一个尺寸,看崩溃点有没有移动;加一个字段,看残留内容变了没有。这是一个纯经验的循环。

第三道门是地址泄露:绕过 ASLR。

要伪造结构,你需要两个地址:你伪造的那个结构落在哪里,以及要调用的那个函数(比如 system)在哪里。地址空间随机化让这两个数字每次进程启动都不同,猜中的概率低到没有意义。

Wiz 的做法是用 PHPSESSID cookie 把 worker 的内存泄露回攻击者:构造一次越界读,让内存里的字节通过 session 相关的机制被返回。如果泄露回来的字节里包含一个仍然有效的指针,攻击者就拿到了可靠利用所需的锚点。Datadog 的表述更保守,说 ASLR 是独立的障碍,但内存泄露消除了大部分猜测工作。

这一步的性质与前面两步不同。前两步是“构造”,这一步是“信息”——你必须先把不知道的变成知道的。而且注意它的代价:泄露本身是第二个漏洞的利用。你已经在一个 bug 之上叠了另一个 bug,整条链的可靠性变成了两个概率的乘积。

第四道门是跨版本、跨配置适配。

一个能在某个特定构建上跑的利用,和一个能打的利用,中间隔着一个矩阵。版本上是四条分支——修复版本同时发布在 8.5、8.4、8.3、8.2 上,说明受影响面横跨多个仍在维护的系列。配置上是 memory_limit 的取值,常见的有 16M、128M、256M、512M,这决定了“多大的键能撞破墙”。运行形态上是 SAPI:mod_php、PHP-FPM、php-cgi、FrankenPHP、LiteSpeed,每一种的进程模型和地址空间布局都不同。

每换一个组合,偏移要重算,整形方案要重调,泄露路径要重走。Wiz 的口径是:过去需要世界级团队数月的工作,一名研究员一周完成;针对不同部署环境的适配从数周压缩到数小时(厂商口径)。

四道门放在一起看,它们的性质其实各不相同:第一道是“摆对结构”,第二道是“安排内存”,第三道是“获得信息”,第四道是“重复劳动”。这不是四种知识,是四种劳动。第一种靠想象力和实验,第二种靠对分配器的经验,第三种靠找到第二条漏洞,第四种靠耐心和组合数。

而组合数是会乘的。这就是为什么“一个 PoC”和“一个能打的利用”之间差着一整个矩阵——也是为什么这一步在漏洞经济里最贵:它的成本等于实验次数乘以每次实验的成本,而这两项在过去都由人来支付。

还有一层成本常被漏掉:这四道门不是串联的,是互相牵动的。你把伪造结构挪一个位置去迁就堆整形,泄露回来的地址就变了;你为了适应更小的 memory_limit 缩短字段名,撞破内存墙的时机就提前了,整形的窗口也跟着移动。每改一个参数,前面已经通过的门口都要重走一次。这使“证明”这件事带上了一种特别的质感:它不是把四道门依次推开,而是在四个变量之间找一个同时成立的点,而这个点存在与否,只能靠一次次尝试去撞。

可利用性证明不是一次推理,是一串必须逐一成功的实验。

7.5 模型如何跨过这四道门:失败—诊断—重试的循环

到这里,很多人会自然产生一个想象:既然四道门都有明确的技术要求,那么模型只要足够强,就能一次写出正确的利用。

这个想象是错的,而且它错得很有代表性——它把模型当成了一个知识库,而实际发生的事跟知识关系不大。

Wiz 对方法的描述只有一句,但这一句是本章最该被记住的技术表述:模型会结合调试工具和测试结果,分析攻击失败的原因、寻找替代路径、针对不同环境调整方案。

这句话描述的不是生成,是循环。

循环的形状是这样的:先给一个假设——比如“把伪造结构放在这个偏移上”;然后构造输入把它落实;然后跑起来;然后读反馈;然后修正一个参数;然后重跑。一个完整的循环包含一次运行和一次解释,而循环的价值取决于每一次失败能不能被翻译成下一次的参数改动。

反馈从哪里来,决定了这个循环能不能转。在 2026 年的这套方法里,反馈有三个来源。第一个是 sanitizer:用 ASan 或 UBSan 之类的插桩构建,把“段错误”这种贫瘠的信号变成“在某个地址上发生了堆上的越界访问”这种带位置的信号。第二个是调试器:崩溃时给出地址、寄存器、调用栈,让“错在哪个字段”从猜测变成读数。第三个是与各支持平台 CI 匹配的复现环境,它给出最朴素也最重要的那个二值判断——这一次有没有真的派生出一个 shell。

把这三个来源接起来,失败就不再是失败,而是下一轮的输入。这是整个方法的关键:安全实验的失败信号质量很高。它不模糊,它有一个地址、一个偏移、一个类型不匹配。这种失败可以被吸收。

在这个基础上,有三件事让循环比人类跑得快,而这三件事都不是“更聪明”。

第一,循环次数不再受耐心约束。一个偏移需要试两百次,人会在第三十次的时候开始相信自己已经知道了方向,然后停止搜索,转向“我觉得问题在别处”的直觉。模型不会。它的第两百零一次和第一次一样,没有偏好,也没有对某个假设的投资。这一点在堆整形上尤其致命:整形方案的搜索空间由尺寸、字段顺序、填充长度组成,直觉在这里是负资产。

第二,每次循环的成本下降会改变“值得试什么”。当一次实验从“一个研究员半天”降到“几分钟”,变化不只是六十倍效率,而是原本因为太贵而根本不会被列入考虑的假设,现在值得一试。搜索的边界不是被推远了,是被取消了。

第三,重复劳动可以并行。第四道门本质上是矩阵:四条版本分支乘五种 SAPI 乘若干种内存配置。人的做法是抽样,因为穷举不起;抽样的代价是漏,而漏掉的那个组合就是别人打进来的入口。模型的做法是穷举。Wiz 说适配从数周压到数小时,压缩的就是这部分——不是把每一步变快,是把抽样换成穷举。

这里必须放一句边界,否则这一节会被读成对模型的吹捧。

模型的输出在这个循环里是候选,不是结论。候选的正确性由运行结果判定,不由模型的自信判定。把模型接进一个有真值的反馈环,是这套方法成立的前提条件;脱离这个环,模型关于内存布局的陈述没有额外的可信度。这也解释了为什么同样的模型在“读代码找可疑点”上表现好,而在“判断这个补丁对不对”上不行——后一件事没有便宜的、可自动运行的真值。

基金会在这件事上的表述非常清楚:每一个判定与补丁都由人完成;模型不能决定引擎中长期设计问题的正确修复方案,不能权衡兼容性取舍,不能审查补丁。这三条不是谦辞,是对“真值在哪里”的准确描述。

所以这一节的结论要写得克制一些。AI 并没有“理解”了 PHP 的内存管理,也没有“想出了”攻击路径。它做的事情是把调试这件苦力的边际成本压到了接近零:每一次失败的解读、每一个参数的微调、每一个版本的偏移重算,都不再需要一个有十年经验的人在深夜完成。

而可利用性之所以贵,本来就贵在这些苦力上。

7.6 一个反证:如果 2024 年有人手工做完了,会怎样

一本讲判断的书,必须自己接受判断的检验。这一章的核心论点是“缺的是证明,不是发现”,那么最强的反驳就是:假设 2024 年真的有人手工做完了呢?如果那条链被闭合了,今天的一切会不会不同?

这个反证有两条分支,都值得走到底。

第一条分支:更早修复。

如果 2024 年有人交出了可用的利用,那么 CVE 会在 2024 年发出,8.x 会在 2024 年被修好,2026 年 10 月 20 日那条论坛帖不会存在,全球的运维不会在那天夜里同时打开终端。这个推论成立,而且它有力。

但它只成立到一半。修复的前提不只是知道,还有方案。这个漏洞的根因在最核心的内存管理与错误处理里,几乎每个请求都会触及;修复方案存在真实的竞争取舍——改哈希表的顺序会破坏大量依赖现行为的行为与扩展;加检查会引入新的拒绝服务风险;改错误处理语义会要求大量第三方扩展修改,而这种协调几乎不可能完成。这些取舍不会因为早两年出现一条 PoC 而变得简单。

所以这条分支能推出的结论是:可能更早开始,不一定更早结束。把“2024 年就该修完”当作定论,等于假设 2024 年的决策能力比 2026 年更强——材料里没有任何东西支持这个假设。

第二条分支:更早泄露。

如果 2024 年有人做完了,而且细节以某种方式扩散出去——不必是公开,只需要落到不特定的人手里——那么 2024 年到 2026 年之间会是一段完全不同的历史。那时的防守准备度是多少?检测规则还没有被整理出来(那套“异常超长字段名、Unknown on line 0 的致命错误、worker 段错误、shell 派生”的三层关联,是 2026 年才成形的);缓解措施还没有清点过(连“关掉 session.upload_progress 只能切断一条路径”这种结论都是事后才知道的);CERT-EU 没有公告,镜像仓库没有补丁,主机上跑着的 8.x 和 2026 年的部署形态几乎一样。

于是这条分支的图景是:一场没有预警、没有检测、没有补丁、持续两年的 N-day。

两条分支放在一起,得到的东西比“应该早点做”更复杂。它说明“更早知道”不是纯收益。知道得更早,只有在“知道之后能立刻动”的前提下才是净好处;如果动不了,早两年知道只是把风险从“潜伏”改成“暴露”,而暴露的方式不由你选择。

这也把本章的论点校准了一下。我说 AI 补的是“证明”这一环,但证明本身不是一个终点。证明之所以重要,是因为它能把一条无法记账的风险转换成一条可以排期的风险;如果体系在排期这一段卡住,证明的价值就兑现不了。第 9 章会把这个卡点写成“三个速度”,第 11 章会写成“瓶颈在人身上”。

现在必须说第三条,也是最要紧的一条:假设推演不能替代事实。

关于 2024 年,材料支持的只有一句话:核心开发者在修复审计发现的问题时碰到了 109618 的最初迹象,判断理论上存在利用可能,但找不到具体攻击路径,于是搁置。这里没有会议记录,没有谁说了什么,没有内部争论的细节,我也不会替他们补台词。任何比这更细的叙述都是虚构,而虚构在这里的代价特别高——因为它会把一个关于制度的问题,误读成关于某几个人的问题。

本章的结论不是“他们错了”。他们在信息不完备的条件下做了一个可辩护的判断,而且那个判断在当时是对的。

那么,这件事的连锁后果是什么?这里只点到,展开留给后面。

对漏洞赏金而言,“可证明”从稀缺变成了相对充裕,价格的锚会移动:一份只有理论描述的报告,中位价会下降;一份带可用利用的报告,溢价会上升;“证明”从研究者的内部工序,变成一个可以被单独定价、单独外包的环节。

对 CVE 数量而言,需要记住一个技术事实:CVE 登记簿记录的是“已确认”,不是“已利用”。当证明变便宜,进入登记簿的条目会通胀;通胀之后,任何用数量做风险仪表盘的组织都会系统性失准。

对维护者负担而言,算术很直白:上游的证明成本下降,意味着进入维护者队列的“必须处理”条目变多,而处理能力不变。队列会变长,而队列的长度会反过来影响每一个条目的处理质量。

现在把这一章收在一句话上。

可证明性本身就是一种安全资源。

这句话要展开成三个动作才站得住。第一,一个能被证明的漏洞,才有价格、有排期、有公告、有补丁——它进入了账本,因此可以被管理。第二,一个不能被证明的漏洞,在风险管理体系里的当量是零,无论它的实际危险有多大——它不在账本上,因此不存在。第三,AI 改变的正是记账的能力:它没有让世界上多出一个洞,它让一个已经在账上却无法计价的条目,突然有了明确的数字。

2024 年的 109618 不是一个被忽视的漏洞。它是一个无法被记账的风险。这两者的区别,是这本书想说的全部。

而记账这件事本身是有工序的——机器在这条工序上究竟扮演了哪几个角色,它读代码时在做什么,它写利用时在做什么,它设计检测规则时又在做什么,是下一章的事。

第 8 章 机器如何证明一个洞能打穿:AI 的四个角色

“发现漏洞、开发利用、攻击检测、验证补丁——四个环节都在这条工具链的贡献面里。”

——Google Cloud 博客对 Gemini 4 Argon 在本事件中贡献范围的表述,2026 年 8 月

本章只回答一个问题:把“AI”这个词拆开之后,它在这件事里究竟做了几件不同性质的工作?

第 6 章讲了三支队伍如何在两周内抵达同一个根因,第 7 章讲了 2024 年那次被搁置的判断。两章里“AI”都出现在主语的位置上,好像它是一个统一的行动者。这一章要把它挪到宾语的位置:在哪一个环节、被要求交出什么东西、交出物的判据由谁给。按 Google Cloud 一侧的官方口径,模型在这件事里的贡献面是四段:发现、利用开发、攻击检测、补丁验证。四段的技术难度不同,判据不同,可替代性也不同。把它们混在一起谈,是当前关于“AI 安全能力”的大部分误解的来源。

8.1 角色一:读代码的机器——语义推理与模式匹配的差别

读代码是模型最广为人知的能力,也最容易被理解成“一个更聪明、更不知疲倦的 grep”。这个类比有一半是对的,另一半错得关键。

先看对的那一半。给定一个大型 C 代码库,模式搜索能做到的事比大多数人以为的多:统计某个计数器的全部出现位置,找出某个分配函数的每一个调用点,按已知的漏洞形状批量生成候选。这些工作的共同点是判据在文本里:一串符号、一个先后顺序、一种配对的缺失。安全行业过去十年里的静态扫描这条线,本质上就是把人类积累的坏形状一条条写成规则。

现在看错的那一半。109618 的洞,在文本上不是坏形状。

它的形状是这样的:一个函数取当前水位当索引,水位加一,元素计数加一,然后才去为键复制一份字符串。前三行是算术,算术不会失败;第四行是唯一可能失败的操作,而这个函数在失败位置上的约定不是返回错误码,是直接报致命错误、沿 bailout 路径跳出执行栈(第 3 章详细讲过这条路径)。

请把这段描述当成一个纯粹的文本看:它没有任何一行是可疑的写法。先记数、后分配,是 C 代码里最常见的次序之一;不做失败处理,在这个函数里是正确的,因为失败根本不会返回。静态规则库里没有任何一条会把这两行标红,因为它们都不违反自己所在函数的局部契约。

它违反的是一个从未被写下来的全局不变量:一个被计入“已用”的槽位,其内容必然已经被初始化。这句话不在任何头文件里,不在任何注释里,它是所有使用这张哈希表的人共享的隐含前提。洞的位置就在这里——不变量被一条完全合法的错误处理路径打破了,而打破它的方式是“少做了一件事”,不是“多写了一个错”。

要让一个读者判断出这是 bug,他必须同时握有四个事实,并让它们互相咬合。

第一,那个计数器是“已用槽位数”的权威表达,它不只用于循环上界,还被下游的清点逻辑当作事实来源。第二,紧随其后的那次分配,是这个函数里唯一可能失败的操作,且失败的表现形式是长跳转而不是返回值。第三,shutdown 阶段的清理会以这个计数器为上界遍历,因此那个槽位会被当作合法值读取。第四,这个槽位的内容从未被写过,里面躺着的是上一次使用这块内存的人留下的字节。

这四个事实分散在不同的文件、不同的函数、不同的调用层次里。grep 能把每一个名字都找出来,把它们的全部出现位置列成一张表。但“这个函数里的加一”与“另一个文件里循环的上界”是同一个量——这个认同动作,任何模式匹配都做不到,因为它需要对两个不同语境里的符号做出同一个语义解释。这就是语义推理与模式匹配的差别所在:模式匹配找的是“以前见过的坏形状”,语义推理找的是“这次看起来正常的形状里少了一个前提”。前者的知识来自历史,后者只能来自被读代码自身的语义。

还有一层更硬的要求:模型必须判断这个破坏是否可达。写法本身不违规,只有当那次分配真的失败时才算被破坏;要判断“能失败”,需要知道 Zend 内存管理器在 memory_limit 之下的行为,需要知道 bailout 不可返回,需要知道请求内存池的水位可以被前置字段推高。这些是执行模型的推理,不是文本特征的推理——第 3 章那个三点五兆字节的字段名成立的前提,正是有人想通了“必须让连续余量不足,而不是让单次申请超过上限”。

所以角色一的产物是一个判断,不是一个坐标:这里存在一个被错误处理路径破坏的不变量,并且破坏可达。它可被争论、被推翻,需要由别的环节证实。

这也是这个角色最现实的代价:假阳性。模型会把每条看起来相关的路径都读完,交出的是候选集,不是结论;筛选成本随阅读量上升。这里有一个可参照的数字,但要小心口径:按 PHP 基金会的披露,自 2026 年 7 月 1 日起 PHP 已发十五个 CVE,同时处理了一百多份无效、重复或不可复现的报告。这个数字说的是报告处理的负担,不能直接归因于任何一家模型的输出,但它指出的现实清楚——读得越多,判断哪些值得看就越贵。

最后要说清这个角色的可替代性。三家机构都在这个环节用了模型,并且独立抵达同一个根因(第 6 章)。独立复现说明这不是单点运气,而是一项可以被重复购买的能力;这类能力的门槛通常在算力、上下文长度与代码库的可读性上,不在人的经验上。

一句话定性:在发现这个环节,机器替代的是阅读量,不是判断力。

8.2 角色二:写利用的机器——从可疑到可执行的工程链条

8.1 的产物是一句判断,8.2 的产物是一次事件。判断可以被争论,事件不能。

这就是“可利用性证明”这一步在整个漏洞经济里最贵的原因:它把一堆可以被合理质疑的推断,压成了一个别人重跑一次就能看见的结果。第 7 章讲过这一步有四道技术门槛,本节不复述它们的机制,只看它们在工程上是什么性质的工作。

第一道是布局:伪造出来的结构要落在哪,引擎顺着那个半初始化槽位里的指针去读的时候,读到的必须是攻击者安排好的字节。第二道是整形:请求内存池里要先铺一层可控的残留字节,同时池子的水位要被抬到“下一次分配必然跨过余量”的临界。第三道是地址:进程启动时栈、堆、共享库的基址都被随机化,伪造的指针不能靠猜,必须有一次能把进程内存字节带回请求方的泄露。第四道是适配:目标的内存上限、SAPI、扩展集合不同,前三道的参数就要重算。

四道门有一个共同点,而这个共同点解释了模型为什么在这一段表现出色:每一道门都有明确的成功判据,而且判据是外部的、可测量的。布局的判据是引擎读到的字节能否被解释成期望的结构;整形的判据是崩溃位置是否稳定复现;地址的判据是泄露出来的字节里是否含有仍然有效的指针;适配的判据是同一套逻辑换一个镜像之后是否仍然命中。判据清楚,就意味着这件事可以被一个“失败—诊断—改参数—重试”的循环推进。

素材里对 Wiz 方法论的描述值得逐字读:模型会“结合调试工具和测试结果,分析攻击失败原因、寻找替代路径、针对不同环境调整方案”。这句话描述的不是一次生成,是一台机器在失败之后的行为。利用开发的第一版几乎必然失败——布局没中、水位算错、偏移过时、泄露拿不到。人类研究员面对一次失败,要读崩溃栈、重建假设、重新布置,一天可能只跑三五轮;一个把调试器、测试环境与模型封装在一起的系统,在同样的时间里可以跑几百轮。Wiz 的自述口径是:过去需要世界级团队数月的工作,一名研究员一周完成;针对不同部署环境的适配从数周压缩到数小时。这是厂商口径,读的时候要打折,但它指的机制是可以独立验证的:被压缩的不是创造力,是重试的间隔。

这里需要一次横向对照,才能看清这一段在行业曲线上处在什么位置。2025 年 6 月,XBOW 成为首个登上 HackerOne 美国排行榜第一的非人类参与者,约九十天内提交了一千多份报告(榜单位置是第三方事实);其自述口径是:一百零四个真实世界挑战用二十八分钟完成,人类渗透测试员平均四十小时,约八十五倍。这个数字要带着口径读:挑战目标是被裁剪过的,有清晰的“成功”定义,环境是给定的。但它说明的事情很清楚——判据清楚、目标同构时,机器在这一步上的相对优势是数量级,不是百分比。

把这条对照放回 PHP2Shell:Wiz 在 WordPress、Drupal、Joomla 的当时最新容器镜像上演示了未认证远程代码执行,把多个 bug 串成单一利用脚本。真正值钱的不是“串起来了”,而是“在当时最新镜像上”。镜像是一个被构造出来的世界:版本已知、配置已知、扩展已知、可以无限次重入。所有关于“机器会写利用”的叙事都建立在这个世界之上,这一点在 8.5 会变成一次反证。

角色二还有一条性质:它的产物是可执行的物件,不是论断。一个能派生 shell 的脚本,价值不依赖作者的信誉。这既是它在披露流程里如此有分量的原因,也是它一旦泄露就无法收回的原因——2026 年 10 月 20 日那段演示视频被误发后删除,但已经扩散,这件事的技术前提正是“利用是可执行的物件”。

一句话定性:在利用开发这个环节,机器不是更聪明,是更不知疲倦;而不知疲倦之所以值钱,是因为这一步的成本几乎全部由重试次数决定。

8.3 角色三:设计检测的机器——从利用特征反推规则

第三个角色最不显眼,却最反常:模型在这里同时持有攻击侧与防守侧的知识。它刚刚亲手把一个洞推到可执行,现在要用同一套理解去设计“怎么发现有人正在这么做”。

检测规则的常规来源是情报、事故复盘与恶意样本。这一次的来源不同——规则是从一次自己完成的利用反推出来的。反推的起点不是“我见过这个 payload”,而是一个更强的问题:攻击者为了成功,有哪些事是他不能不做?

这个问题可以问出三层答案,而这三层恰好构成了本次事件公开的检测规则(第 3 章末尾已经列出,这里看它的构造逻辑)。

第一层是请求形态。要让那个槽位出现,攻击者必须先做堆整形,也就是往请求内存池里铺一层可控的残留字节;同时要把池子的水位抬到临界。落地到 HTTP 上,这就是两个非常具体的特征:一个内容含原始空字节的空名字段,和一个长度约三点五兆字节的字段名。这两个特征都不是“攻击者的偏好”,是机制的必然——整形必须有地方放字节,撞墙必须有一次足够大的分配。

第二层是故障痕迹。撞墙那一下会在日志里留下致命错误:Allowed memory size of 16777216 bytes exhausted ... in Unknown on line 0。这条日志的形状本身就是证据:内存在请求初始化阶段被耗尽,脚本代码尚未开始执行,所以没有文件与行号。第三层是能力确认:worker 进程派生出了 shell,或 Apache 记下 AH00052 ... Segmentation fault (11)。

三层必须放在一起看,这是整套规则设计的核心。任何单独一层都能被解释成正常:内存耗尽在运维里是日常,段错误可能是别的 bug,worker 派生进程可能是定时任务、图片处理库或某个运维脚本。三层同时出现在同一个时间窗里,才构成一个不可辩解的组合理由——先是有人把池子撞穿,然后是引擎在清理阶段误判了内存,最后是那个被误判的内存带来了执行能力。它的说服力不来自任何单一信号的强度,而来自三者的因果咬合。

为什么说这是建模任务而不是规则编写?因为要从一个成功案例推出一类利用的不变量,必须回答“哪些事是攻击者绕不过去的物理约束”。撞墙绕不过:不让分配失败在那一行,半初始化槽位就不会产生。整形绕不过:槽位的内容必须可控,否则引擎读到的字节没有意义。能力获取绕不过:要执行命令,就必须让引擎走到某个回调上。这三条约束是从机制推出来的,不是从样本里统计出来的。由此得到的规则有一个样本规则不具备的性质:它对尚未出现的变体仍然有效,因为变体也不能违反这三条约束。反过来,一条只匹配已知 payload 字节序列的规则,只要攻击者把字段名长度改到三点四兆,它就失效了。

模型在这个角色上的优势是结构的:它刚刚在 8.2 里为了构造攻击而必须理解的那些约束,正是设计检测时要用的约束。同一套语义理解,换一个方向使用。这也是本书反复说的攻防同源在技术层面的具体含义——不是“同一个模型两边都能用”这种笼统说法,而是“为进攻而建立的约束模型,可以直接翻译成防守的判据”。

但这个角色在真实环境里的难处,与模型能力无关,与数据有关。

第一是可见性。WAF 与代理经常对大请求体做截断或丢弃,三点五兆的字段名恰好落在会被截断的区间里,规则就永远不匹配——你以为在看请求,其实在看被裁剪过的请求。任何检测规则的隐含前提都是“我看得见完整输入”,这个前提在多数生产栈里并不成立。

第二是日志源的异构。字段名、时间戳精度、worker 身份的表示方式,在 FPM 日志、Apache 错误日志、容器运行时日志里各不相同。三层关联听起来是布尔组合,做起来是一次跨数据源的实体对齐。

第三是假阳性的价格。三点五兆的字段名罕见,但不是零:大表单上传、富文本编辑器、base64 编码的图片都可能造出超长字段。运维最怕的不是漏报一条,而是规则每天误报三十条,一周后所有人都学会忽略它。规则的质量因此不看它覆盖多少种攻击,看攻击者绕过要付多少成本。

产品侧还有一个延伸,属于厂商口径:Wiz Sensor 1.0.12768 内置响应策略,可自动终止表现出 PHP2Shell 利用行为的进程。从检测走到响应,判据必须更保守——误杀一个 worker 就是一次业务中断,两者不可能共用同一个阈值。

一句话定性:设计检测规则,是把攻击的物理约束翻译成防守方数据里的布尔组合;机器在这里快,是因为它同时握着两侧的知识。

8.4 角色四:验证补丁的机器——关闭攻击面与挡住已知 PoC 的区别

四个角色里,这个最不像能力展示,却最难。

补丁本身不复杂。按第 3 章末尾的描述,修复绕开了那两行:不再在可能失败的操作之前把计数推上去。改动很小,小到外行会认为“既然只要改一个顺序,这件事从一开始就不该拖两个月”。这句抱怨之所以错,是因为它把“改对”和“确认改对了”当成了同一件事。

验证补丁时,“修好了”这三个字至少有三个不同含义,它们之间的差距就是本节要分辨的东西。

第一个含义是:已知的 PoC 不再成功。把公开的那条攻击请求重放到打补丁的构建上,进程不崩、不派生 shell、日志里没有那段致命错误。这一层的判据极其明确,可以写成一条回归测试,可以让机器每天跑一万次。它必要,也最便宜。

第二个含义是:同一根因的所有入口都被关闭了。109618 的根因不是“某一行写错了”,是“先更新元数据、后分配内存”这个模式在一条失败路径上留下了一个不可回滚的中间态。只要这个模式还在别处存在,洞就还在,只是换了一个入口。要确认这一层,验证者必须从根因出发重新枚举入口:还有哪些地方在可能失败的操作之前修改了会被下游当作事实的状态?这需要把一条局部写法抽象成一个模式,再回到整个代码库里找它的全部实例。防守侧引入 OpenAI 的 Daybreak Red 之后做的正是这件事——素材对它的描述是“验证漏洞报告与 PoC、按相同根因给报告分组”。分组看起来是去重,实际上是在建立“一个根因可以有多少个入口”的清单,而这份清单正是第二层验证的前提。

第三个含义是:语义层面的模式不再存在。这一层要求改变一个约定,比如让计数只在操作成功之后才推进。这类约定横跨四个受支持分支,横跨一个不在 PHP 自己控制下的扩展生态:任何依赖“计数先加、内容后到”这一现行为的外部代码都会受影响。到这一层,问题已经不是验证,是设计,属于 8.6 要划的边界。

于是本节的核心判断可以说出来了:把第一个含义当成全部,是这次事件里最容易被犯、代价也最大的错误。

PoC 是一个点,攻击面是一个集合。补丁的作者与验证者共享同一个盲区——他们都只看得见自己想到的那条路径。一条被 PoC 证明“已挡住”的补丁,只证明了这一条路走不通,没有证明别的路也走不通。这一点有独立来源支撑:Datadog 的判断是,补丁修掉了已知的利用,但“先更新元数据再分配内存”的模式本身仍然有风险,同类漏洞会再来。

那么为什么说验证比生成更需要判断力?因为生成的成功判据在外部,验证的失败判据是缺席的。写利用时,“跑通了”就是跑通了,一个能派生 shell 的脚本不需要任何人相信它。验证时,你要交出的是一句关于“没有”的断言——没有别的入口,没有别的触发点,没有别的配置能把补丁绕过去。而“我没有找到反例”与“不存在反例”之间隔着一整片未知。人类在这件事上的历史表现很差,因为缺席的证据天然不显眼:一个没被想到的路径,不会在任何人面前举手。

要支撑那句断言,唯一的办法是主动构造反例,这实际上是把可利用性证明反着再做一遍,做很多遍。换 SAPI:FPM 与 mod_php 的进程模型不同,mod_php 的风险在事件里被列得最高,两者的验证结论不能互相替代。换内存上限:从十六兆的老配置到五百一十二兆,水位不同,参数就要重算,重算之后还能不能触发是另一个问题。换扩展组合:堆布局由扩展决定,装了图像处理的构建与什么都没装的构建,内存地图不一样。换请求形态:urlencoded 与 multipart 走不同解析路径。换触发点:致命错误不止内存耗尽,超时、递归深度、断言失败都会走 bailout,都会在同一位置留下中间态。每换一个维度,都是在反方向再做一次可利用性证明。

这里还有一个结构性的不对称,它解释了这个角色为什么最难被替代。生成方错了,代价几小时后就会以“跑不通”的形式出现,循环会自己纠正它。验证方错了,代价在半年后才出现,而且出现的形式是“有人用另一条路打了进来”——那时没人能回到当初那份验证报告,去判断它是疏忽还是不可能。换句话说,验证是这条流程里唯一不能靠“多试几次”提升质量的环节:试错提升的是覆盖率,不是断言的真值。

模型在这个角色上的价值是真实的:它可以不知疲倦地跑那几十种维度组合,可以在每一轮之后读日志、比对崩溃位置、判断这一轮算不算触发。这件事人做不完,做了会烦。但它交出的永远是“未发现反例”。把这句话读成“已关闭攻击面”是一次解读,而这次解读由人负责。

一句话定性:补丁验证的产物不是结论,是一份关于“哪些路已经确认走不通”的清单;清单以外的地方,仍然是人的责任。

8.5 闭环为什么重要:调试工具、sanitizer、复现环境

第 6 章拆过安全 AI agent 的四层结构,这里看循环内部的时间形状:一次“猜想—测量—修正”里发生了什么,为什么只有它能产出可核验的结论。

循环里站着三件装置,各回答一个不同的问题。

调试器回答:这次失败发生在哪一步。一次尝试失败,可能败在布局没落对位置,可能败在整形把池子填多了、更早的一次分配就撞墙,也可能败在泄露拿到的是过期指针。这三种失败在“脚本退出”这一层面上长得一样,只有把进程停在崩溃点上、看清寄存器与栈,才知道下一步改哪个参数。它是诊断的唯一入口;没有它,模型的第二版修改与第一版的失败原因未必有关——这一点是“一次性生成”与“循环迭代”的分界线。

sanitizer 回答另一类问题:这次没崩,是不是其实已经越界了。这是三件装置里最反直觉、也最被低估的一件。未定义行为经常不崩——堆布局恰好无害时,越界读会读到一个合法但错误的字节,程序继续跑,什么都不发生。对靠失败信号前进的机器来说,“什么都没发生”是最坏的反馈:既不是成功也不是失败,没有梯度可循。ASan 与 UBSan 的价值就在这里:它们不关心你在找什么洞,只检查内存访问是否合法、类型与算术是否合法,然后把那些静默的越界变成一条带栈的报告。它们把“没有反应”翻译成“有反应”,等于把一大片黑暗变成了可测量的地面。

复现环境回答第三个问题:这个结论在发布产物上成立吗。这一条对 109618 特别致命,因为触发依赖内存水位,水位依赖配置,也依赖堆布局,布局依赖扩展集合与编译选项。素材里那句话值得重复:PHP 基金会在验证时使用的是与各支持平台 CI 匹配的复现环境。它的技术含量远高于读起来的样子——版本、SAPI、编译选项、默认扩展、内存配置都与官方构建一致。任何一处不一致,你复现的就不是那个 PHP,结论也没有迁移价值。

三件装置合起来做的是一个很朴素的动作:把模型的陈述变成别人能重跑的事件。

“我认为那个槽位可能被当作数组析构”是一句陈述,只能被相信。“在 8.5.11 的 FPM 镜像上,以十六兆上限,这条请求在 shutdown 阶段触发了对非法地址的读,ASan 报告如下,栈如下”是一个事件,可以被第三方对着同一份镜像重跑。前者是裸模型的典型输出,后者是 agent 的终端产物。差别不在“能不能调用工具”,而在两件事:每一次测量是否留下可核验的中间物,以及下一步是否被上一步的测量结果约束。

横向参照一次:PHP2Shell 不是孤例,它只是曲线上被全世界看见的那个点。

2016 年 DARPA 的 Cyber Grand Challenge 第一次证明机器速度的攻防在受控条件下可行;2024 年 Google 的 Big Sleep 成为首个在真实软件中发现此前未知漏洞的 AI 系统,目标是 SQLite;2025 年 Big Sleep 又报告了约二十个广泛使用开源软件中的发现,其中包括 CVE-2025-6965,据称提前阻断了即将发生的利用。2026 年 4 月 7 日,Anthropic 发布 Claude Mythos Preview 与 Project Glasswing;同年 5 月,Mozilla 披露由 Mythos 驱动的流水线在 Firefox 150 中发现了二百七十一个潜伏缺陷,其中一百八十个高危、八十个中危、十一个低危,含一个十五年前的 element bug 与一个二十年前的 XSLT use-after-free。同月,Cloudflare 在五十多个自有仓库上应用 Mythos,并报告两项前代模型做不到的能力:从低危原语构造完整利用链;在无人工干预下生成可编译、可运行、能确认可利用性的 PoC。

这条曲线上,被机器吃掉的是三段:发现、可利用性证明、检测规则的生成。没被吃掉的那一段,从 2016 年到 2026 年几乎没动。PHP2Shell 的位置因此可以说得更准确:它不是第一个,也不是最强的,它是第一次让这三段同时作用在一个承载着大约七成互联网部署的软件上,并且第一次让三段之间的速度差被全世界在同一周里看见。

一段必须写的反证:为什么“模型擅长利用开发”被高估了。

第一条:依赖人类构造的环境与判据。Wiz 的演示之所以成立,前提是一组容器镜像、已知配置、可反复重入的场地,以及“派生 shell”这个明确的成功定义。这四样都是人给的。真实网络里的目标不是容器镜像:它有 WAF、有非默认的扩展集、有被运维改过的内存上限、有出网限制、有基于行为的检测。同一个洞,在镜像里是一小时的事,在真实资产上可能是三周的事,这个差距不由模型能力决定。

第二,成功率随目标异构度下降,且下降方式不利于通用化。Wiz 自己的表述是“跨部署适配从数周压缩到数小时”——压到小时,不是压到零。适配成本仍随目标数量线性增长;防守方资产有限、攻击方目标无数,这个不对称在自动化之下没有消失,只是变小了。

第三,公开数字的口径普遍偏乐观。那八十五倍来自一百零四个被裁剪过的挑战;AIxCC 的识别率是百分之八十六的合成漏洞,半决赛只有百分之三十七,真实世界里另有十八个未植入的漏洞被发现、十一个可行补丁被提交——后半段才是实战的水位。合成与真实之间的落差在这类数据里反复出现,媒体通常只引前半段。

回应这三点:它们不否定能力,否定的是“通用化”的想象。模型擅长在给定环境里把一个已知类型的判断推到可执行,不擅长发现环境本身长什么样;而后者在真实攻防里恰好是最贵的一步,因为它需要接触目标。这也解释了本次事件里最有效的用法为什么不是“一个模型打穿世界”,而是“一个研究员带着一个 agent 打穿一个已知目标”。

一句话定性:闭环不提高模型的正确率,它提高的是错误被发现的速度——而这一次,错误被发现的速度恰好是整件事的关键。

8.6 边界在哪:模型做不到的三件事

素材里有一段话,是本章的分界线,也是全书的分界线。按 PHP 基金会的口径,模型能验证报告、能给报告归组,但不能决定引擎中长期设计问题的正确修复方案,不能权衡兼容性取舍,不能审查补丁。这三件事不是“目前还不行”,是性质不同。

第一件:决定长期设计问题的正确修复方案。109618 的修法有三条路,每条都有代价。改哈希表的插入顺序,会破坏大量依赖现行为的代码与扩展;加一次检查,会引入新的拒绝服务风险,攻击者可以用更小的代价把请求推进同一条失败路径;改错误处理的语义,则要求大量第三方扩展跟着修改,而协调几乎不可能完成。三条路都能让洞消失,代价分布在不同的人身上。选哪一条,是一个关于“让谁承担”的决定。

第二件:权衡兼容性取舍。PHP 的兼容性承诺横跨四个受支持分支、十年、一个不在自己控制下的扩展生态。这个生态里的大部分依赖没有被写下来:它们散在别人的代码里,只有在行为改变之后才会以 issue 的形式回来。模型可以把三条路的利弊写成一张很漂亮的表,事实上它做得比大多数人快,但它无法为取舍定价,因为定价需要承担。

第三件:审查补丁。审查不是找 bug。找 bug 问的是“这段代码有没有问题”,审查问的是“这个改动在别人的代码里会发生什么”。后者需要知道谁依赖了现行为、依赖到什么程度、有没有替代写法,而这些知识大部分不在代码库里。

三件事的共同性质可以说出来了:它们都需要对整个系统的历史与约束负责。这个“负责”不是道德修辞,是技术性的:它意味着你的判断在出错时要由你承担后果,而后果的分布会反过来塑造判断本身。一个不需要承担后果的主体,可以给出很多正确的观察,但无法完成一次取舍——取舍的本质不是选出最优项,是接受某一项的代价。

这也是为什么“模型输出没有成本”是双面的:它让模型一天能提出三百个方案,也让这三百个方案里没有任何一个带有重量。基金会的表述很克制:做正确的修复决策需要理解整个 PHP 代码库的经验工程师,而这样的人太少。重点不在“工程师”,在“太少”——这是关于注意力的经济学判断,不是关于模型能力的判断。

有一个可检验的推论能验证这条边界是否真实:如果模型真能替代这三件事,我们应当看到修复速度也跟着塌陷。事实相反。从 8 月 11 日多路确认完整影响,到 10 月 21 日补丁发布,中间隔了两个多月;期间方案反复停滞与重启,基金会自己承认这是“结构性失败”,最终要靠基金会资助的多人全职、跨周末、昼夜协调才收住。发现成本塌了,证明成本塌了,修复成本没动。

四个角色的可替代性可以排一次序。

发现的可替代性最高:判据在文本与语义里,可以并行、可以重试、可以三方独立复现,门槛在算力而不在人。检测规则的设计次之:需要对机制的理解,但产物是一组能立刻上线、并被反馈修正的判据。利用开发居中:判据清楚但依赖环境,成功率随目标异构度衰减,它的效率提升是真实的,它的通用性是虚构的。补丁验证的可替代性最低:它要交出的是一句关于“没有”的断言,判据缺席,错了要负责,而这个负责没有捷径。

而在这四者之外,还有第五件事,模型完全不在场:决定怎么修。

本章的结论不是“AI 比人强”,也不是“AI 不如人”。结论窄得多:在这条从“可疑”到“必须立刻处理”的链条上,机器已经能走完前四段中的大部分,而链条的最后一段——决定把代价放在谁身上——至今没有任何一段被自动化。把这两件事混着谈,会让防守方高估自己能处理的速度,也会让维护者低估自己剩下的工作。

如果四个角色里有三个的成本都已塌陷,只有第五件事原地不动,这个行业的价格结构会发生什么?第 9 章要算这笔账:三段成本、三个速度,以及那个已经变成负数的 N-day 窗口。

第 9 章 三个速度与三段成本:攻防经济学被改写

这不是关于 AI 会不会发现漏洞,而是关于永久性地关闭“漏洞被发现的速度”与“组织能响应的速度”之间的差距。

——SANS 与云安全联盟《The AI Vulnerability Storm》,2026 年 5 月

本章只回答一个问题:当发现一个漏洞的边际成本降到接近于零,而修补它的边际成本几乎不动,压力会流向哪里?

前面八章讲了同一件事的侧面:洞在哪里(第 2 到 4 章)、影响多大(第 5 章)、谁先看见(第 6 章)、为什么两年前看见却没修(第 7 章)、机器各干了什么(第 8 章)。这一章只看价签:把发现、证明、修复各自标上价,看 AI 在哪个环节改了价签、改了多少,以及当三个价格不再同步变动时压力会堆到谁身上。

9.1 三段成本:发现、证明、修复

先说清三段各是什么,以及每段成本由什么构成。这个问题容易被简化成“时间”,但时间只是表象:三段成本的构成不同,它们对同一个技术变化(模型能力提升)的反应也不同。

发现成本。发现是“知道某段代码可疑”。成本由三块组成:人力是能读懂这套代码的人,时间是把代码库读完并建立调用关系图的工作量,判断力是决定“先读哪里”的取舍能力。第三块常被忽略:读代码的人必须先用某种直觉排出优先级——内存管理、序列化、解析器、边界处理值得先看,模板渲染不是。这种排序能力不体现在工时里,体现在“读完有没有收获”上。人类审计一个新代码库,按素材口径是数周到数月。

证明成本。证明是“从可疑变成可打穿”,这是三段里最贵的一段,成本构成也最畸形:人力要求会写利用的人,这类人在行业里是少数;时间要求反复试错,因为每一次失败都需要被解释;判断力的权重最高,因为走通的标志不是“找到一个错误”,而是“确认这条路在真实部署形态下可达”。第 7 章列过那四道门:布局、整形、泄露、适配。其中三道失败时给不出清晰信号,只有一次崩溃或一个静默的错误值。所以证明的成本大头不是敲键盘,是诊断。

这个成本结构有一个重要后果:证明是一段可以被合法搁置的工作。因为它不确定——你不知道再投两周能不能证明出来,也不知道证明出来算不算严重——所以它总被排到队列末尾,而这个决定在事后看起来总是合理的。2024 年 PHP 核心开发者看到 109618 的最初迹象,判断“理论上可利用”,找不到具体攻击路径,工作搁置(PHP 基金会口径)。这里没有失职:一段投入产出比不确定的工作,在人力紧张的志愿项目里被推迟,是任何管理者都会做的决定。

修复成本。修复是“改掉根因、保证不砸坏别人、然后发出去”。人力是有引擎知识的核心开发者,这类人比会写利用的还少;时间被兼容性权衡与评审占据,不是写补丁的时间;判断力是决定“改哪一层、牺牲什么”,这是三段里唯一无法被外部工具替代的一段。第 11 章会展开三种方案,这里只说一个事实:修复成本不随发现量下降,甚至随影响面上升。当你知道这个洞影响 70% 的互联网(Wiz 的暴露面估计),修法就从“最快”变成“最不容易造成二次事故”,两个目标经常相反。

三段成本并排看,AI 改动的幅度是:

发现这一段,AI 把成本从人月压到 token 级别(数周到数月对“数天内给出可验证候选集”,素材 04 口径)。变化是量级上的,而且同时消掉了第三块成本——排序:模型不必先决定读哪里,把代码全读一遍就是,代价是上下文与调用费用。取舍能力从稀缺资源变成可以花钱买的资源。

证明这一段,塌陷得最彻底,而这一点最容易被误读。多数关于“AI 找漏洞”的讨论停在发现这一段,然后得出“AI 让漏洞变多了”的结论。但在 PHP2Shell 里,2024 年就有人知道洞在哪。真正让它在 2026 年变成全球应急的,是证明这一段被跨过了:Calif 从 2026 年 7 月 30 日开始审计 PHP(Alpha-Omega 的 Patch the Planet 计划,用 GPT-5.6 Sol),8 月 11 日报告“无条件远程代码执行”,8 月 14 日提交 PoC(其自述口径);Wiz 从 8 月 19 日到 8 月 24 日,用 Wiz Atlas 加 Gemini 4 Argon 与 GPT-5.6 Sol,从第一个 PHP 漏洞做到对 WordPress、Drupal 的未认证 RCE 并上报同一根因(Wiz 口径)。

两段时间放在一起:2024 年到 2026 年 8 月,同一步“证明”悬置约两年;2026 年 7 月 30 日到 8 月 14 日,同一步用了十五天。素材给的对比只有 Wiz 一句自述——一名研究员一周完成过去世界级团队数月的工作,这是厂商口径,不能当测量结果。但时间线是硬的:证明的可完成性,在两年里从“找不到攻击路径”变成“两周内交出 PoC”。

修复这一段,几乎没动。8 月 11 日之后这个洞成为 PHP 项目的最高优先级,多人全职、跨周末推进,到 10 月 21 日发布,约七十天。这七十天不是浪费,是被几种互相冲突的约束占满的。尾端还更长:约一半的 PHP 部署跑在停止支持的分支上(第三方估算),对它们来说修复成本不是七十天,是“永远修不完”,只能靠最小源码 backport 与发行版的覆盖速度。

所以三段的形态是:前两段塌陷,第三段不动,第三段的尾端还在变长。

这里要下一个比“效率提升”更重的判断。证明成本的塌陷,取消的不是工作量,是拖延的正当性。过去一个维护者可以说“我知道这里有问题,但不知道能不能被利用,先放一放”——这话有据,因为证明要花数月,失败概率很高。2026 年之后这话没有据了:三家机构在两周内独立交出可利用证明,而独立复现说明它是可重复购买的能力(第 6 章)。当“能不能被利用”的答案可以在一周内买到,继续以“不确定”为由推迟,就从谨慎变成了失职。

一句话定性:证明成本的塌陷,改写的不是工作量,是延迟的合法性。

9.2 三个速度:发现、披露、修复

成本是存量,速度是流量。同一件事可以看两头:一段工作要花多少资源,以及它在日历上走得多快。三段成本的剪刀差,投影到日历上就是三个速度的剪刀差。

发现速度。这件事的“发现”有三种算法,用哪个必须说清楚,否则数字会互相打架。

第一种,从零到知道洞在哪:约两年(2024 年看到最初迹象,到 2026 年 8 月三方独立抵达)。这个数字不该被算成“AI 发现的”——2024 年那一步是人做的。

第二种,从开始审计到交出可利用证明:Calif 是 7 月 30 日到 8 月 11 日,十二天;若按其自述的发现日 8 月 1 日算,到 8 月 14 日提交 PoC,十三天。

第三种,从发现第一个洞到把攻击面铺到主流 CMS:Wiz 是 8 月 19 日到 8 月 24 日,五天(8 月 20 日确认 Drupal,8 月 21 日确认 WordPress)。

三种算法给出两年、十二天、五天。本书用后两种,因为第一种混入了 2024 年的历史,不构成 2026 年的能力观测。第二个性质是并行:三路不是接力,是同时跑。Calif 在 7 月 30 日开工时不知道 Wiz 会在 8 月 19 日开工;OpenAI 在 8 月 11 日独立报告同一根因。发现之所以快,不只因为单路快,还因为路上同时有几个人且互不知情。

披露速度。日历上看起来只是“提前了一天”,实质上完全不同。

原定发布是 10 月 22 日 14:00 UTC。实际顺序:10 月 20 日约 15:00 UTC,两个 CVE 编号在论坛与社交媒体流出,附带错误的发布时间与错误的 CVSS 10.0(实际官方评分 9.2,10.0 是 watchTowr 自评);19:30 UTC,PHP 发布提前预告,把发布从 10 月 22 日提前到 10 月 21 日;20:00 UTC,Calif 误发一段展示 PoC 的视频,随后删除但副本已扩散;10 月 21 日 07:41 UTC,Chainguard 单方打破 embargo,提前发布含修复的构建镜像;13:30 至 14:00 UTC,补丁正式可用并同日披露。

把这段当作速度来读,结论是:披露速度从“按周计划”变成了“按小时失控”。从 CVE 编号流出(10 月 20 日 15:00)到第一个可用修复镜像(10 月 21 日 07:41),十六小时四十一分;到官方补丁可用(13:30),二十二小时半。而协调方原定的窗口是六十天以上(8 月 11 日确认到 10 月 22 日计划发布,七十二天)。

要区分两种“快”。一种是决策变快:协调方判断形势不利,把发布提前一天,主动、有意、可控。另一种是控制失效:编号泄露、视频误发、第三方单方发布,三个环节按素材的说法都不是恶意,合起来却让协调披露名存实亡。协调披露的速度本质上不是“多快能发”,是“能不能由自己决定什么时候发”。前一种是好事,后一种是失控。

修复速度。这段最容易算,也最容易被算错。

按补丁算:8 月 11 日确认(Calif 口径 8 月 15 日 PHP 确认)到 10 月 21 日发布,七十一日,通常说约七十天。关键节点是 9 月 21 日 PHP 安全团队确认收到报告并启动协调修复,到发布三十天。

按根因算:2024 年发现最初迹象,到 2026 年 10 月 21 日发布,二十四个月以上。

按部署算:补丁发布之后,把它推到每台实际在跑的机器上要多久?没有权威数字,只有侧证——多国政府机构公开呼吁,日本首相要求相关机构尽快部署并在必要时暂停网站服务,荷兰与芬兰部分政府网站预防性下线。政府首脑的呼吁出现在发布当天本身就是指标:如果补丁能靠自动更新几天铺开,不需要首相出来说话。

比值随之出来:发现(含证明)五天到十三天,披露的失控窗口十六到二十三小时,修复七十一天,相对根因二十四个月。修复速度约是发现速度的六倍,是失控窗口的七十倍以上。这个比值在 2024 年是反的:发现慢,修复也慢,两个慢互相匹配,系统不显失衡。失衡来自比值改变,不来自任何单个数字变坏。

先加一句限定:下表的“AI 之前”一栏无法精确量化。这个行业没人在 2019 年测过“审计一个代码库的平均人月”,只有从业者的经验值。所以这一栏写的是口径与来源,不是数字。

| 环节 | 成本构成(人力/时间/判断力) | AI 之前(口径) | AI 之后(口径) | 变化量级 | 对应的速度与实测 |

| --- | --- | --- | --- | --- | --- |

| 发现 | 能读这套代码的人/读完并建图的工作量/决定先读哪里 | 一个新代码库数周到数月(素材 04 第二节口径) | 数天内给出可验证候选集(同口径) | 一至两个数量级(本书推断,非测量) | 发现速度:8 月 19 日到 8 月 24 日五天(Wiz 口径) |

| 证明 | 会写利用的人/反复试错与诊断/判断路径是否可达 | 2024 年看到迹象、找不到攻击路径、搁置(PHP 基金会口径) | 7 月 30 日起审计、8 月 11 日报告、8 月 14 日交 PoC(Calif 口径) | 从约两年悬置到约半个月 | 发现速度(含证明):十二天至十三天 |

| 修复 | 有引擎知识的核心开发者/兼容性权衡与评审/决定改哪一层 | 根因 2024 年可见但未修(PHP 基金会口径) | 8 月 11 日升为最高优先级、10 月 21 日发布 | 基本不变;尾端因 EOL 部署而变长 | 修复速度:七十一日(相对根因二十四个月以上) |

| 披露(无对应成本段) | 协调方的信誉与守时/六十天以上的 embargo/判断谁该先知道 | 计划 10 月 22 日 14:00 UTC 统一发布 | 10 月 21 日 13:30 至 14:00 发布,前夜三处泄露 | 由可控改为失控 | 披露速度:编号流出到首个修复镜像十六小时四十一分 |

第四行是刻意加的。披露不是成本,是制度安排,但速度这一栏必须有它,否则无法解释为什么总时长没被压缩:两段成本塌了,日历上只省下一天。

一句话定性:三个速度里,只有第一个在被优化,第二个在被拆解,第三个被人的日程锁着。

9.3 N-day 窗口的坍缩:从周到小时,再到负数

攻防经济学里有个老概念,叫 N-day 窗口:补丁发布之后,到野外出现可用利用代码之间,防御方还能抢跑的那段时间。窗口长度是所有补丁管理制度的隐含前提:企业敢说“每月第三个周二统一打补丁”,是因为历史上这个窗口有数周。

第一阶段:从月到周。 这个趋势在 2017 年前后成型。漏洞公开、补丁发布、有人写 PoC、PoC 被整合进工具包、出现规模化扫描,每一段都被自动化压缩过。补丁 diff 一旦公开,逆向就是确定性工作;防御方可用的时间从“数月”掉到“数周”。

第二阶段:从周到小时。 关键变化不是 PoC 写得更快,是“补丁 diff 本身成为可利用的情报”。当修复版本与旧版本只差几十行,任何能读 diff 的人和模型都能在几小时内定位根因,再花几小时把根因变回攻击面。Cloudflare 在 2026 年 5 月对 Mythos 的描述里有一句可引的判断:它能从低危原语构造完整利用链,并在无人工干预下生成可编译、可运行、能确认可利用性的 PoC。放进 N-day 的语境,它说的是窗口从“天”掉到“小时”。侧证是:Beazley Security Labs 称技术细节与 PoC 从 escrow 提前泄露;CERT-EU 称补丁发布后技术细节被公开讨论、初始 PoC 开始流通,“攻击者已在利用的可能性上升”。两个口径都指向同一件事——细节流通速度不再受协调方控制。

第三阶段:负数。 PHP2Shell 出现了三个彼此独立的证据,每一个都足以让窗口为负。

第一,补丁未发,PoC 已流通。10 月 20 日约 20:00 UTC 那段视频虽只含有限细节,但按时间线的说法它加速了外部 PoC 开发。此时官方补丁还有约十八小时才发布(10 月 21 日 13:30)。对读到这段视频的人来说,可利用知识的到手时点早于补丁的到手时点。

第二,补丁已被第三方提前放出,但只对一部分人。10 月 21 日 07:41 UTC,Chainguard 单方打破 embargo 发布含修复的构建镜像,比协调发布早六小时左右。一面是它给用这个镜像的人提供了提前量;另一面是它给了所有人一份“可以立刻 diff 的补丁”。补丁的提前公布,在信息层面等价于漏洞细节的提前公布。对多数防守方,这不是提前量,是提前暴露。

第三,编号与评分在补丁之前流出。10 月 20 日约 15:00 UTC 那两条论坛帖子没有技术细节,但有一个错误的评分 10.0 和一个错误的发布时间。错误本身构成噪声:它让搜索这两个编号的人找错方向,也让应急团队在信息不完整的状态下开始做决定。

把三者叠起来,负数窗口的定义就是:可利用知识的扩散时点,早于补丁的可用时点。写成顺序更清楚——

传统顺序:洞被发现 → 厂商修好 → 补丁可用 → 细节公开 → 攻击者开始利用。防守方在第三段有数周。

2026 年 10 月的顺序:洞被证明 → 编号泄露(10 月 20 日 15:00)→ PoC 视频泄露(20:00)→ 第三方镜像可用(10 月 21 日 07:41)→ 官方补丁可用(13:30)。

两个顺序的差别不在快慢,在因果方向。传统顺序里补丁是“因”、攻击是“果”;负窗口里,攻击情报是“因”,补丁是“果”。

这个反转对防守方有三层含义。

第一层,补丁可用性与披露时点脱钩。你的补丁到不到手,取决于你用哪个发行版、哪个镜像源、哪个托管商,不取决于官方披露的时点。约一半部署跑在停止支持的分支上(第 5 章),它们的补丁不会因为 10 月 21 日下午那场发布会而存在。于是防守方内部出现分裂:一部分人说“已经修了”,另一部分人连“能不能修”都还不知道。这类分裂在应急指挥上极贵,它让“整体风险”无法被单点判断。

第二层,没有中间态可站。传统窗口里,防守方在补丁到达前有一段缓冲,可以靠临时缓解过夜。这次不行。第 5 章梳理过临时缓解的可靠性:session.upload_progress.enabled=0 只切断 109619 的一条路径,memory_limit=-1 不能根除,用 RLIMIT_AS 与 RLIMIT_DATA 限制内存不可行;Cisco 的公告直接写 “no workarounds available”。当缓解手段不可靠,而可利用知识已经在外流通,防守方的状态只有两种:已打补丁,或者完全暴露。没有“部分缓解、观察一晚”这个选项。

第三层,防守方的第一动作不再由自己安排。这是最重的一条。补丁管理的全部制度设计——变更窗口、灰度批次、回滚预案——都假设“我有时间安排”。负窗口否掉了这个假设:你不知道外面有多少人看过那段视频、拿到那份镜像、已经在写利用,唯一确定的是官方补丁在某个小时之后才有。于是“等官方发布再动”从稳妥变成了赌博。

连带效应是:负窗口不是把防守方的时间压到零,是把决策权拿走了。过去防守方决定“什么时候打”,攻击方决定“什么时候打得到”;负窗口里,攻击方的进度变成了所有人的时钟。

也要加一句自我限定。负窗口在 PHP2Shell 里成立,是因为三个偶发事件(编号泄露、视频误发、第三方破约)叠在一起,其中没有一个属于“模型能力”。负数不是 AI 直接造成的;AI 造成的是前两阶段变快,负窗口是人与流程在高速之下的失配。归因给 AI,结论是“限制模型”;归因给流程,结论才是“重排流程”。

一句话定性:负窗口的意思不是更快,是防守方的第一动作不再由自己决定。

9.4 漏洞通胀:当供给曲线右移,价格会发生什么

把漏洞当商品看有边界,但在供给这一侧好用。供给方是发现者,需求方分两类:想修的人和想用的人。价格由三件事共同决定:洞是否真的存在、是否可被利用、有多少人已经知道——分别靠发现、证明、披露,三段成本正好对应三个定价因素。

现在让供给曲线右移:发现与证明的成本同时塌陷,“可验证漏洞”的数量成倍上升。需求不变、供给上升,价格下跌。但这里的“价格”不是一个数,是四层东西,它们朝相反方向动。

第一层:CVE 数量。 这一层最容易造数字,本书不引用任何全局年度总量,因为没有可核验口径。可用的只有局部观测:按 PHP 基金会披露,自 2026 年 7 月 1 日起 PHP 已发布十五个 CVE,同期处理了一百多份无效、重复或不可复现的报告;按 Mozilla 口径,2026 年 5 月一条 Mythos 驱动的流水线在 Firefox 150 中发现 271 个潜伏缺陷(180 HIGH/80 MODERATE/11 LOW),含 15 年前的 element bug 与 20 年前的 XSLT use-after-free;按 Cloudflare 自述,同类方法被用在 50 多个自有仓库上。三者观察对象不同,不能加总,但指向同一方向:被记录的缺陷在变多,且主要是“潜伏十年的老缺陷”,不是“新写进去的错”。这不是软件质量在恶化,是检测能力在补历史的课。

第二层:报告质量的分布。 供给增加时,新增供给的质量通常低于存量,这是所有市场的规律。PHP 那个“一百多份报告对十五个 CVE”的比例就是分布的样子:绝大多数报告要么不成立,要么重复,要么无法复现。这对生产者的含义和直觉相反——不是“工作量增加了六倍”,是“筛选成了主要成本”。SANS 与 CSA 那句话正好指这一层:真正的问题是发现速度与响应速度之间的差距,而差距的大头不在修,在筛。

第三层:维护者队列。 撞上的第一个硬约束是队列。PHP 基金会的处境素材里有清楚描述:要靠赞助才能雇多人全职处理,2026 年 5 月才成立 Ecosystem Security Team,决策机制从共识制改成多数决并鼓励折中。三件事都是扩容动作,扩容对象是人和制度,不是算力,而两者都有月级以上的滞后。队列长度等于到达率除以服务率:分子被 AI 抬高了一到两个数量级,分母基本不动。队列的增长因此不是线性的,是发散的。

第四层:市场定价。 这一层最敏感,也最容易编造。本书没有获得可核验的赏金与漏洞交易价格数据,只讨论机制,不引用数字。漏洞的价值取决于“买方能否据此行动”:只有可疑点的报告,买方还要自己投入证明成本,值不了多少;带可运行 PoC 的报告,买方可以立刻行动,所以值钱。AI 把证明成本压下来,等于把大量“可疑点”升级成“可行动项”——供给右移的同时,产品平均品级也在上升。两个变化叠加,结果不是单边降价:低品级报告因供给泛滥而贬值,高品级报告因“可验证”的稀缺性反而更受追捧。同一份“Critical”标签,含 PoC 与不含 PoC,谈判地位完全不同。

四层里,前三层是数量问题,第四层是结构问题。合起来就是“漏洞通胀”的准确含义:通胀的不是漏洞价值,是待处理项的数量,而处理能力按年规划。

把“供给曲线右移”翻译成工程语言,是下面几件具体的事。

补丁窗口排队。 一个团队一年能安排的变更窗口是有限的:每月固定的维护夜、每季度一次的大版本窗口、年底冻结期。窗口数由人力与业务低峰决定,不由漏洞数量决定。当要打的补丁从每季度两个变成每周两个,窗口就不够用。够不上的处理只有三种:加窗口(加班与审批)、合并(一次打多个,出事时无法定位是哪个补丁)、跳过(先打“重要的”)。三种都是风险转移。

变更审批。 审批产能同样固定:读变更说明、判断影响面、确认回滚方案,单位时间不会因为漏洞是 AI 找到的就缩短。当补丁请求频率超过审批频率,结果不是审批变快,是出现“紧急通道”——通道一开,审批退化成签字。这是通胀最隐蔽的代价:它不直接造成事故,它稀释控制措施的有效性。

回归测试资源。 这是最硬的一堵墙。每个补丁都要跑回归,要占 CI 队列、占测试环境、占一个能判断“这个失败是不是我造成的”的人,三样都是固定资产。补丁翻倍而测试资源不变,只有一件事会发生:测试覆盖被削减,且削减在最不容易被发现的地方。第 5 章讲过这次为什么危险:PHP 是语言层,一个补丁要覆盖 FPM、mod_php、php-cgi、FrankenPHP、LiteSpeed 五种形态,而 mod_php 风险最高——回归不是一个矩阵,是五个。

合起来,工程语言版的“供给曲线右移”是:到达的工单数超过变更管理系统的吞吐,系统开始用降低单件处理质量的方式维持吞吐。这个降级在指标上看不出来:补丁数量、平均修复时间、合规率都会变好,只有“每次变更的验证深度”在下降,而这个量没人统计。

一句话定性:供给曲线右移,下跌的不是漏洞的价格,是“打一个补丁”这件事能分到的注意力。

9.5 攻防同源:同一套适配能力的两副面孔

前面四节讲的都是防守侧:要处理的东西变多了、变快了。这一节讲一个更不舒服的事实:让它变多的那套能力,对另一边同样有效,而且是同一个东西,不是两个相似的东西。

证据来自最不该承认这件事的一方。Wiz 描述自己的效率时给了两个数字:一名研究员一周完成过去世界级团队数月的工作,跨部署适配从数周压到数小时。前一个是成绩,后一个是隐患。在可利用性证明的四道门里(布局、整形、泄露、适配),第三道是“让洞能打穿”,第四道是“让洞在目标的具体部署上打穿”。适配的技术内容在两侧完全同构:Wiz 要证明“WordPress 的默认容器镜像能被未认证打穿”,攻击者要证明的也是这句话,只是主语换成他自己的目标。同一段代码、同一个调试循环、同一种失败诊断。Wiz 自己点明了:同样的适配能力也降低攻击门槛。

这句话不是“AI 也可能被滥用”这种笼统提示,而是一个具体机制:适配成本是这个行业过去二十年最有效的天然减速带。一个漏洞从“能打穿我的测试环境”到“能打穿客户的真实环境”,中间隔着版本差异、编译选项、内存分配器行为、扩展加载顺序、容器基础镜像的差别。这些差别没有智力含量,只有工作量。任何靠“攻击方懒得做这么多适配”维持的安全边际,在适配成本被压到数小时之后就消失了。

行业史的证据不止一条,分布在不同应用方向上。

XBOW 在 2025 年 6 月成为首个登上 HackerOne 美国排行榜第一的非人类参与者,据其自述约九十天提交一千多份报告;一个对比是 104 个真实世界挑战用 28 分钟完成,人类渗透测试员需要 40 小时,约 85 倍(厂商自述口径)。数字不能当测量值,形状是对的:单件更快,且可以并行。渗透测试这件事,防守方买它来发现问题,攻击方做它来找入口,工作内容一致。

Mythos 一侧的证据是量的:2026 年 4 月 7 日 Anthropic 发布 Claude Mythos Preview 与 Project Glasswing;同年 5 月 Mozilla 披露 Mythos 驱动的流水线在 Firefox 150 中发现 271 个潜伏缺陷;Cloudflare 报告了两项前代模型做不到的能力——从低危原语构造完整利用链,以及无人工干预生成可编译、可运行、能确认可利用性的 PoC。第二条尤其要紧:它说的不是“找到可疑点”,是“交出了证明”,正是 9.1 里最贵的那一段。

攻击侧再看一条:2026 年 9 月,有 agent 驱动的凭据收割行动在六小时内搭好基础设施,其中存有 23,800 多个被盗密钥,含云服务与 AI 接口的 key。它不是找漏洞,是把已知手段规模化;六小时建成能装两万多个密钥的基础设施,人力时代要一个小团队干几周。

同期还有一类更难归因的现象:有报道称存在伪装成漏洞赏金计划、借“请帮我验证这个漏洞”的名义骗取研究者数据与访问权限的做法,规模达 150GB 量级。这条本书只拿到媒体转述,没有一手材料,仅作机制示意:当“提交漏洞”成为常规流程,这条流程本身就成了攻击面;赏金计划的前提是双方善意,而善意不能验证。

合起来能得出一个比“双刃剑”更精确的判断:攻防同源不是道德问题,是技术同构。防守方买到的能力与攻击方买到的能力是两个 API 调用,后端是同一个模型。任何“只让防守方用”的制度设计都会在实现层面撞上这个同构——你无法给同一个权重矩阵加一个“仅限善意使用”的开关。

差异不在能力,在流程与约束。防守方有资产清单、有合法性、有审计记录,但也有变更审批、预算周期与“不能把生产环境搞挂”的硬约束。攻击方没有清单、没有合法性、没有审批,唯一约束是“别被抓到”。于是同一套能力在两侧的速度不同:防守方拿到能力后还要经过采购、部署、验证、上线四个环节,攻击方的下一个动作就是用它。这是 9.2 剪刀差的另一半解释——不只是修复慢,是防守方每一次能力升级都要穿越组织。

一句话定性:同一把钥匙开了两扇门,而其中一扇门的锁,本来就是我们自己装的。

9.6 一个反证:难道不是“更多漏洞被发现”本来就是好事

写到这里必须给对面一个位置。本章前面的论证有一个显而易见的反驳,而且相当强:安全行业几十年来一直抱怨的是“漏洞藏得太深、发现得太少”。现在发现变便宜了,为什么反而是坏事?

支持这一方的理由至少有三条,每条都有实证。

第一条,暴露即修复。一个潜伏十五年的 use-after-free 出现在报告里,总比躺在生产环境的日志里好。Mozilla 那 271 个缺陷里有 15 年前的 element bug 与 20 年前的 XSLT use-after-free,它们不是被 AI 造出来的,是被 AI 看见的。看见之前是沉默的风险,看见之后是可以排期的工作项。从十年尺度看,这是净收益。

第二条,历史债务清理。AIxCC 的结果可引:识别 86% 的合成漏洞(半决赛为 37%),修补其中 68%,并发现 18 个 DARPA 未植入的真实漏洞、提交 11 个可行补丁。这组数字的意义不在比赛,在于证明“批量清理欠账”可行。开源生态的债务规模,人力时代永远还不完。

第三条,防御方获得先手。发现者站在防守侧,窗口就是正的。Big Sleep 在 2025 年报告约二十个广泛使用开源软件的发现,其中 CVE-2025-6965 据称提前阻断了即将发生的利用,这是最干净的样本。PHP2Shell 里三家发现机构也在防守侧或中立侧。

三条都成立,本章不能停在“AI 让事情变坏”。但三条各有一个未说出的前提,补上之后结论会变。

补第一条的前提:暴露即修复,需要“披露时点可控”与“修复能力存在”同时成立。时间差是最容易漏掉的成本:一个洞从“被看见”到“被修好”,中间知道它的人增加了,能修它的人没增加——这段是净增风险。对约一半跑在停止支持分支上的 PHP 部署,这段时间是“无限”,因为官方补丁对它们不存在。所以第一条要改写为:暴露即修复,前提是你修得动。

补第二条的前提:清理债务需要偿付能力。271 个缺陷被看见,接着要排进 Mozilla 的队列;一百多份报告对应十五个 CVE,筛掉的部分也有成本。被看见不等于被偿还,利息一直在滚——每多一个未修的已知漏洞,下次披露的暴露面就更大。

补第三条的前提:先手的价值等于“时间 × 修复速度”。PHP2Shell 里防守侧确实拿到了先手:8 月 11 日就知道了,比公开披露早了七十一天。七十一天在历史上是奢侈的窗口。但这七十一天没有换来提前的防护,因为修复速度没动,而且还被 10 月 20 日那一夜的泄露吃掉了。先手被浪费的原因不是先手不够长,是后半段跑不动。这也解释了为什么“防守方先发现”这件事本身不足以让人放心:先手是一张支票,兑现需要产能。

三条前提补完,结论清楚了:更多漏洞被发现,在“修得动”的世界里是好事,在“修不动”的世界里是负债。2026 年的现实是,发现这一段被加速了一到两个数量级,修复那一段的产能几乎没变——所以这一章的主角不是 AI,是这个不对称。

还要加一层自我限定,否则这一章会被读成技术悲观论。本章的结论不是“应该限制 AI 找漏洞”。理由三条:一,限制不住,能力已经扩散,Wiz 的适配能力与攻击方的是同一个东西(9.5);二,限制方向错了,把发现速度按回去等于让潜伏十年的洞继续潜伏;三,真正需要重排的是后两段——披露时序与修复产能,这两件事都可以改,成本远低于训练一个前沿模型。SANS 与 CSA 那句话的落点也在这里:要关闭的是“发现速度”与“响应速度”之间的差距,关闭的方式可以是压前者,也可以是抬后者,而只有后者是可持续的。

本章的最后一个判断,用一句话收:一个只加速了第一段的系统,比一个三段都没加速的系统更危险——因为它把风险从“未知”变成了“已知但未处理”。

剩下一个问题:防守方的后两段能不能也被机器加速?素材里已经有一条完整的流水线:Claude Mythos 扫描,Daybreak Red 验证报告与 PoC,sanitizer 构建与 CI 匹配环境复现,然后交给人工判定与打补丁。每一段都有名字、有输入输出、有明确的失败模式,也有一处至今无法自动化。第 10 章把它拆开,一段一段看哪里跑得快、哪里断掉。

第 10 章 防守方的机器:从扫描到补丁验证

“工具找 bug 更快,但决定正确修复只能靠人。”

——PHP 基金会关于 PHP2Shell 修复过程的口径,2026 年 10 月

本章要回答一个问题:在防守这一侧,机器究竟站在哪几个位置上,而这条流水线在哪里必须停下来等人。

第 6 章到第 8 章讲的是发现侧:三家机构在两周内抵达同一个根因,模型把“可疑”压成了“可执行”。防守侧的故事看起来应该更顺理成章——既然机器能读代码、能写利用、能验证补丁,那么把同一套能力转向自己的代码库,不就等于把自己修好了吗?PHP 基金会的公开说法恰好相反:每一个判定与补丁都由人完成。

这句话值得当作起点,而不是结论。它说的是防守侧的机器不是一枚“一键修复”的按钮,而是一条由若干段拼起来的流水线,每一段的产物是下一段的原料,每一段都有自己的失败方式。下面这六个段落,是本书根据 PHP 基金会的公开披露、Wiz 与 Google 的公开材料以及 2026 年 7 月到 10 月的公开时间线重建出来的模型。它不是任何机构的官方流程图,各段之间的边界在实际工作里也不像表格那样整齐,有些工作会来回折返;重建它的目的,只是把“哪一段可以被机器接手、哪一段不能”变成可以逐段讨论的对象。

| 段落 | 输入 | 输出 | 主要失败模式 | 自动化程度 | 是否必须留人 |

| --- | --- | --- | --- | --- | --- |

| 一、模型扫描(Claude Mythos,Alpha-Omega 渠道,2026 年初) | 一份代码库快照,加上“状态不变量被错误处理路径破坏”这类问题设定 | 候选集:可疑点与判断理由 | 误报;快照时效;不产出补丁 | 高 | 否,但问题设定与优先级由人定 |

| 二、报告验证(Daybreak Red) | 第三方报告、PoC 或 exp、对方自述的环境 | 可复现或不可复现的结论与理由 | 环境无法重建;否定性结论不完整 | 中 | 部分,结论需人签发 |

| 三、去重与归组(Daybreak Red) | 多份报告的语义内容与利用行为 | 按同一根因分组的报告集合 | 误并;漏并;按文本相似而非机制相似 | 中高 | 部分 |

| 四、sanitizer 复现 | ASan/UBSan 构建、与各支持平台 CI 匹配的复现环境、PoC | 稳定的复现结果与中止点 | sanitizer 改变布局;环境漂移;EOL 版本缺席 | 高,前提是环境已声明化 | 否 |

| 五、人工判定 | 经归组与复现的结论、影响面判断 | 是否成立、影响多大、先修哪个 | 判断分歧;共识机制下的拖延 | 低 | 是 |

| 六、补丁审查与发布 | 候选补丁、兼容性约束、发布时序 | 公开补丁与公告 | 引入新的拒绝服务风险;破坏扩展;不可回滚 | 低 | 是 |

看到这张表,容易得出一个过于轻快的结论:前四段交给机器,后两段留给人。真实情况更微妙:表中“自动化程度高”的段落,前提往往是别的段落先做对了——第四段之所以能批量,是因为环境被写成了代码;第三段之所以能归组,是因为第二段交出的结论足够具体。而“必须留人”的两段,理由也不是模型能力不足。

10.1 Claude Mythos 与 Alpha-Omega:公共资金买来的扫描能力

本节的时间坐标很重要:2026 年初,PHP 基金会与 Alpha-Omega 合作,用 Anthropic 的 Claude Mythos 对 PHP 做安全扫描。这比 8 月 11 日 Calif 向 PHP 安全团队报告“无条件远程代码执行”早半年多,比 7 月 30 日 Calif 在 Patch the Planet 计划下开始审计 PHP 也早。在整条公开时间线上,“防守方请机器来读自己的代码”发生得比“攻击方用机器证明洞能打穿”更早。

这个先后顺序值得注意:基金会并不缺少“知道代码可能有问题”这件事,缺的是把可疑变成确定的能力,以及确定之后把它修掉的能力(第 7 章)。第一次扫描买到的只是前者的原材料。

这笔钱的性质需要说清楚,因为它决定了流水线能跑多久。扫描不是零成本行为:调用前沿模型读一整个代码库要消耗算力与 token,成本虽远低于同等人力审计,但不为零。这一次是谁付的,公开信息给得明确——Alpha-Omega 渠道。同一个渠道也支撑了 Calif 对 PHP 的审计;再往前一年,2024 年那次 PHP 多年来首次外部安全审计,是 Sovereign Tech Agency 资助、OSTIF 组织、Quarkslab 执行。三个时间点,三个出资方,三批执行者,指向同一个问题。

那个问题是:开源安全的基础设施,究竟由谁来付钱?

它在 2026 年被反复讨论,原因不是道德,而是算术。三段成本的剪刀差意味着漏洞供给在上升,修复成本没有下降(第 9 章)。供给上升的第一承受者不是厂商,是维护者:按 PHP 基金会的披露,自 2026 年 7 月 1 日起 PHP 已发布十五个 CVE,同时处理了一百多份无效、重复或不可复现的报告。这些报告不会因为出自机器而变得更懂事,而处理它们的人,是那批被基金会自己描述为“太少”的、有深厚引擎知识的核心开发者。

把出资结构摊开,公开信息里能看到三种钱。第一种是项目式的公共与慈善资金,比如 Sovereign Tech Agency 资助的那次审计,比如 Alpha-Omega 渠道支撑的扫描与审计。第二种是厂商把自家模型的算力当捐赠:Calif 用 GPT-5.6 Sol,Wiz 用自家的 Wiz Atlas 加上 Gemini 4 Argon 与 GPT-5.6 Sol,成本由厂商承担,回报是能力证明与声誉。第三种是基金会自己筹来的钱——修复进入最高优先级之后,需要基金会资助的多人全职、跨周末、昼夜协调。

三种钱有一个共同点:都是项目式的,不是常态化的。项目式资金买到的是一次扫描、一段时间的阅读量、一支临时凑起来的注意力,而漏洞供给是常态的。这是我作为写作者的判断,不是任何机构的表述,但它可以从公开事实直接推出:如果扫描能力是租来的、按项目拨付的,它覆盖的时间窗必然断续,而代码库的变化是连续的。一次扫描交出的是一份快照的结论,不是一个持续有效的保证。

扫描的产物也需要说清它到底是什么:不是漏洞列表,是候选集。同一条产品线的公开记录能说明这个量级。2026 年 5 月,Mozilla 披露 Mythos 驱动的流水线在 Firefox 150 中发现二百七十一个潜伏缺陷,分为一百八十个高危、八十个中危、十一个低危,其中包括一个十五年前的 element 缺陷和一个二十年前的 XSLT 释放后使用问题。同月,Cloudflare 说自己在五十多个自有仓库上应用了 Mythos,并报告了两项前代模型做不到的能力:从低危原语构造完整利用链,以及在没有人工干预的情况下生成可编译、可运行、能确认可利用性的 PoC。这些都是厂商披露口径,不是第三方复现的结果,读的时候要打折;但它们指向的产能清楚:机器交出的量级,远超人能逐条读完的量级。

这里必须做一次区分,否则容易把别人的成果记在这次事件头上。上面两组数字描述的是 Mythos 这条产品线在 2026 年 5 月的行业状态,不是 PHP 基金会那次扫描的结果,公开材料没有披露那次扫描在 PHP 上找到了多少东西。本书不替它补这个数字。能确证的只有流程位置:扫描发生在年初,之后的某一天,基金会决定引入第二个系统来验证报告。

这个“之后”有含义。先买“读”,再买“验”,不是采购顺序的偶然,是两种能力在成本结构上的差别。读的边际成本低、可以并行、失败便宜——读错了只是多一条待筛的候选;验的边际成本高、依赖环境、失败昂贵——验错了会把一个真漏洞放回队列。先用便宜的那一段把漏斗做大,再用贵的那一段收口,是唯一算得过来的顺序。

失败模式在扫描这一段也最容易看清。第一是误报:模型会把每条看起来相关的路径都读完,交出的是候选集,筛检成本随阅读量线性上升。第二是时效:代码在变,结论的有效期与快照绑定。第三是它不产出补丁,连“应该改哪一行”都不一定说得准——它说得准的是“这里少了一个前提”。第四最要紧:找到与证明之间的那段空档,机器并不自动填平。2024 年的教训是位置被看见两年、缺的是证明;2026 年初的扫描重新把位置摆出来,仍然缺证明。

一句话定性:公共资金买到的是一次快照式的阅读量,不是一个可以长期依赖的判断力。

10.2 Daybreak Red 的位置:验证报告,而不是生成报告

过了扫描这一段,基金会引入了 OpenAI 的 Daybreak Red。它的任务在公开材料里写得很具体:验证漏洞报告与 PoC 或 exp,并按相同根因给报告分组。

这句话里有两个动作,本节只谈第一个,第二个放到 10.3。先说清一个容易混淆的地方:这里的“验证”与第 8 章讲的“补丁验证”不是同一件事。补丁验证问的是“修复是否真的关闭了攻击面”,输入是补丁;报告验证问的是“这份报告说的东西是不是真的”,输入是别人交来的报告、PoC 和一段自述的环境。两者都在“验证”这个词底下,判据完全不同,混着谈会得出错误的结论。

那么验证者与发现者的角色差别在哪里?

发现者交出的是一句判断:这里存在一个被错误处理路径破坏的不变量,并且破坏可达。这句话可以被争论、被推翻,也可以被搁置两年没有人管——2024 年那次就是这么过去的。它的价值在于指出方向。

验证者交出的是一份结论:这份报告声称的攻击路径,在一个可重建的环境里是否真能走通。结论的形态是二值的,但它附带的东西比二值复杂得多——如果走不通,要说明是环境不对、假设不成立,还是报告本身就错了。这个“为什么不通”的部分,才是验证工作的主要成本。

时间线可以当证据。2026 年 8 月 11 日,Calif 报告“无条件远程代码执行”,同日 OpenAI 独立报告相同根因;8 月 14 日提交 PoC,8 月 15 日 PHP 确认。从报告到确认只隔四天,其中最关键的一步是 PoC 落地的那一天——PoC 在前,确认在后,这个顺序不是巧合。一份没有 PoC 的报告,确认成本会高出一个量级;而自 7 月 1 日以来那一百多份无效、重复或不可复现的报告,绝大多数就卡在这里。

于是问题变成:为什么验证比发现更难自动化?

第一个原因是输入的性质。发现面对的是代码,代码完备、自洽、可以被完整读取;验证面对的是人的描述,而描述天然不完整。报告者会省略他以为是常识的环境细节,会把自己调试时的临时改动带进结论,会用一个在他机器上成立的参数去解释一个在别人机器上不成立的现象。要把这些补回来,靠的不是更强的推理,是追问——而追问需要对方还在场。

第二个原因是环境的重建。这一点与 10.4 直接相连:一份报告能不能被验证,取决于验证方能不能把报告者的世界重建出来。SAPI、内存上限、扩展集合、版本、worker pool 的大小,任何一项不同,堆的布局就不同,同一段利用在一个环境里稳定命中,在另一个环境里三次中一次。验证者必须先成为环境工程师,才能成为裁判。

第三个原因更根本:验证的主要产物是否定性结论,而否定性结论需要覆盖性论证。“我能复现”只需要一次成功;“我不能复现”需要对全部失败原因给出交代。两者工作量差着数量级,而后者的完备性无法被证明——你永远不知道自己漏掉了哪一种环境差异。

这里必须放一段反证。反面的说法是:验证的判据比发现的判据清楚得多。发现要靠判断“这里有没有问题”,开放、可以永远争论;验证只要跑一次,能复现就是能复现,不能就是不能。既然判据这么硬,机器理应更擅长验证。

这个说法对了一半,而它对的那一半正好说明问题在哪。验证的最终判据确实硬,但它只在最后一步出现;在此之前的每一步——报告是否读懂、环境是否等价、失败是否归因正确——判据都是软的。这就像说“编译是否成功”的判据很硬,所以写编译器很容易。真正难的是让那一次运行有意义。

还有一层不属于技术:验证者是在替别人背书。一份报告被验证通过,意味着验证方愿意把自己的名字放在这个判断旁边;背书的价值来自可信,不来自吞吐。所以即便验证的某些环节可以完全交给机器,最后的签发动作仍然留在人手里——不是因为机器签得不够快,是因为没有人会接受一份没有署名的保证。

一句话定性:验证者的产物不是新知识,是可信度;而可信度不能靠吞吐量生产。

10.3 去重与归组:为什么“同一根因”的识别比发现更难

验证之后的动作是归组,这是整条流水线上最不被外行看见、却最容易出事的一段。

先看事实的密度。这次事件里三支队伍在两周内独立抵达同一个根因:Calif 在 8 月 11 日报告,OpenAI 同日独立报告相同根因,Wiz 从 8 月 19 日开始、8 月 24 日上报。三个提报方、三套工具、三条路径,落到维护者桌上是三份文档,而它们描述的表象并不相同:一份讲内存耗尽之后的内存破坏,一份讲跨请求的陈旧全局状态,还有一份是从 Drupal 与 WordPress 的未认证远程代码执行反向追进去的。它们在字面上甚至可以不共享一个句子,却指向同一个机制缺口。

再看另一端的密度。自 2026 年 7 月 1 日起,PHP 处理了一百多份无效、重复或不可复现的报告。同一个时间窗里,维护者面前同时堆着两类噪音:一类是同一件事被说了三遍,一类是三件不同的事都被说成了一件。

归组的难点可以这样表述:报告的坐标系是表象,根因的坐标系是机制,而这两个坐标系之间没有函数关系。

按表象聚类,会出现两种错误。第一种是把不同的洞当成同一个。相同的一段 PoC 可能同时打穿两个根因,因为一条完整的利用链本来就会叠加多个缺陷——Wiz 公开的做法就是把多个 bug 串成单一利用脚本,在 WordPress、Drupal、Joomla 的当时最新镜像上演示未认证远程代码执行。若按“PoC 相似”归组,这条链上的两个根因会被合并成一个,修掉其中一个,另一个还在。第二种是把同一个洞当成不同的:同一段代码路径,从 HTTP 层看是一个入口,从 CLI 看是另一个,从 FPM 看又是第三个,三份报告各有各的复现步骤。

两种错误的代价不对称,这一点必须说清。

重复占用的代价是可见的、线性的:同一个根因被当作三个独立漏洞,就要三次评估、三次方案讨论、三次影响面核算。在多数开源项目里这只是浪费时间;但在共识制决策的结构里,它会变成另一种东西——三个“独立”的严重问题同时压上来,会让人误判严重程度,也会让每个方案的讨论都从零开始(第 11 章)。这一段的成本可以计量,所以它可以被优化,也最容易被当成全部问题。

误并的代价是隐蔽的、非线性的,而且更贵。一个根因被误并进另一个,意味着有一个攻击面在补丁发布之后仍然开着,而公告已经说了修好。这时候所有下游响应都建立在一个错误的前提上:运维按公告打补丁,检测规则按公告的利用特征写,安全负责人按公告的风险评估关掉工单。没有人会再去看那个洞,因为它已经“被修过”了。这是假阴性的最坏形态——不是漏报一次,是让整个响应体系对一个仍然存在的缺口失去警觉。

把两种代价放在一起,就得到这一段的判据:宁可重复,不可误并。这也解释了为什么归组要以机制为单位,而不能以相似度为单位。

109618 与 109619 这两个编号的存在,本身就是归组必须精确的证据。它们同源——都出现在 worker 复用与 bailout 路径的交界处,都涉及 shutdown 阶段的错误清理,公开叙事里经常被一起提到。但根因不同:一个是哈希表“先增计数器、后分配内存”的顺序留下的半初始化 bucket,被 shutdown 当作合法 zval 析构;另一个是跨请求的陈旧全局状态,由两类中断路径产生。若按“都跟 shutdown 有关”合并,修完 109618 之后 109619 的两条路径仍然开着,而且在“已修复”的公告之下。基金会在协调修复中把它们分成两个 CVE,这个动作本身就是一次成功的归组。

模型在这一段的表现如何?公开材料说 Daybreak Red 按相同根因给报告分组,但没有披露它的判据、准确率或人工复核比例。能说的是任务性质:这是语义聚类,恰好是模型的强项——它能读到“这两份报告描述的是同一条不变量的同一个破坏方式”,即便两份报告用词完全不同。

但它错的方式与人不同。按文本相似度合并,错得比较笨,人也容易发现;按语义聚类合并,会错在“语义上真的很像,机制上完全不同”的地方——比如两个都由内存耗尽触发的缺陷,一个在请求初始化阶段,一个在脚本执行阶段,描述几乎一样,根因却隔着一层。这类错误人反而不容易看出来,因为它看起来太合理了。

所以这一段的实际结构是:机器做粗分,人做确认,而确认的判据是“能不能用一个补丁同时关掉它们”。这个判据只有在补丁写出来之后才真正可测,这就是为什么归组与补丁验证在流程上必须互相回望——补丁发布前,用一个回归用例去跑另一个组的 PoC,如果还能打通,说明归组错了。

一句话定性:发现是把一个洞从沉默里拉出来,归组是决定它算一个还是算两个;后者的错误不会被立刻发现,所以更危险。

10.4 sanitizer 与 CI 匹配环境:让“复现”变成可批量的事

前三段处理的都是文字与判断,这一段处理的是机器。PHP 基金会给出的做法是:配合 sanitizer 构建,以及“与 PHP 各支持平台 CI 匹配的复现环境”来辅助验证。这句话看起来像工具清单,实际说的是两种不同的东西:一个是判据,一个是场地。

先说判据。ASan 与 UBSan 属于编译期插桩工具,装上之后程序在运行时带着额外检查跑。ASan 抓越界访问与释放后使用一类问题,会在第一次非法访问发生时立刻中止并给出调用栈,而不是等程序继续跑到某个地方莫名崩溃或者执行了不该执行的命令。UBSan 抓未定义行为,比如整数溢出、类型混淆的转换、指针对齐问题。

这对这次的两个漏洞意味着什么?109618 的核心是“一个被计入已用的槽位,其内容从未被初始化”,最终危害发生在 shutdown 阶段被当作合法 zval 析构的时候。严格说,单纯“读到未初始化内容”这件事 ASan 不直接负责,那要靠另一类未初始化内存检测工具。ASan 在这里的作用是间接而关键的:当引擎顺着那个伪造结构去读时,读到的地址往往已经越出合法区域或指向已释放内存,ASan 会在那一刻中止。它把判据从“是否成功派生 shell”前移到了“是否发生非法访问”。

这个前移在经济学上比在技术上更重要。原来的判据要求整条利用链跑通——布局、整形、地址、适配四道门都过(第 7 章),才有一次“成功”;这条判据昂贵、不稳、依赖环境,失败时给出的信息还很少,只告诉你没打通。sanitizer 给出的判据便宜得多:只要走到那一步,就会有一份带栈的报告。判据变便宜,意味着“失败—诊断—改参数—重试”这个循环每一轮都变短,而这段循环的速度,正是 8.2 里说过的、机器相对人的全部优势所在。

再说场地。“与 PHP 各支持平台 CI 匹配的复现环境”这个措辞值得逐字读。这类漏洞的复现不是抽象的,它依赖一堆具体参数:五种 SAPI 里是哪一种,mod_php 与 PHP-FPM 的进程模型不同、风险也不同;memory_limit 是多少,它决定撞墙那一刻的水位;装了哪些扩展,因为每个扩展都往请求内存池里放东西,池子的排布因此改变;PHP 是 8.5 还是 8.4、8.3、8.2,因为修复版本分别对应这几个分支,偏移与布局在不同分支上有差别;worker pool 有多大,因为 109619 是概率性的,池子越大越难打(第 4 章)。差一项,堆的布局就变,同一段利用可能稳定命中,也可能三次中一次。

所以“复现”在这里实际上是“重建”,重建的前提是等价,而等价的前提是把环境写成可以被重复创建的东西。这就是“与 CI 匹配”四个字是整句话重心的原因:CI 是项目自己认账的支持平台清单,是维护者愿意为其负责的那组组合。把复现环境对齐到 CI,等于让每一次复现的结论都在维护者认可的范围内成立。反过来,如果验证方在一个维护者不支持、也不打算支持的组合上打穿了一个洞,结论在技术上可能是真的,在流程上却没法使用——维护者会问的第一个问题是“这个组合我们支持吗”。

一旦环境被声明成代码,复现就从手艺变成了跑批。同一份 PoC 可以在多个 SAPI、多个版本、多个内存配置下并行跑一遍,产出一张“哪些组合命中、哪些不命中”的矩阵。这张矩阵的信息量远大于一句“我可以复现”:它同时给出了影响面的形状与修复的验证基线——补丁打上去之后,同一张矩阵要变成全不命中。这就是 8.4 说的“关闭攻击面”与“挡住已知 PoC”的差别在工程上的落地方式。

失败模式有两个很难绕开。第一是 sanitizer 本身改变了程序:插桩会改变内存布局与时序,某些在真实环境里能打通的利用在 ASan 下打不通,因为伪造结构与真实堆的对应关系被插桩破坏;反过来,某些在 sanitizer 下报得很欢的问题,在真实环境里根本不可达。把 sanitizer 报告直接当作漏洞清单,会同时产生假阴性与假阳性,所以输出必须经过可达性判断,而可达性判断又要回到代码语义。

第二是覆盖面的缺口。CI 覆盖的是受支持的版本,而这次事件的影响面里有相当一部分部署不在官方支持范围内——第 5 章讨论过 EOL 债务,约一半部署没有官方补丁。这些组合不进 CI,也就没有匹配的复现环境。它们不是不重要,它们是这套方法在结构上照不到的地方;方法越依赖 CI 对齐,这个盲区就越稳定。

一句话定性:复现之所以能批量,不是因为模型更强,是因为环境终于被写成了可以重复创建的东西。

10.5 从检测到自动响应:把应急速度当作基础设施指标

到这里为止,流水线处理的是“洞”。这一段处理的是“正在被打”。

公开材料里的检测设计分三层,每层对应攻击者绕不过去的一件事(第 8 章从设计角度讲过构造逻辑,这里只看它如何变成处置)。第一层在 HTTP:异常超长的表单字段名,长度约三点五兆字节,配合一个名字为空、值里带原始空字节的字段。第二层在日志:PHP 的 Allowed memory size ... exhausted ... in Unknown on line 0,以及 Apache 的 AH00052 ... Segmentation fault (11)。第三层在进程:web worker 派生出了 shell。

三层信号的价值不在任何一层有多强,而在它们必须同时出现才有意义。单独看,内存耗尽是日常,段错误可能是别的 bug,worker 派生进程可能是定时任务或图片处理库。三层在同一时间窗里咬合,才构成一条不可辩解的因果链:有人把池子撞穿,引擎在清理阶段误判了内存,那个误判带来了执行能力。这个思路在检测工程里不算新,新的是产出被直接接进了处置。

按厂商口径,Wiz Sensor 1.0.12768 内置了响应策略,可以自动终止表现出 PHP2Shell 利用行为的进程。公开材料没有说明触发条件是三层全部命中还是部分命中,也没有给出误杀率,所以这里只能描述它做的那件事:在没有人的情况下,结束一个进程。

这件动作有三个性质值得注意。第一是可回滚:进程可以被重启,流量可以被重新接上,最坏后果是一次可用性抖动——这与打补丁完全不同,补丁一旦发布就进入镜像层与 EOL 生态,收不回来。第二是局部:它阻断的是一次具体利用行为,不关闭攻击面,攻击者换一组参数可以再来。第三是时序:它的价值全部集中在暴露窗口里,也就是补丁发布之后到打上之前这段时间。

同一时间窗里发生的另一件事是厂商级联动。Cloudflare、Fastly、Google、AWS、Microsoft、Akamai、Imperva、Vercel 在数小时内发布了缓解规则或指引。这个清单本身说明现代 Web 的形态:绝大多数流量在到达那个有漏洞的 worker 之前,已经过了至少一层这样的基础设施。而“数小时”这个量级值得单独拿出来看。

本书主张把应急速度当作基础设施韧性的一项指标。理由是算术上的:修复速度在这个案例里完全不可控(第 9 章),发现速度已经被机器推到极限,那么在一个给定长度的暴露窗口里,唯一还能被改变的变量是窗口内被成功利用的概率,而它大致由两件事决定——规则覆盖得多宽,上线得多快。前者是能力,后者是组织。厂商几个小时就能把一条规则推到全球边缘,这个能力十年前不存在,它确实降低了这一天的实际损失。

但把速度当指标,必须同时说清代价,否则指标会被误用。

第一,速度不等于修复。缓解规则是这次公开叙事里最容易被当成答案的东西,而 Cisco 的公告写得很直白:no workarounds available。这句话与“数小时内发布规则”并不矛盾,它们说的是两层:网络层的规则可以降低被扫中的概率,却不改变任何一个 PHP 进程里那个半初始化 bucket 的存在。同样,session.upload_progress.enabled=0 只切断了 109619 的一条路径,memory_limit=-1 不能根除,RLIMIT_AS 与 RLIMIT_DATA 这条路走不通。只有打补丁是完整修复。

第二,速度有前提:可见性。三层信号里最贵的是第一层,因为三点五兆字节的字段名恰好落在很多 WAF 与代理会截断或丢弃的区间里。你以为在看请求,其实在看被裁剪过的请求。任何规则的上限都是它能看到多少原始输入。

第三,速度带来同构。当所有人同时被同一份规则文件保护,他们也同时暴露在同一个绕过之下。规则是公开可推断的,攻击者可以看到自己的请求被拦然后调整;一旦调整成功,所有下游同时失效。这不是反对厂商联动,是指出它的风险形状:它把分散的防御变成了一次同步的防御。

第四,自动响应的边界由可回滚性决定,不由检出率决定。杀进程可以自动,因为代价有上限;隔离主机、回滚版本、关闭站点不能自动,因为代价没有上限。这条线在下一节会变成一条通用判据。

一句话定性:自动响应买到的是时间,不是安全;而时间只有在补丁真的会到来的时候才有价值。

10.6 流水线在哪里断:人工判定与补丁审查

现在回到那张表和那道断口。

PHP 基金会的表述给了三句边界,话说得克制,但每一句都封住了一整类自动化的想象:AI 不能决定引擎中长期设计问题的正确修复方案,不能权衡兼容性取舍,不能审查补丁。

这三句要当作技术事实读,而不是公关措辞。第一句封住的是“让模型写补丁”。写一个能通过测试的补丁是容易的,写一个在长期设计上正确的补丁是另一件事:这次根因在最核心的内存管理与错误处理,几乎每个请求都会触及,任何改动都在动整栋楼的地基。第二句封住的是“让模型评估影响”。兼容性取舍的前提是知道生态里有多少扩展依赖现在的行为,而这个数字没有人持有——几乎无法枚举。第三句封住的是“让模型验收”。审查要回答的不是“这个 diff 看起来对不对”,而是“打完这个补丁之后,会不会有新的问题在半年后出现”。

这三件事合起来,就是断点的位置。它不在“能不能写出一行正确的代码”上——模型早就能写。它在“敢不敢为这行代码的后果署名”上。

这里要防止一个误读:把这三句边界理解成模型能力不足。能力当然有关系,模型对 PHP 引擎的理解不如那批核心开发者深,这是事实。但这不是主要约束,主要约束是责任结构。

第一,判据不完整。补丁正确性有三重判据:关闭攻击面、挡住已知 PoC、不引入新问题。前两重可测,第三重是开放的。这次讨论过的方案各自带着明确的新风险——改哈希表顺序会破坏大量依赖现行为的行为与扩展;加检查会引入新的拒绝服务风险;改错误处理语义会要求大量第三方扩展跟着改,几乎无法协调。这些代价没有一个能被自动评估,因为它们的分母不在代码里,在生态里。

第二,后果不可回滚。补丁一旦发出去,就进入了镜像层、发行版仓库与那批 EOL 部署,收不回来。这与 10.5 里杀进程的可回滚性形成对照:可回滚的动作可以自动,不可回滚的动作必须有人签字。

第三,审计的产物可以被复核,决定的产物必须被追认。一个模型交出“这里有一个洞”,错了只是多一条噪音,任何人都可以推翻它;一个团队交出“这就是正确的修法”,错了要有人承担。责任不能转移给模型,不是因为模型不可靠,是因为模型没有可被追责的主体地位——它也没有“当时为什么这么选”的记录,有的只是一次推理的轨迹,而轨迹不是理由。

写到这里,可以回答本节的问题了:自动化到哪一步就该停?

本书给三条判据,按顺序使用。

第一条是可验证性:这个环节的产物是否有一个外部可测的判据,而且这个判据不依赖产物本身的正确性?“能复现”是可验证的,“这是正确修复”不是。归组就卡在这条线上:它的判据“能不能用一个补丁同时关掉”只有在补丁之后才可测,所以它只能机器粗分、人来确认。

第二条是责任归属:这个环节的错误最终由谁承担,以及承担者是否愿意把名字署在产物旁边?报告验证的输出需要签发,因为有人要为“这份报告是真的”负责;补丁审查的输出需要签发,因为有人要为半年后可能出现的回归负责。

第三条是可回滚性:这个动作的后果有没有上限?有上限的动作可以自动——终止进程、拉黑地址、把一条规则置为阻断;没有上限的动作必须留人——发版本、改语义、动地基。

三条判据是“或”的关系,不是“与”:只要一项不满足,就不该全自动。用它们去扫那张表,结论与经验吻合:第四段三项全过,所以能批量;第五段第二项不过,第六段三项全不过,所以必然留人。

这也解释了 2026 年基金会那些组织动作的方向——成立 Ecosystem Security Team、引入 AI Security Engineer in Residence、把决策机制从共识制改成多数决并鼓励折中。这些动作没有一个是“让机器多做判定”,全部是“让人的判定更快一些”。它们承认了断点的位置,然后去优化断点两侧的东西,而不是假装断点不存在。

本章的结论不是“防守侧已经自动化了”,也不是“AI 在防守上没用”。它是一条更窄的判断:防守侧的机器已经把流水线的前四段做成了可以重复的工序,而整条线的吞吐由最后两段人的带宽决定。把机器做得再快,如果不改变最后两段的带宽,系统的输出不会增加,只会积压——一百多份无效、重复或不可复现的报告,就是这个积压的形状。

于是压力流向一个具体的地方:那批有深厚引擎知识的核心开发者。基金会自己说这样的人才太少,而两个月的修复、反复的停滞与重启、被承认的“结构性失败”,都是这句话的注脚。第 11 章要看的,就是瓶颈落在人身上的具体形态——不是抽象的“缺人”,而是缺到哪一层、缺哪些判断,以及为什么这类判断没办法被拆成更小的任务分给更多的人。

第 11 章 瓶颈在人身上:维护者、流程与开源的经济学

“AI 找到了 bug,实验室跑完了披露,瓶颈现在在维护者身上。”

——clauday 对 PHP2Shell 事件的评论,2026 年

本章要回答的问题只有一个:从 2026 年 8 月 11 日到 10 月 21 日,这七十一天里究竟是谁在干活,他们卡在了哪里,以及为什么这一段花掉的时间没有随前两段一起塌陷。

前面十章已经把答案的前半部分给完了。发现成本塌了,证明成本塌了,而修复成本没动。三支队伍在两周内独立抵达同一个根因,一台机器在四天里跑完了过去要世界级团队数月才能跑完的适配矩阵。然后整个世界原地等了两个月。这两个月的性质,和“AI 还不够强”没有关系。它是一段人力账,一段取舍账,和一段制度账。这一章要把这三笔账分开来算,再合起来看。

11.1 两个月的修复:为什么一个已知根因需要这么多人

时间轴先摆出来,因为它本身就是这一节的论点。

2026 年 8 月 11 日,Calif 报告无条件远程代码执行;同一天,OpenAI 独立报告了相同根因。8 月 14 日提交 PoC,8 月 15 日 PHP 确认,8 月 21 日报告第二个 bug,8 月 24 日 Wiz 把 Drupal 与 WordPress 的未认证 RCE 上报给 PHP 安全团队。9 月 18 日,OpenAI 报告更多攻击向量;9 月 21 日,PHP 安全团队确认并启动协调修复。10 月 20 日,CVE 编号在论坛提前流出,项目当晚决定把发布提前一天;10 月 21 日,8.5.12、8.4.27、8.3.36、8.2.35 四个版本同时发布。

从 8 月 11 日到 10 月 21 日,七十一天,整十周。

这十周里没有“正在找”的阶段。根因在 8 月 11 日就已经确定,甚至更早——2024 年核心开发者就在修复审计发现的问题时碰到过 109618 的最初迹象,只是当时找不到具体攻击路径,搁置了。所以这十周不是发现期,是决定期和验证期。基金会后来的表述是:修复升为最高优先级,需要基金会资助的多人全职、跨周末、昼夜协调。

现在把这七十一天拆成具体的工作项。拆开之后,“两个月”就会从一个印象变成一组可以被检验的量。

第一件事是确定修什么。两个 CVE 不是两个补丁。109618 的根因在哈希表的插入顺序与 bailout 的配合上;109619 的根因在 worker 级全局状态:一条路径是 fatal error 打断了状态重置,另一条是 shutdown 之后诊断信息重建了状态。它们共享同一个前提,即异常中断留下的半成品状态,但修复点落在不同的函数、不同的生命周期阶段。更麻烦的是范围还在动——9 月 18 日 OpenAI 又报来一批攻击向量,意味着攻击面在修复过程中继续扩张。修一半的攻击面和修全部的攻击面,方案可能不同。

第二件事是方案设计,留给下一节,这里只记账:方案设计不是一次讨论,是在几个都不可接受的方向之间反复缩小失败面。

第三件事是回归验证。这一段的成本被系统性地低估,因为它不产生代码,只产生“没有坏”这个结论。修复点落在内存管理与错误处理上,而这一段代码几乎每个请求都会触及。这意味着任何改动都会穿过整个 PHP 的行为表面:数组操作、错误抛出、异常传播、资源回收、输出缓冲、session 处理。验证的方式是把这些表面重新走一遍,而且要在多个构建配置、多个 SAPI、开启与关闭 sanitizer 的组合下各走一遍。第 10 章描述过那条流水线:Claude Mythos 扫描、Daybreak Red 验证报告与 PoC、sanitizer 构建加 CI 匹配环境复现。请注意这条流水线的终端在哪里——每一个判定与补丁都由人完成。机器把“这份报告是不是真的”变成了可负担的问题,但它没有把“这个补丁会不会弄坏别的东西”变成可负担的问题。

第四件事是扩展兼容。第三方扩展是用 C 直接与引擎交互的,它们依赖的不是文档里写的接口,而是实际行为的细节。一个修复如果在语义上动了约定的边界,编译能过,运行会坏,而且坏在扩展作者自己的代码里。这一项的成本不在 PHP 项目内部,它在项目无法直接观测的那片区域里。

第五件事是多分支 backport。四个版本同一天发布,意味着同一个语义修复要在四份不同的代码状态上分别成立。8.5 分支与 8.2 分支之间隔着三年的演化和若干次重构,同一个补丁的文本可以搬过去,语义搬不过去。每一条分支都要有自己的回归。这里的算术是乘法,不是加法:两个 CVE 乘四条分支,再乘每条分支的回归矩阵。至于 7.4、8.0、8.1 这些已经结束维护的系列,官方只提供了最小源码 backport,没有可分发的位置——这是第 5 章算过的 EOL 债务,对那部分部署来说,“修好”意味着自己动手。

第六件事是协调与发布。发布窗口要跟协调披露对齐:CERT-EU 记录的时间是 13:30 UTC 可用、计划 14:00 UTC。这个时间点要同时对上下游发行版、镜像仓库、托管面板、大型托管商。第 1 章写过 10 月 20 日那段泄露链条如何把这套安排打乱,以及项目如何在四小时内做了提前一天的决定。这类决定本身也要人来做,而且要在电话和聊天窗口之间做。

六件事叠起来,“七十一天”就不再显得慢了。它显得紧。

现在做一段人力账。以下全部是假设推演,不是内部记录;材料里没有人时统计,没有会议次数,我也不会替他们补。

假设一:能对“哈希表插入顺序”“bailout 路径”“shutdown 期状态复位”这类问题做出可信判断的人,数量级在个位数到十几人之间,不是几十人。依据是基金会自己的表述:做正确的修复决策需要理解整个 PHP 代码库的经验工程师,而这样的人才太少。把“太少”读成一个不足十人的量级,是一个合理的判断,不是精确的统计。

假设二:一次高风险修复的评审轮次。假设方案设计需要三轮,每轮含准备与消化共五个工作日,合计十五个工作日。每一轮需要四到六名有资格评审的人同时在场,而在一个志愿者为主的项目里,这四个人的日程交集每周可能只有几个小时。

假设三:每条分支的回归覆盖五个 SAPI 与若干内存配置组合,共约十几种组合,每种组合跑一次完整回归假设需要数小时机器时间加一个人的诊断时间。

把这三个假设合起来算一遍。若有六名核心决策者,每人每天能分给这件事的时间是两小时——其余时间在维护别的部分——七十一天的可支配总量是 6 × 2 × 71,约 850 小时。假设其中三成用在“在几个方案之间做选择”这件纯决策工作上,就是约 250 小时。如果一次有效的方案评审要四个人同时在线两小时,250 小时只够三十次这样的会议。而三十次里要装下:方案设计、三次方向调整、四条分支的一致性确认、扩展兼容的判断、以及一次在泄露压力下临时改期的协调。

这个推演的用途只有一个——把“两个月”从一个印象变成一个可以被反驳的估计,而不是证明它刚好够或刚好不够。它想说的是:那个数字已经接近这个人群的可用决策带宽,所以它不是靠加班能压下去的。你无法让人在“理解一段没读过的内存管理代码”这件事上加班。

“两个月”不是一个速度问题,是一个规模问题:参与决策的人太少,被决策影响的面积太大。

11.2 三种方案的取舍:兼容性、DoS、扩展生态

材料里能确认的是三句话:改哈希表顺序会破坏大量依赖现行为的行为与扩展;加检查会引入新的拒绝服务风险;改错误处理语义会要求大量第三方扩展修改,而这种协调几乎无法完成。

三句话,三个方案,三种失败方式。这一节要把每一种的失败代价拆到“由谁承担”这一层,因为这才是它们真正的差别。

方案一,改哈希表的插入顺序。把“先增计数器、后分配内存”改成“先分配、后计数”,是最符合直觉的修法:让半初始化的槽位在物理上不可能存在,比事后检测它更干净。

它的代价是行为变化。PHP 的数组在用户代码里是最基础的数据结构,而“写入数组”这件事的执行顺序,会以多种间接方式体现在可观测的行为上:出错时的状态、异常发生的时间点、内存分配的顺序、以及依赖这些细节的扩展代码。这里的“破坏”不是指语法不兼容,而是指那些从来没有被写进文档、但一直被依赖的行为会变。

代价由谁承担:由扩展作者承担第一层,由等着扩展更新的下游用户承担第二层,由那些把某种性能或错误行为当成前提的应用承担第三层。这个分布有一个特点:它是延后的、分散的、不可见的。没有人的服务器会因为这次改动在发布当天挂掉。会有一些人的服务器,在某个看起来无关的升级之后,开始变慢,或者开始报一个没人见过原因的错误。

这类代价在决策时最难被计入,因为它不出现在发布当天的任何仪表盘上。

方案二,加检查。不动顺序,只补一个失败分支:分配失败时把已经增加的计数器回退,把那个槽位标记为不可用。这听起来是最保守的方案——不改变任何正常路径的行为,只在异常路径上补逻辑。

问题在于异常路径本身的性质。这条路径的触发条件是内存已经耗尽,而它的执行方式是长跳转,直接落进 shutdown。在这条路径上增加工作,意味着在最缺资源的时刻再要一次资源;而攻击者可以反复触发它。修复本身于是变成了一个可以被反复引爆的开关:一次请求撞破内存墙,触发回退逻辑,回退逻辑自己也需要内存,再撞一次。这就是“引入新的拒绝服务风险”。

代价由谁承担:运维,而且在最坏的时刻承担——内存压力、流量高峰、正在被打的时候。

这一类代价在沟通上最困难。你为了关闭一个远程代码执行,打开了一个拒绝服务入口。在评分表上这是两类不同的问题,一个 9.2,一个可能只是可用性;但对一个正在被打的站点,运营者的体感是同一件事:它挂了。

方案三,改错误处理语义。这是语义上最干净的方向:既然问题的根源是“异常中断留下半成品状态”,那就让中断本身不再留下半成品——让 bailout 更早、更彻底地把状态复位。

它的代价是要求大量第三方扩展修改。扩展会捕获致命错误、会在 shutdown 阶段做事、会假设某个字段在某个时刻是有效的;一旦语义变了,这些假设全部要重写。而“要求大量第三方扩展修改”在开源生态里几乎等于“无法协调”:你不知道谁在用,你不知道谁会跟,你也没有权力要求他们改。

代价由谁承担:生态。而且是一个没有代表席位的生态。它不在任何一间会议室里,它只在发布之后才现身。

把三个方案并排看,会发现一件很冷的事:没有一个方案的代价落在做决定的人身上。

第一种,代价落在未来和分散的人身上;第二种,代价落在运维和最坏的时刻上;第三种,代价落在一个你无法谈判的对象身上。做决定的人只能靠想象来评估这三片分布,而想象是最不可靠的评估工具。这不是能力问题,是位置问题。

那么在这种局面里,什么是可辩护的做法?

我的判断是:可辩护的做法通常不是“选一个正确的方案”,而是“把每个方案的失败面缩小到可以被观察的范围”。具体说,把修复尽量限制在异常路径上,不改正常路径的行为;让新行为在测试里能被主动触发,而不是只在被攻击时出现;为有争议的语义变化留一个可以回退的余地,或者在下一个次版本里再收紧。

这些做法都不漂亮。它们不像“彻底修复”,更像是“把风险装进一个有边界的盒子里”。但在一个人力稀薄的代码库上,可逆性比正确性更值得优先——因为你无法预测改动会碰到谁,而你只有一次发布窗口。

还有一层不能忽略:这三条路不是互斥的。真实的高风险修复往往是在三个方案里各取一段,做出一组补丁,其中每一处都在缩小一部分失败面,同时接受另一部分风险。这解释了为什么这件事需要那么多轮评审——评审的不是一段代码,是一个风险组合。

修复的成本不是写代码的成本,是让许多人承担后果的成本。

11.3 共识制的代价:一个开源项目如何做高风险决策

材料对这一段的描述很短:安全团队原本是共识制决策,在多方案之间难以抉择,担心选错的后果;结果是数月内反复停滞与重启。同时,基金会在组织层面把决策机制改成了“多数决 + 鼓励折中”。

短,但它指向一个几乎所有开源项目都存在的问题。

共识制在什么情况下是好机制?低风险、高频、可回滚的决策。代码风格、CI 配置、小补丁合并、命名习惯——这些用共识制是对的。它的成本是每次讨论几分钟,收益是没有人需要被迫接受一个自己不认同的东西,冲突在日常里被消化掉了。

共识制在什么情况下会瘫痪?高风险、低频、不可回滚的决策。而这次恰好是这个类型。

原因有三层,都是机制性的,不是人品问题。

第一层,举证责任不对称。共识制给每个人一个否决权,但否决不需要理由,同意需要理由。于是任何一个人说“我觉得这样会出问题”,讨论就得继续;而说“我觉得可以了”不能结束讨论。这种不对称在信息不完备的时候会把最保守的意见变成默认结果——请注意,默认结果还包括“什么都不做”,而“什么都不做”是不需要论证的。

第二层,责任被平均分摊,等于没有人拥有那个位置。高风险决策需要一种特定的东西:有人站出来说“就这样定,出错我承担”。共识制的设计目的恰恰是消灭这个位置,因为它把权力和责任一起摊平了。和平时期这是优点,在需要有人在证据不足时举手的时候,它变成了空缺。

第三层,缺少反馈。低风险决策做错了,第二天的 CI 会告诉你;高风险决策做错了,要等几年。2024 年那次搁置就是这个形状:判断是“理论上可利用但找不到路径”,这个判断在当时是对的,而错了要两年后才知道。没有反馈的决策不可能靠讨论收敛,因为讨论唯一能依赖的是各自的先验,而先验不会因为多讨论几轮就改变。

三层叠起来的结果就是材料里那六个字:反复停滞与重启。停滞不是懒,重启也不是决心——它是同一次决策被重新打开了很多次,每次都没有走到能结束的地方。

再看改革的表述:“多数决 + 鼓励折中”。顺序很重要。

多数决解决收敛问题:它给讨论装了一个终点,让“我们还没有共识”不再是一个可以无限期成立的状态。鼓励折中解决另一件事:它降低被多数决压掉的那一方所付出的代价——少数意见不是被忽略,而是被要求写进最终方案里。这两件事合起来,是把决策成本从“无限期讨论”换成“有限期承担”。

代价也很真实:有人必须在证据不足的时候举手,并且承担举错的责任。这在一个志愿者为主的社区里尤其难,因为项目给不了他什么来补偿这个风险。

这一节还要放一句冷的话,否则前面会被读成一次治理进步故事。

8 月 11 日之后之所以能推进,不是因为治理改了,是因为一个外部时钟出现了。多路确认给出了完整影响面,一个无条件 RCE 的 PoC 已经存在,这意味着“再等等”这个选项的代价突然变得可计算了。最高优先级、多人全职、跨周末、昼夜协调——这些都不是共识制的产物,是压力替代了程序。

所以治理改革的真正意义不是让这次变快,这次已经不缺压力了。它的意义是让下一次不必等一个漏洞来替项目做决定。

共识制本身没有错。它只是把成本从“决策的时候”挪到了“决定之后很久”。

11.4 基金会的钱从哪来:公共资金、企业赞助与“搭便车”

要理解“为什么只有这么几个人能修”,得先问另一个问题:这几个人的时间是谁付的钱。

材料里有三条清晰的线索。

第一条是公共资金。2024 年那次外审,是 Sovereign Tech Agency 资助、OSTIF 组织、Quarkslab 执行的。这段安排里有一点很容易被忽略:出钱的一方、组织的一方、执行的一方是三个不同的主体。公共资金买到的不是补丁,是一段时间的专业注意力——几个真正懂 C 和引擎的人,被付钱去读一段已经被读过无数遍的代码。第 7 章说过,109618 的最初迹象不是审计报告里的条目,而是核心开发者在修别的问题时顺手撞见的。也就是说,那笔钱没有直接买到这个洞,它买到的是一个让人有机会撞见它的条件。

第二条还是同一类钱,但用在另一侧。2026 年初,基金会与 Alpha-Omega 合作,用 Claude Mythos 对 PHP 做安全扫描;此后引入 Daybreak Red 做报告验证与归组。而 Calif 的 Patch the Planet 审计,也是经 Alpha-Omega 渠道做的。

这里有一个值得停下来看的对称:发现侧的机器能力和防守侧的机器能力,是同一类钱买来的。三支队伍里的至少两支、基金会自己的扫描,都挂在同一条资助链上。这说明 AI 时代的开源安全,前沿能力的第一批使用者是那些拿到公共或慈善资金的项目——而不是那些最需要用它的项目。这个落差会在第 12 章展开,这里只标记。

第三条是企业赞助。基金会的口径很直接:需要靠赞助才能雇多人全职处理这次修复。这句话把开源可持续的老问题写得很清楚:不是没有工作量,是没有编制。

还有一类来源值得作为一个类别提一下:个人捐赠。它是几乎所有开源基金会资助结构里的第三根支柱。但材料没有给出 PHP 基金会这一项的任何数字,我也不会给它编一个比例。这里只说结构:个人捐赠提供的是稳定性和独立性,它买不到全职人力,它的作用是让项目在最坏的时候不至于完全依赖某一个赞助方。

四类钱放在一起,会看出一件很别扭的事:这套资助结构里,付款方和受益方的边界相当模糊。

任何用 PHP 赚钱的公司——托管商、面板厂商、SaaS 公司、电商平台、外包团队——都在享受一个非常便宜的底座。这门语言免费、稳定、生态巨大、招人容易。它的价值恰恰在于“所有人都在用”,而不在于“谁在做差异化”。这意味着没有任何一家公司有独占动机去维护它:你投入的每一块钱,收益被所有同行分享,包括那些一分不出的。

这就是“搭便车”。需要说清楚的是,它不是道德问题,是激励设计问题。

它的机制是这样的:受益是分散的、可测量的、落在业务部门的账上——省下的自建成本、省下的招聘成本、省下的迁移成本;出资是集中的、自愿的、落在某个人的判断里。这两个数字在任何一家公司的报表上都不在同一个科目。于是不作决定就是默认选择,而默认选择不需要任何人负责。

结果是一个稳定但长期偏低的投入水平。它稳定,是因为没有人有动机打破它;它偏低,是因为没有人有动机抬高它。

现在放一段反证,因为这一节很容易走向一个太便宜的结论:多给钱就好了。

先说钱能买到什么。钱能买到时间——把一个人从别的项目里赎出来,让他全职做这件事;钱能买到注意力——一次外审、一轮扫描、一个驻留岗位;钱能买到带宽——分诊、验证、回归这些不需要天才但需要人做的事。

再说钱买不到的,有三样。

第一,对代码库的历史理解。这不是靠读代码获得的。你读到的是一段现在正确的代码,而你要判断的是“改动它会破坏什么”,答案藏在那些已经被删掉的分支、被修过的 bug、被某个扩展依赖过的偶然行为里。这部分知识只存在于少数人的记忆和几千条提交记录里,而提交记录只能告诉你改了什么,不能告诉你当时为什么那样改。这是一种只能靠年头积累的资产。

第二,招聘周期长于漏洞周期。假设一个有资格改 PHP 内存管理的工程师,从被找到到能独立做判断需要以年计——这个假设我认为是保守的;而这次漏洞的披露周期以周计,两个 CVE 从确认到发布是七十一天,从泄露到发布是不到两天。钱在这两个时间尺度之间无法兑换。你在危机中付多少钱,都不会让一个还没读完代码库的人明天做出可信的决策。

第三,知识集中在少数人身上,而钱不能复制人。你可以同时雇五个新人,但他们不会因此变成五个能签字的人。在需要“有人负责”的位置上,人数不能替代资格。

还有一个更强的反驳值得回应:那就长期投,平行培养一批人,十年后就不缺了。

这个反驳在原则上成立,而它恰好撞在这本书的标题上。洞等了十年,人也要等十年。这个时间尺度上的对称,说明“投资人才”是对的,但它不是一次应急响应的解法,它是下一代基础设施的解法。用它来回答“这次为什么慢”,是答非所问;用它来回答“下次还慢不慢”,才是对的。

钱在这个体系里的准确位置是:它买到的是让那几个人不必分心。这已经很贵,也很重要,但它是减速带,不是引擎。

11.5 新岗位:AI Security Engineer in Residence 意味着什么

组织层面的动作比技术层面的动作更容易被记者写出来,也更容易被读者低估。因为它们看起来像人事安排,实际上是一次关于“带宽从哪里来”的公开回答。

材料里能确认的机构动作是:2026 年 5 月成立 Ecosystem Security Team;随后有三个具体的人在不同的月份出现。

Alexandre Daubois,7 月,支援分诊。请注意这个时间——它在 8 月 11 日之前。也就是说,队列的压力早于这次事件存在。材料给了一个能对应的数字:自 2026 年 7 月 1 日起,PHP 已经发了 15 个 CVE,处理了 100 多份无效、重复或不可复现的报告。

Daniel Scherzer,9 月,方向是加固。9 月正好是 9 月 21 日启动协调修复的那个月。

Volker Dusch,AI Security Engineer in Residence。

三个时间点拼起来,是一条可以被读懂的曲线:先有人处理进来的报告,再有人做系统性的加固,最后有人把 AI 接进流程。这个顺序不是随机的——一个项目在队列爆炸时最该补的是入口,不是出口。

这一节的重点在第三个头衔上。“in Residence”这个词值得停一下。

驻留不是入职。它是有期限的身份,通常由外部资金支持,任务是一次性的:把某种能力接进来,把方法留下来,然后离开。它不进编制,也不占长期预算。

这个措辞本身就是一次诚实的自我定位。它等于承认:这件事无法被现有编制吸收,只能临时借一个人进来。对一个以志愿者为基本形态的开源项目来说,说这句话是有成本的——它透露了“我们没有余量”。

那么,在开源项目里“引入一个人”到底意味着什么?三件事,都不是产能。

第一件是沟通成本,而且它不是线性的。新人要读代码库的历史,而历史不在代码里,在老人的脑子里。在知识高度集中的项目里,引入一个人的前三个月,老人的产出会下降——他要花时间解释为什么这里是这个样子。这笔账在任何招聘计划里都不会出现。

第二件是决策带宽,这里有一个反直觉的点:增加人数不等于增加决策带宽。真正稀缺的不是人手,是“同时在场且都读过相关代码的人”。在上面的推演里,一次有效评审需要四个人同时在线两小时——如果这四个人各自被别的事占着,多一个助手并不能让评审发生。

但驻留者能改变这件事的方式很特别:他不参加决策,他压缩决策所需的信息量。他替核心开发者做完前期的证据整理、复现、对照、写清方案各自的失败面。于是一次评审从四小时变成一小时,从“四个人的日程交集”变成“两个人的结论加一个人的复核”。带宽不是靠加人扩大的,是靠减少每个人需要处理的信息扩大的。

第三件是知识传承,最慢,也最关键。一个驻留者留下的东西不是代码,是一套判断习惯和一批“为什么这样修”的记录。这部分价值在他离开之后才显现,而且它有一个特点:它是可以被继承的,代码不是。代码会随版本演化,判断习惯会跟着人走。

顺着这个逻辑看,“AI Security Engineer”这个岗位名是准确的。它不是“安全工程师加一个 AI 工具”,它是“知道 AI 该放在流程哪个位置的人”。这两者的差别在于:前者依赖对漏洞的经验,后者依赖对流程的理解——知道模型在哪个环节的输出可以被直接采信,知道在哪个环节必须有人签字。

而基金会反复强调的那三件事,恰好划出了这条边界:AI 不能决定中长期设计问题的正确修复方案,不能权衡兼容性取舍,不能审查补丁。三个“不能”都不是能力问题,是权力问题——它们是必须有人负责的动作。一个岗位如果存在的意义是把这三个动作之前的所有工作压缩掉,那它的名字就该这样写。

引入一个人不是增加产能,是增加带宽的密度。

11.6 结构性失败的承认:这段自述为什么重要

基金会把修复的停滞称为“结构性失败”。

这五个字在一份项目公告里出现,是不寻常的。它不是“我们修复得比较慢”,不是“这个漏洞很复杂”,也不是“我们在协调多个下游”。它说的是:我们的结构会必然导致这件事变慢。

技术复盘和组织诊断的区别在这里。技术复盘回答“我们这次做错了什么”,答案是具体的,可以被修掉——改一行代码、加一条测试、补一个检查。组织诊断回答“我们的结构会让我们反复做错什么”,答案不可被修掉,只能被改制度。

组织级的自我诊断比技术复盘稀缺,原因有三个,每一个都很硬。

第一,它有成本。承认结构性失败,等于承认这件事会再发生。组织的本能是承诺“不会再发生”,因为承诺不花钱,而承认要付信用。

第二,它需要抽象能力。把七十天里的具体困难——方案难选、人手不够、队列里全是噪音、四条分支要同步——归到一个机制上,比列出五个 bug 难得多。多数复盘停在列清单,因为清单看起来像行动。

第三,它不可归因到个人。技术复盘可以点名,结构性诊断不能。而在压力之下,组织的第一个反应往往就是找一个可以点名的地方。

这三个原因叠起来,说明这段自述的价值不在措辞,在于它拒绝了一条更容易的路。

那么它对别的开源项目意味着什么?我认为有三条是可以迁移的,而且都不需要 PHP 的具体知识。

第一条,决策机制是安全基础设施的一部分。一个项目能不能在七十二小时内做出一个高风险决定,和它能不能在七十二小时内打出一个补丁,是两件不同的事。而后者通常被写进了安全策略(有 SLA、有响应时间、有升级路径),前者往往完全没有被写进任何文档。共识制、否决权、谁有权签字、僵局怎么打破——这些听起来像章程细节,实际上是补丁能不能按时出现的决定因素。这次事件里,解锁它的是外部压力,不是章程。

第二条,带宽要按“发现供给”投资,不是按历史平均。当发现成本塌陷,队列的形状会变,而且不是简单地变长:它会变成“大部分是噪音,少数是紧急”。100 多份无效、重复、不可复现的报告,和真正需要处理的条目,混在同一个入口里。这类队列不能靠“处理得更快”来对付,只能靠分诊结构来对付——先分类,再决定谁看,谁有权直接关闭。7 月那个支援分诊的岗位,就是这条判断的组织形态。

第三条,单点知识是风险,不是效率。一个只有三个人真正理解的模块,在和平时期是效率:决策快、争议少、改动能落地。在危机时期它是单点:这三个人一旦被同一件事占满,全项目停摆。这个从“效率”到“单点”的转换点,没有任何系统会提前标注它。2024 年那次搁置之所以能发生,很可能不是因为没有人在意,而是因为在意的那几个人正好在忙别的——这句话是我的推断,材料没有这么说,但它符合“人手不足”这个已被确认的事实。

这里必须放一句自我限定,否则这一节会变成对一份公告的赞美。

承认不等于修复。说出来的结构性失败也可能变成一种免责叙述——“我们结构有问题”比“我们这次做错了”更容易被原谅,而原谅会降低下一次的压力。这段自述的价值,取决于它后面跟了什么。材料里能看到的后续是具体的:一个团队、三个岗位、一次决策机制改革。它们不是承诺,是已经发生的事,这让那段自述有了分量。

本章的结论不是“开源不可持续”。这个判断太整齐,而且它把责任推给了一个没有主体的东西。开源是可持续的,只是它的可持续性一直由一群特定的人在补贴——用他们的夜晚、他们的优先级、他们的职业生涯。

七十一天里真正稀缺的东西不是算力,不是模型,是“有资格说这句话并且愿意签字的人”。

而这个位置为什么一直空着,为什么它空着的时候没有人报警,是这本书还没处理的部分。剩下的问题有四个:能力边界在哪里,责任该落在谁头上,为什么防守方的采纳速度更慢,以及制度的哪几处正在滞后。下一章要一个一个把它们摆出来,而不给它们答案。

第 12 章 剩下的问题:能力边界、责任与下一个十年

“这不是关于 AI 会不会发现漏洞,而是关于永久性地关闭漏洞被发现的速度与组织能响应的速度之间的那条差距。”

——SANS 与 CSA《The AI Vulnerability Storm》,2026 年 5 月(六十余名署名贡献者,二百五十余名 CISO 审阅)

这一章要回答的问题,是前面十一章一直绕开的那个:这本书能说清“发生了什么”,但它说不清“该怎么办”。

前十章基本是描述性的:洞在哪里、机器做了什么、成本怎么变、行业怎么反应。描述可以精确,因为它面对的是已经发生的事。从第 11 章开始,语气变了——维护者的负担、开源的经济学、结构性失败的自述,这些已经带有判断。这一章要把判断的部分全部摊开,并且明确标出哪些地方我没有答案。

四类问题按顺序摆:能力的边界在哪里;当 AI 找出的洞被 AI 写出的利用打穿,责任该落在谁身上;为什么防守方的采纳速度慢于攻击方;以及披露规则、责任法、关键基础设施标准这三处制度滞后。最后是一个更根本的问题——如果修复成本也被压下来,安全会不会只是一场更快的追赶。这个问题没有现成答案,我只能给出三种情景和各自的观察指标。

12.1 能力的边界:模型不擅长的三类问题

PHP 基金会在事后的技术自述里给了三条边界,口径很清楚:模型不能决定长期设计问题的正确修复方案,不能权衡兼容性取舍,不能审查补丁。

第 8 章 8.6 节讲过这三条在这次事件里的具体表现。这一节要做的是另一件事:把它们从“这次的三次失败”提升为三类问题,并说明为什么它们不会随模型变强而自动消失。

第一类:长期设计取舍。

109618 的补丁至少有三种候选路径,第 11 章 11.2 节讲过它们的取舍结构:一种更贴近根因而改动语义,一种改动面小但留下同类模式,一种在扩展层面加约束。三条路都算“能修”,区别在于风险落在谁身上、什么时候落。

改成更彻底的那种,兼容性破坏的成本落在扩展作者身上——他们没有参与决策,却要在自己的项目里消化这次语义变化。改成更保守的那种,新 DoS 风险的账单落在运维身上,因为他们要在真实的流量里承担一个“理论上更安全”的引擎。选择改动面最小的那种,回归风险落在维护者身上,因为他们要为“没有完全修掉”这件事在半年后解释一次。

这是同一份代码上的三种成本分配,不是三种技术方案。模型能做的事很清楚:把每条路的后果推演到足够细,把“谁会受伤、受多重的伤”列成一张表。它做不到的是在表上打勾,因为打勾需要一个共同的目标函数,而这里没有。三类人的损失不可通约,把它们换算成同一个量纲必须先有一个价值排序,价值排序不在代码里。

所以即便模型能力再上一个量级,它能给出的最好东西也只是把账摊得更清楚;记账的人还是得是人。

第二类:跨生态兼容判断。

这一次的补丁同日发布在四个分支上:8.5.12、8.4.27、8.3.36、8.2.35。同一份根因,四套 ABI 承诺。往下还有一层:每个发行版有自己的回移政策与时间表;容器基础镜像有自己的重建节奏;控制面板、共享主机、Serverless PHP 运行时各有各的封装方式。

一份在 php-src 上正确的补丁,在 Debian 打包的 8.2 上、在一个依赖旧 bailout 行为的扩展上、在一个连 session 都没开的最小应用上,“正确”的含义都不一样。判断这件事需要的不只是代码,还需要一张“谁在依赖什么”的图:哪个扩展依赖了某个析构顺序,哪个框架的兜底逻辑假设了某个错误形态,哪个托管商的监控靠某条日志文本告警。

这张图不在任何仓库里。它在成千上万个项目的 issue 区、在邮件列表的旧帖里、在没有人写下来的运维经验里。我的判断是:这类判断的成本几乎全部是信息成本,而信息成本不会被算力摊薄——它只会被更努力地收集摊薄,而收集的对象在别人的私有系统里。

第三类:对“没有反例的断言”负责。

补丁通过了全部测试。这句话说明的是:测试覆盖到的那些失败形态没有出现。它不说明“没有别的路径能到达这个状态”。后者是一个负向断言,它的判据是完备性,而测试集的完备性只能由人来断言。

这次的检测规则也是同一个结构。三层特征——异常超长表单字段名(约三点五兆字节)与空名字段的二进制值;Allowed memory size ... exhausted ... in Unknown on line 0 与 Apache 的 AH00052 ... Segmentation fault (11);web worker 派生 shell——覆盖的是“这一次利用的形状”,不是“利用的种类”。规则写得再好,也只是把一个已知的反例钉住。

Datadog 在事后写过一句值得记住的话:补丁修掉了已知的利用,但“先更新元数据、再分配内存”这个模式本身仍有风险,同类漏洞会再来。这句话等于承认:修复的判据不是“这类洞没有了”,而是“这一条路径关上了”。

2024 年那次搁置是同一个结构的另一面。当时缺的不是“可疑”——PHP 核心开发者已经看见了 109618 的最初迹象,判断是理论上可利用。缺的是反例。一个没有反例的断言,在工程上无法结案;而模型擅长生产正例,不擅长为一个否定命题负责。

一段反证。

有人会说:这三类边界都只是当下的技术水位。推理链更长、自我验证、形式化方法,都会把它们吃掉。

我的回应分两半。

承认的一半:第三类确实可能被大幅压缩。12.5 节会讲,形式化验证做的正是把“完备性”从人的直觉变成一条可检查的规格——这是唯一一个可能真正解决负向断言的技术方向。但规格要有人写,写规格就是判断:断言什么、允许什么、忽略什么,都是取舍。判断的位置只是上移,不是消失。

不接受的一半:第一、二类是分布问题,不是能力问题。模型完全可以把“破坏兼容性会影响多少扩展”算得比人更准,甚至可以给出概率分布。但“该不该让这部分人承担成本”没有正确答案可算。这类问题上,模型的最好用途是提供更清楚的账,而不是替人记账。

一句话定性:能力边界不在模型能想多远,而在它无法承担错误的代价。

12.2 责任归属:当 AI 写的利用打穿 AI 找的洞

这条链条确实完整、可复现。

2026 年 7 月 30 日起,Calif 在 Patch the Planet 计划下用 GPT-5.6 Sol 审计 PHP;8 月 11 日报告无条件 RCE,同日 OpenAI 独立报告了相同根因。8 月 19 日到 24 日,Wiz 用 Wiz Atlas 配合 Gemini 4 Argon 与 GPT-5.6 Sol 抵达可执行利用,并在 WordPress、Drupal 上做了未认证 RCE 的演示,随后上报 PHP。9 月 21 日 PHP 安全团队确认并启动协调修复。10 月 21 日补丁发布。

现在假设一个不存在的分支:这批报告里的某一份,在补丁可用之前被某个第三方拿到,并且用同类的模型能力在几小时内完成了适配,造成实际损失。损失该谁赔?

这不是修辞问题,是一个结构问题——四类主体都能举证自己只做了自己那部分,而每一种归责逻辑都会产生不同的后果。

逻辑一:模型提供方。

它的来源是产品责任思路:把模型当作交付物,提供方对可预见的输出后果负责。

后果有两条。第一条是过滤:要压低输出的危险性,最直接的动作是让模型在生成可运行利用时拒绝或降级。代价是攻防同源——第 9 章讲过,让防守方一周完成数月工作的能力,和让攻击方规模化利用的能力是同一套东西;过滤会把两侧一起削掉,先削掉的还是防守侧最需要的那部分。第二条是准入:只向通过审查的客户开放,结果是能力向有合规部门、有法务、有安全团队的大组织集中,中小防守方被排除在最好的能力之外。

还有第三条后果,更隐蔽:免责条款与责任保险。责任一旦落到提供方,商业上的第一反应不是提高安全性,是把责任以合同方式转移出去,成本最终进到使用者的账上。而使用者多数是防守方。

逻辑二:使用者。

它的来源是操作责任:谁调用,谁负责。类比很朴素——开车的人负责,不是造车的人。

这是四种逻辑里最可能被默认采纳的一种:不需要新立法,只需在现有注意义务框架里加一句“使用自动化工具不免除注意义务”。它也是最容易产生寒蝉效应的一种,因为它不区分调用者的目的。

Wiz 在这件事里做的是防守动作:发现、验证、上报、演示影响面、然后等补丁。如果“生成了可运行的利用”本身成为归责依据,那么最先停手的不会是最该停手的人,而是最守规矩的人。这是一个可预测的后果:规则的执行力永远弱于规则的自愿遵守率,而寒蝉效应作用的恰恰是愿意遵守的那部分。

逻辑三:维护者。

它的来源是把“未修”读作过失。

这条逻辑在法理上最薄弱,在舆论上最强。维护者没有对价关系、没有合同、没有法务、没有免责团队,一旦被纳入这条逻辑,最理性的选择是退出。而这次事件恰恰暴露了另一面:有引擎知识的核心开发者本来就少,基金会的修复靠赞助雇人全职做,还把决策机制从共识制改成多数决加鼓励折中(第 11 章)。

PHP 基金会在自述里用了“结构性失败”这个词。这句话在工程语境里是诚实的自我批评,在法律语境里是一份危险的自我描述——它等于承认知道问题而未能解决。

我的判断:这是四种逻辑里最不该被采纳的一种,也是社交媒体上出现频率最高的一种。这两件事同时成立并不奇怪:最容易传播的归责,往往是最难执行的归责。

逻辑四:部署方。

它的来源是“谁暴露谁负责”,与现有合规体系最兼容。

后果是分层失效:对有安全团队、有变更流程、有预算的组织,这条逻辑可执行;对 EOL 债务最重的组织不可执行——约一半 PHP 部署运行在停止支持的分支上(第三方估算),这些组织既没有能力也没有人。一条只对强者可执行的规则,实际效果是把风险向弱者集中。

四种逻辑叠加会怎样。

答案是责任稀释。每一方都能证明自己履行了本环节的注意义务:提供方做了过滤,使用者没有恶意,维护者已经在全职推进,部署方在补丁发布后完成了升级。总损失没有人落账。这可能是未来几年最可能出现的实际状态:不是有人被判责,而是没有人被判责。

目前没有法律答案,这一点必须说清楚,不能含糊过去。模型输出在“产品”与“服务”之间的定位没有定论。一种可能的解读是:把它归为产品,则“缺陷”难以定义——一个能找到零日的模型,到底是有缺陷,还是过于有效;把它归为服务,则注意义务的标准要按“同类服务提供者的专业水平”来定,而这类服务没有可比的行业基准。欧盟 AI Act 已经建立了按风险分级的管理思路(本节不展开条款细节),但分级管理解决的是“是否合规地提供”,不是“损失由谁承担”。这两件事经常被混为一谈,而混为一谈的后果是:合规做得好,不等于责任分得清。

最后做一个显式标注的假设:2024 年那次搁置之后,如果有 agent 独立抵达同一根因并生成可执行利用,而当时没有任何补丁在路上——按上面四种逻辑,每一方都有可辩护的立场。维护者说资源不足,使用者说目的是防守,提供方说输出未被用于攻击,部署方说不知道有这个洞。四种立场都成立,这正是问题所在:一个所有立场都成立的责任体系,等于没有责任体系。

一句话定性:责任真空不是没人管,是每个人都在管自己那一段。

12.3 扩散的不对称:为什么防守方的采纳速度更慢

一个很自然的期待是:机器对双方是同源的,所以攻防会同时提速,平衡不变。

这一节的判断是:同源不等于同步。同一件工具在两套激励结构里的采纳速度差一个量级,而这个差值本身会改变平衡。

防守方慢的四个原因,每一个都可以具体到流程。

预算周期。 安全预算按年批,漏洞按小时到。一个新工具要进预算,通常需要先有一个“去年的损失”来论证必要性;而 AI 审计的收益是“没有发生的损失”,这种收益无法入账。这不是财务部门的迟钝,是会计的结构:不发生的事没有凭证。

合规审查。 把代码和漏洞报告送到外部模型,等于把最敏感的资产清单交出去。数据处理协议、模型是否留存输入、输出是否可审计、跨境传输怎么算,每一项都要走一遍评审。这次事件里 Alpha-Omega 渠道用 Claude Mythos 扫描 PHP、用 Daybreak Red 验证报告,是公共资金买来的能力;商业组织要复制这条路径,先要过的是自己的法务。

变更管理。 这一条最容易被低估,也最与技术无关。防守方的任何自动化最终都要落到生产环境,而生产变更有窗口、有回滚、有审批。像 PHP2Shell 这种语言层漏洞,完整修复意味着一次全量滚动升级,涉及 FPM 集群、容器基础镜像、控制面板与共享主机(第 5 章)。这个动作的周期以周计,与 AI 一点关系都没有。发现速度从月变到小时,升级节奏还是周——剪刀差就在这个缝里。

责任规避。 引入 AI 的判定,意味着流程里多了一个签字点。如果流程出错,签字的人要解释为什么相信模型的输出。在没有责任法的环境下,理性的选择是“不用它,就没有这个签字点”。这一条与 12.2 是同一个问题的两面:责任不清,会以“少做事”的形式表现出来。

攻击方快的三个原因,同样具体。

无合规约束:没有评审委员会,没有数据处理协议,没有变更窗口。

失败成本低:一次失败的利用只是重试,日志里多一行;一次失败的生产变更是一次事故,要写复盘、要通知客户、要报监管。风险不对称会直接反映在尝试次数上。

收益即时:一次成功即可变现。材料里有一个可参照的量级——2026 年 9 月,有 agent 驱动的凭据收割行动在 6 小时内搭好基础设施,其中存有 23800 个以上被盗密钥。这个数字说的是速度,不是规模,不宜外推。

还有一条更根本的:验证方向不对称。 防守方要证明“没有洞”,攻击方只需证明“有洞”。这就是证明成本塌陷对双方意义不同的原因——它把“有洞”这一侧的证明变得极便宜,而没有让“没有洞”那一侧便宜一分。第 7 章说过,可利用性证明是这个行业里最贵的一步;这一步变便宜,首先便宜的是不需要完备性的一方。

一段反证,而且这次的反证很有力。

必须承认反方向的证据。这次事件里,防守侧有真实的速度记录:Cloudflare、Fastly、Google、AWS、Microsoft、Akamai、Imperva、Vercel 在数小时内发布了缓解规则或指引;Wiz Sensor 1.0.12768 内置了响应策略,可以自动终止表现 PHP2Shell 利用行为的进程。这是厂商级联动,它确实构成了基础设施韧性的一部分——第 10 章把这个现象当作新常态来描述,是准确的。

我的回应是位置,不是否定。

这些是分布里的中心节点:有工程能力、有商业激励(保住客户)、有 24 小时值班表。不对称不在最前面那一层,而在后半段。WordPress 驱动约 40% 的网站,长尾在共享主机、控制面板、容器镜像、EOL 分支、内网系统与被遗忘的测试环境里(第 5 章)。中心节点的速度提升不会自动传导到长尾,因为传导需要长尾组织自己做变更,而它们正是最没有能力做变更的那部分。

所以更准确的说法不是“防守方慢”,而是“防守方的速度分布变得更陡”。平均值可能提高了,方差扩大得更快。而风险从来是由分布的后半段决定的。

一句话定性:攻防同源,但不同步;速度的分布比速度的平均值更重要。

12.4 制度的三处滞后:披露规则、责任法、关键基础设施标准

这一节要处理的是“制度跟不上”这句常见的判断。它太顺口,所以需要拆开:滞后的不是速度,是前提。

12.4.1 披露规则:假设了信息稀缺且可控

协调披露(CVD)的模型可以列清:知情人有限,时间点可协调,信息在协调期内可控。这套假设在研究者和厂商的二元关系里成立。

PHP2Shell 的泄露链条精确地打在这三条上,而且三段性质各不相同。

2026 年 10 月 20 日 15:00 UTC 前后,CVE 编号在论坛流出,附带错误的时间和错误的 10.0 评分(官方评分是 9.2,10.0 是 watchTowr 自评,第 1 章校准过)。信息在扩散的第一步就已经失真——控制得住时间,控制不住解释。

同日约 20:00 UTC,Calif 误发了 PoC 演示视频,删除但已扩散。这一条最值得注意:它不是恶意,是一次误操作。而 embargo 的可靠性不能建立在“所有参与者都不出错”上,因为参与者一多,误操作的期望次数一定上升。

10 月 21 日 07:41 UTC,Chainguard 单方打破 embargo,提前发布修复镜像。这一条的逻辑又不同:它不是泄密,是抢在窗口前把修复送到用户手里,按它自己的价值排序是正确的。三种行为,三种正当性,合起来让协调披露名存实亡。

失效机制要讲到机制层面。embargo 的价值来自同时性;同时性依赖两个条件——知情人数有限,且守时。当知情人数增加(三支独立的队伍、多个模型、托管的第三方、镜像构建方、CI 流水线),并且每一个知情者都能在小时级生成可运行的 PoC,提前量就从“小时”变成“事件”。escrow(托管)的信任假设同一回事:各方守时守规是前提,一旦有人提前动,所有人受损,且不可逆——信息释放之后收不回来。

我的判断:这不是流程设计失误,是流程所依赖的前提被成本变化取消了。当披露的成本降到接近零,任何以“控制披露”为核心的制度都会持续漏。

12.4.2 责任法:分类学的边界,不是法条的疏漏

三种现行的分类思路,逻辑各不相同。

产品责任以缺陷为要件,通常偏严格,但有一套免责事由;它的前提是交付物固定、可复现、有明确的制造者。服务责任以过错与注意义务为要件,标准取决于同类服务提供者的专业水平;它的前提是有可比的行业基准。开源豁免建立在非商业、无对价的基础上,通常获得较宽的免责空间;它的前提是“没有收益,所以没有相应的义务”。

模型输出在这三者之间没有落点。它不是产品,因为交付物每次都不同,同一个输入也可能给出不同输出;它也不完全是服务,因为输出的后果可以很远,远到超出任何一次服务关系的边界。

我判断分类失效的原因是它建立在三个前提上:制造者与使用者关系清晰,交付物固定可复现,损害与行为之间有可追踪的因果链。模型输出不满足任何一条。这不是法条写得不够细,是分类学的边界——硬把新东西塞进旧格子,得到的不是责任,是争议。

明确地说:目前没有法律答案。这一节的目的是把问题定位清楚,不是给出解法。

12.4.3 关键基础设施标准:建立在“发现速度有限”之上

这一处滞后最硬,因为它可以被技术事实直接验证。

CISA KEV 这类机制的逻辑是:确认“已知被利用”,然后限期修补。它成立的前提是“被利用”的信息晚于“被公开”,因此存在一个可操作的窗口。

当 PoC 在补丁之前就已经在野外,这个时间结构就失效了。这次事件的窗口是负的——编号先漏,PoC 视频先传,镜像提前 6 小时发。清单发布的时候,损伤可能已经发生。也就是说,KEV 不是在缩短响应时间,它是在一个已经过去的时间点上做排序。

第二层更硬:标准通常假设“补丁可用之前可以先用缓解措施过渡”。这次这个动作在技术上几乎是空的。session.upload_progress.enabled=0 只切断了 109619 的一条路径;memory_limit=-1 不能根除问题;不能用 RLIMIT_AS 或 RLIMIT_DATA 兜底;Cisco 的公告直接写了 “no workarounds available”;只有打补丁是完整修复。标准里的那个“过渡期”,在这次的漏洞面前是虚构的。

第三层:标准假设组织有修复能力。约一半部署在停止支持的分支上(第三方估算),PHP 虽为 7.4、8.0、8.1 提供最小源码 backport,但发行版与厂商的覆盖速度不一。对这些组织,标准只能靠例外条款遵守,而例外条款一多,标准就退化成文书。

12.4.4 一段克制的题外话:军控动作与技术现实之间的落差

“AI 安全军控”这个说法近年出现得越来越多。这一小节只做一件事:把可观察的政策动作与技术现实并列,不做政治判断。

可以看到的动作大致四类:对可用于网络攻防的模型能力做分级评估;对前沿模型的访问设门槛;对权重与算力的流动设限;用公共资金支持防守侧,例如 AIxCC 这类竞赛、Alpha-Omega 这类资助渠道。

落差有三处,都是可观察的,不需要评价。

第一,能力存在于权重里,而权重可以被蒸馏与迁移:一处受限,能力会以社区微调的形式在数周内重现。门槛调节的是“谁先拿到”,不是“谁能拿到”——效果是延迟,不是阻断。

第二,能力评估是行为评估。行为评估测的是“模型在被测试时的表现”,而测试场景是可以被识别的——不是模型有意图,是训练分布里本来就有“被评估”这一类样本。任何以测试为判据的资格审查,都会催生针对测试的优化。

第三,也是最关键的一处:这些动作调节的是“前沿模型的可得性”,而决定攻防平衡的量是“发现成本与证明成本的比值”。当这个比值被压到很低,即使最强的模型只对少数机构开放,中端模型的可用性提升也足以让比值维持在低位。于是可以观察到的现象是:政策动作在增加,而可测量的攻防成本比没有相应变化。

我的判断:技术上的可干预点不在模型这一端,在修复侧的产能。谁付钱让理解代码的人变多,比谁拿到最强的模型更影响结果。这句话没有政策含义,只有一个工程含义:产能可以用钱买,而且比算力便宜。

一句话定性:制度滞后不是因为制度慢,是因为制度优化的是“信息可控”和“发现有限”这两个已经不存在的前提。

12.5 下一个十年:如果修复成本也被压下来

全书反复用三段成本这个框架:发现成本塌陷,证明成本塌陷,修复成本几乎不动。三者的剪刀差,就是压力的来源。

这一节要做一件前十一章一直避免做的事:假设第三段也塌陷。这不是为了乐观——不做这个假设,就没法讨论“下一个十年”。

12.5.1 三个正在发生的机制

自动补丁生成。 AIxCC 的结果可以作参照:识别了 86% 的合成漏洞(半决赛为 37%),修补了其中的 68%,另外发现了 18 个 DARPA 未植入的真实漏洞、提交了 11 个可行补丁。这个数字要小心读:合成漏洞识别率高,真实漏洞与可行补丁的绝对数小,说明“生成候选补丁”与“可合并的补丁”之间还有一段距离,但那是工程距离,不是原理距离。

形式化验证与规格生成。 这直接对上 12.1 的第三类问题:把一个负向断言变成一条可检查的规格。这是唯一一个可能真正解决“完备性”的技术方向,但它的瓶颈会立刻转移到“谁来写规格、规格写错了谁负责”。规格就是判断的书面形式,写下来只是让它可被质疑,不是让它自动正确。

AI 审查 AI 的补丁。 防守侧已经在做相近的事:Daybreak Red 在这次事件里的位置是验证报告与 PoC,而不是生成报告(第 10 章)。下一步自然是验证补丁——判据不是“这个补丁能不能挡住这个 PoC”,而是“这个补丁是否改变了任何不该改变的行为”。后者需要一张行为基线,而基线是什么,仍然要人来定。

12.5.2 三者都成立时会怎样

瓶颈从“修”变成“裁”。 当候选补丁以百为单位出现,维护者的工作变成选择与拒绝,而不是编写。这个位置不可自动化,理由与 12.1 完全相同:拒绝需要一个能承担后果的主体。

责任的重心会移动。 修复的边际成本下降,意味着“不修”从“没能力”变成“没选择”。这是一个非常关键的变化:今天部署方不修,可以解释为资源不足、供应商不支持、EOL 无法升级;当修复变得便宜可控,“不修”的辩解会消失,责任重心会从维护者移到部署方。我的判断是:这可能是未来十年里对组织行为影响最大的一次位移,比任何一次模型升级都大。

一个新的风险:同质化。 如果绝大多数补丁由少数几族模型生成,那么它们的盲区是相关的。Datadog 说“先更新元数据、再分配内存”这个模式本身仍有风险、同类漏洞会再来;如果补丁只是按“这一次的利用形态”生成,那么同一个模式的下一次变体会被同一批模型以同样的方式漏掉。这是我对方向的一个判断,不是已观察到的现象,但它可以从“攻防同源”直接推出来:能力的相关性决定了失败的相关性。生态多样性在过去是可靠性的来源,如果修复也集中到少数模型,这份多样性会先在最关键的地方消失。

12.5.3 三种情景与各自的观察指标

我不给概率:三个机制的时间表都未知,给概率只是把不确定性换成一个看起来精确的数字。

乐观情景。 修复成本降到与发现成本同一量级,公共资金与合规要求同时跟上,维护者从“修”变成“裁”,漏洞通胀被吸收。可观察指标:主流开源项目从“公开披露到稳定版补丁可用”的中位时间进入小时级,且补丁回滚率不高于当前水平——即速度的提升没有以质量为代价。

中性情景。 修复成本下降,但被新增的漏洞供给与裁决成本吸收。瓶颈从“修复时间”变成“裁决时间”,行业规模扩大,相对位置不变。可观察指标:同一根因的多份报告,从上报到归组完成所需的人工工时——这个数字如果不降,“修得更快”就不会转化为“风险更低”。

悲观情景。 修复只在中心节点被压下来,长尾与 EOL 债务继续存在,同质化补丁制造相关失败,“打补丁”从安全动作退化为合规动作——完成了流程,没有降低风险。可观察指标:EOL 分支上的部署占比是否维持在约一半且不下降;如果修复成本下降而占比不动,说明成本下降根本没有到达最需要它的地方。

三种情景的共同点比差异更重要:它们都把瓶颈从技术移到了裁决。这也回答了这一章开头那个更根本的问题——当防守方也拥有同样的机器,安全会不会只是一场更快的追赶。答案是:如果双方都只是更快,那确实只是一场更快的追赶;只有当裁决这一环变得可规模化,追赶才会变成拦截。而裁决可不可以规模化,目前没有证据支持。

一句话定性:如果修复也变便宜,稀缺的东西就从补丁变成裁决。

12.6 本书的结论不是「AI 改变了一切」

写到这里,可以回答这一章开头的问题了。但回答的方式不是给一个更大的框架,而是把“变了”和“没变”分开列清楚。

变了的三件事。

成本结构。 发现与证明从“人月”变成“token”。这是全部变化的源头,也是这本书唯一一个可以量化的主张。其余所有结论都必须挂在这个变化上,否则就只是修辞。

分工。 研究者从“读代码的人”变成“决定哪些报告值得读的人”;维护者从“修”变成“裁”;厂商级联动从加分项变成基础设施韧性的一部分——数小时内的缓解规则发布,已经是应急响应的新常态;基金会开始设 AI Security Engineer in Residence 这样的岗位(第 11 章)。

速度。 N-day 窗口从周压到小时,这次甚至是负的:补丁还没发,PoC 视频已扩散,镜像已提前 6 小时。这是所有变化里最容易被感受到、也最容易被误读的一个——被压垮的不是发现环节,是响应环节。

没变的三件事。

理解系统的稀缺性。 修复慢的第一原因不是钱,也不是流程,是有引擎知识的核心开发者不足。AI 没有增加这类人的数量。它可以读完整个代码库,但“知道改了这里会让谁受伤”这件事仍然长在人身上。这次事件里,基金会要跨周末动员多人全职推进,靠的不是算力,是那几个人的时间。

判断力的责任。 PHP 基金会的三条边界是一份很干净的证词:不能决定正确的修复方案,不能权衡兼容性,不能审查补丁。每一个判定与补丁都由人完成——这是官方的自述,不是外部的批评。它比任何一篇评论都更准确地划出了当前的分界线。

开源的经济学。 钱从哪来、谁付、谁搭便车,这三个问题的答案在 2026 年与 2016 年没有本质区别。变化只在上游:AI 让漏洞变多,维护者负担因此上升,而付费关系没有变。这是这本书里最不性感、也最难改的一段。

把这两列放在一起,可以得到一个更精确的说法。我的判断是:AI 没有改变安全工作的性质,它改变了安全工作的价格表。价格表变了,所有按旧价格设计的流程、制度、预算和人手配置都会失准;而性质没变,意味着那些真正稀缺的东西依然稀缺,并且因为需求上升而更稀缺。

这也是为什么这本书的结论不能是“AI 改变了一切”。这句话听起来有力,实际上什么也没说:如果一切都变了,就没有参照物,也没法判断下一步该做什么。分清楚变了与没变这两列,才知道力气该往哪放。

这本书从三兆五字节的字段名开始。写到这里,能加的东西已经加完了:再往上加一层框架,只会得到一个更好看的说法,不会得到一个新的判断。剩下的那件事不是结论,是一个可以带走的问题——一个洞能教会一个行业什么,以及它在教完之后,会不会记住。

一句话收束:这本书能回答“发生了什么”,不能回答“该怎么办”;而后者从来不是靠更强的机器解决的,它靠的是愿意为判断签字的人。这句话要用一个洞来讲,是结语的事。


结语 一个洞的启示

2024 年,有人在一份审计报告相关的修复工作里,看见了一行可疑的代码顺序。

2026 年 10 月 21 日,全世界的运维在同一个下午,把同一套补丁推上去了几百万台机器。

中间隔着的,不是两年时间,是一次「证明」。

这本书从头到尾只想说清这一件事:当证明变便宜,整个系统的压力会集中流向「决定怎么修」。这句话听起来像是管理学,其实是一个关于稀缺性的描述。任何一个系统,压力永远堆在最短的那块板上;AI 没有把所有的板变长,它只把其中一块削得很薄。

现在,我想把这个结论放回三个具体的人身上,因为结构分析最容易犯的错误,就是把结论说得太漂亮,以至于没有人能在里面找到自己。

第一个场景:运维

十月二十一日下午,一个运维工程师打开工单系统,看到一条「PHP 高危漏洞,CVSS 9.2」的通知。他需要回答四个问题:我们在哪些主机上跑 PHP?分别是哪个版本?哪些对外暴露?先修哪一批?

这四个问题,没有一个能在一小时内回答完。资产清单按业务系统记,不按运行时分支记;容器镜像的标签不会告诉你里面的 PHP 是什么版本;测试环境里跑着三年前的东西,没人记得它存在。

他知道补丁已经发布,也知道「only patching fixes these vulnerabilities」这句话的分量。他知道临时缓解措施不可靠,因为没有 workaround。他唯一能做的,是排队:先修对外的,先修有数据的,先修能横向移动的。

这就是三成本框架落到个人身上的样子。发现成本塌陷,对他的意义是「你要处理的事情变多了」;证明成本塌陷,对他的意义是「这些事都变成了真的」;修复成本没变,对他的意义是「你的时间没有变多」。

三件事同时发生,结论只有一个:他会累,而且这种累不是暂时的。

任何一本讨论 AI 与安全的书,如果读完之后运维的工作变多了、判断变少了,那这本书是失败的。我写这些章节时一直提醒自己这件事。

第二个场景:维护者

同一时间,一个开源项目的维护者在处理另一个问题:如何在不破坏兼容性的前提下,改掉一个几乎每个请求都会经过的代码路径。

他手上有三个方案,每个方案都有代价。

改数据结构的顺序,最直接,但会破坏依赖现行为的行为与扩展——那些扩展的作者不会在场,他们的用户会在半年后报 bug。

加一层检查,最安全,但会把一个「内存耗尽」的问题变成一个「服务不可用」的问题——拒绝服务也是漏洞,只是换了一个 CVSS 向量。

改错误处理的语义,最彻底,但要求大量第三方扩展跟着改——这在一个松散的开源生态里,等于要求几百个互不认识的团队同时排期。

没有一个方案的代价落在做决定的人身上。这就是他为什么拖延了几个月。

AI 在这件事上帮了他很多:帮他验证报告、帮他归组、帮他跑回归。但没有任何一个工具能替他签字。因为签字的含义不是「我知道这个补丁能过测试」,而是「我愿意为这个取舍负责」。

责任不能被自动化,因为责任的定义就是「有人承担后果」。

这也是我在第 11 章写「瓶颈在人身上」时最想留下的一句话:不是人比机器聪明,是人比机器更能被追责。

第三个场景:研究者

还有一个场景,属于那些用模型做安全研究的人。

他们现在有一个很强的新工具。一个研究员一周能走完过去一个团队几个月走完的路,跨版本适配从数周压到数小时。这种能力让人兴奋,也让人不安,因为同一个能力,攻防两边都能用。

我想对这个场景说的是:请把「证明」当作你最重要的产出,而不是「发现」。

发现一个可疑之处,在这个时代会越来越便宜,便宜到不值钱。真正稀缺的是把它变成一条可复现、可验证、可被维护者信任的结论。这个过程需要你理解系统、构造环境、控制变量、诚实报告失败——这些事模型能加速,但不能替代。

还有一件事:披露的分寸。这一周里,一段误发的 PoC 视频,让所有防守方的时间预算少了一天。这不是道德问题,是工程问题:在发现速度提升十倍的时代,披露纪律的价值提升得比十倍更多。

回到那个字段名

这本书的第一章从一条论坛帖开始,第二章从一个进程的生命周期开始,第三章从两行 C 代码的顺序开始。

所有这些细节,最后都指向同一个问题:当我们把一个系统拆到足够细,会发现它的问题几乎从不在「有人做错了什么」,而在「几个正确的决定放在一起,产生了一个谁都没预料到的组合」。

PHP 先增加计数器再分配内存,是为了让插入操作更快;用 longjmp 跳出执行栈,是为了让错误处理更简单;复用 worker 进程,是为了让每个请求更便宜。每一个决定都是对的。它们组合在一起,产生了两个 CVSS 9.2。

AI 擅长的,恰恰是在这种「每个局部都对、组合起来错」的地方发现问题。因为它不按人的方式阅读代码——人读代码时会顺着作者的意图走,模型不会。它把代码当成一个巨大的、没有意图的文本,于是那些「作者认为不可能发生」的组合,对它来说只是另一种可能。

这是 AI 在安全领域真正的优势,也是它真正的危险:它不理解你的意图,所以它也不受你的盲区限制。

最后一句

这本书能回答「发生了什么」,不能回答「该怎么办」。

我能给的只有三个判断,供你带走:

第一,不要再把「漏洞被发现得更多」当成好消息或坏消息,把它当成一个成本信号。真正要问的是:我们的修复能力,跟上发现速度了吗?

第二,不要指望 AI 替你签字。它可以帮你读代码、写利用、验证补丁、设计检测,但它不能替你承担取舍。任何一个把判断权交出去的组织,会在下一次事件里发现自己没有能力做决定。

第三,也是最不讨喜的一条:如果你所在的系统里有「一半资产跑在停止支持的分支上」这种情况,那么这本书里所有的技术分析,对你都不适用——因为你的问题不在漏洞,在债务。

一个洞能教给人的东西有限。但如果这个洞恰好出现在全世界最普及的语言里,恰好被机器证明、被人类修补、被一次泄露打乱节奏,那么它教的东西,就不只是一个洞的事了。

雨轩于听雨轩 🌧️🏠