2026年10月20日,PHP 官方罕见地提前发布了一则高危安全预警;次日(10月21日),代号为 PHP2Shell 的两个史诗级 PHP 核心漏洞——CVE-2026-109618 与 CVE-2026-109619 正式公开并释出紧急补丁(CVSS v4.0 评分均高达 9.2)。
与以往某个框架或 CMS 爆出的漏洞不同,这次的问题直接出在 PHP 引擎(Zend Engine / SAPI 生命周期)底层。更致命的是,攻击发生在 PHP 解析 HTTP 请求的初始化阶段(RINIT),早于任何业务代码执行。这意味着你在业务层写的鉴权中间件、路由拦截甚至是框架自带的安全防护,在它面前全都形同虚设,哪怕服务器上只跑了一个 echo "Hello World"; 也能被无认证远程代码执行(Unauthenticated RCE)或读取 Worker 进程内存。
在研读官方安全通告与技术细节时,有一个非常值得玩味的细节:官方及各大安全团队在受影响列表中明确点名了新兴的现代应用服务器 FrankenPHP,而国内开发者广泛使用的常驻内存框架 Swoole 却在这场风暴中几乎“免疫”了核心攻击链路。
这篇博文我们就从底层原理出发,拆解 PHP2Shell 的来龙去脉,聊聊 FrankenPHP 与 Swoole 架构差异带来的安全分野,并给出生产环境的应急修复方案。
一、 漏洞复盘:什么是 PHP2Shell?
这次被统称为 PHP2Shell 的漏洞由安全团队 calif.io 发现并上报,影响范围极其广泛(从古老的 PHP 5.4 一路横跨到最新的 PHP 8.5)。它实际上是由两个环环相扣、均涉及请求生命周期与内存状态管理的底层缺陷组成的:
| 漏洞编号 | 漏洞定性 | CVSS v4.0 | 核心触发机制 | 危害影响 |
|---|---|---|---|---|
| CVE-2026-109618 | OOM 内存耗尽导致初始化中断与状态损坏 | 9.2 (Critical) | 发送超长表单字段名(rawkey)或畸形 multipart/form-data 触发 memory_limit OOM,导致 zend_bailout 跳出清理流程。 | 绕过清理逻辑留存野指针、远程代码执行 (RCE) |
| CVE-2026-109619 | 跨请求边界的残留全局状态复用 (Stale State UAF) | 9.2 (Critical) | Worker 进程处理后续请求时,复用了上一次异常请求未重置的内部全局状态(如 session.upload_progress)。 | 跨请求内存泄漏(窃取密钥/凭据)、控制 RIP 寄存器实现 RCE |
1. CVE-2026-109618:不讲武德的 OOM 跳出(OOM Corruption)
熟悉 PHP 底层的朋友都知道,PHP 有一套经典的“Shared-Nothing(无共享)”架构假设:每个请求在开始时初始化(RINIT),在结束时清理销毁(RSHUTDOWN)。
但是,当 PHP 在请求初始化阶段(比如解析 POST 表单或 multipart/form-data 数据)遇到内存分配失败(Out of Memory,触发 memory_limit)时,引擎会调用 zend_bailout()——底层本质上是一个 C 语言的 longjmp 操作,直接粗暴地跳出当前执行流,中止请求。
问题就出在这里:当攻击者精心构造一个超长的表单键名(Field Name,通常达到 MB 级别)在请求启动阶段精准引爆 OOM 时,longjmp 直接跳过了本该执行的指针置空与状态复位代码。 随后运行的清理例程(Cleanup Routine)就会完全信任那些只初始化了一半、甚至指向已释放内存的损坏数据结构(例如反序列化过程中的 Bookkeeping 结构体)。
2. CVE-2026-109619:幽灵般跨请求存活的残留状态(Stale Global State)
如果说 109618 是“把房间弄乱且不打扫就退房”,那么 CVE-2026-109619 就是“下一位住客直接睡在了上一位住客留下的炸弹上”。
在生产环境中,无论是 PHP-FPM、Apache mod_php,还是 FrankenPHP,为了性能都不会每来一个请求就销毁操作系统进程,而是由常驻的 Worker 进程 循环接管下一个 HTTP 请求。
攻击者的标准利用链(以 session.upload_progress 为例)非常精妙:
- 投毒与制造悬垂指针:攻击者发起第一个携带
PHPSESSID和PHP_SESSION_UPLOAD_PROGRESS的multipartPOST 请求。PHP 会在任何业务脚本运行前,自动打开并反序列化 Session 文件来跟踪上传进度。此时攻击者配合 OOM 或特定错误中断初始化,使得底层内存被释放,但 PHP 内部指向该内存的全局变量指针没有被重置。 - 跨请求劫持(UAF):同一个 Worker 进程紧接着处理第二个请求时,再次触发上传进度解析,攻击者控制的字节流会直接占领刚才被释放的内存块(Use-After-Free),在 Session 回写序列化时伪造
zval对象结构,直接劫持指令寄存器(RIP),拿到服务器的 Shell 权限。
此外,在 PHP 8.5 中新增的 fatal_error_backtraces 特性以及部分 OPcache 共享内存状态,也存在完全相同的跨请求污染模式。
二、 为什么 FrankenPHP 会在这场风波中被重点点名?
在这次 PHP 官方及各大安全厂商(如 Huntress、watchTowr、Datadog)发布的紧急通告中,受影响运行时列表里赫然写着:
PHP-FPM,Apache mod_php,php-cgi,LiteSpeed, 以及 FrankenPHP。
很多刚把架构迁移到 FrankenPHP 的朋友可能会疑惑:FrankenPHP 不是基于现代化的 Go 语言(Caddy)开发的吗?怎么也会掉进传统 PHP 的坑里?
1. FrankenPHP 的本质是什么?
FrankenPHP 是近年来 PHP 社区最火热的现代应用服务器(由 Symfony 核心成员 Kévin Dunglas 主导开发,并已被官方纳管)。它的核心架构是:基于 Go 语言编写的知名 Web 服务器 Caddy,通过 CGO 技术将 PHP 解释器(基于 embed SAPI)直接静态编译或动态链接进同一个二进制文件中。
它之所以备受追捧(尤其是配合 Laravel Octane),是因为它不仅开箱即用地支持了 HTTP/3、自动 HTTPS、Early Hints,还提供了极其强大的 Worker 模式——让应用启动一次后常驻内存,直接省去了传统 FPM 每次请求都要重新加载框架文件的开销。
2. 成也萧何,败也萧何:为什么它逃不掉?
FrankenPHP 被点名,主要有两大核心原因:
- 完全复用了原生 PHP SAPI 的请求生命周期
尽管 FrankenPHP 的外层网络收发是由 Go 协程(Goroutine)处理的,但当请求穿透到 PHP 层时,无论是在普通模式还是 Worker 模式下,它依然高度依赖 PHP 原生的php_request_startup()(RINIT) 和php_request_shutdown()(RSHUTDOWN),以及原生的rfc1867(即处理multipart/form-data和文件上传进度的底层 C 代码)。因此,CVE-2026-109618 和 CVE-2026-109619 的触发路径在 FrankenPHP 中是 100% 畅通的。 - 独立打包分发带来的“升级盲区”
这是安全社区重点点名 FrankenPHP 的另一个工程原因。传统的 PHP-FPM 只需要通过系统的apt/yum升级系统里的 PHP 包即可修复;但 FrankenPHP 是把 PHP 解释器直接编译进它自己的二进制文件里的。
更要命的是,像 Laravel Octane 这样的组件在集成 FrankenPHP 时,会自动在项目根目录下静默下载一个独立的frankenphp二进制文件。即便运维人员把宿主机操作系统的 PHP 升到了最新版本,只要没手动删掉并更新项目里的frankenphp二进制文件(或者重新拉取 FrankenPHP 的 Docker 镜像),服务器依然在裸奔!
三、 架构分野:为什么常驻内存的 Swoole 不受影响?
既然同样是主打高性能、同样有所谓的“Worker 进程常驻内存”,为什么国内大厂和微服务体系里广泛使用的 Swoole(包括基于它的 Hyperf、Webman Swoole 驱动等)在这次核心漏洞的攻击链路面前却能独善其身?
虽然国外部分安全博客在罗列“更新 PHP 后需要重启哪些进程”时,顺手把 Swoole 和 RoadRunner 写进了重启清单里,但从漏洞触发的底层机理来看,标准 Swoole HTTP Server 架构天然免疫了 PHP2Shell 的核心攻击向量。原因在于两者底层架构的根本性差异:
[传统 FPM / FrankenPHP 架构]
客户端 HTTP 请求 ──> SAPI 层 (RINIT: 解析 POST/Multipart/Session Upload Progress) [💥 漏洞触发点] ──> 执行业务 PHP 代码 ──> RSHUTDOWN
[Swoole HTTP Server (CLI) 架构]
客户端 HTTP 请求 ──> Swoole C++/协程引擎解析 HTTP (填充 $request 对象) ──> 直接调用 onRequest 闭包 [✅ 绕过 RINIT/RFC1867]1. 运行在 php-cli 模式下,彻底绕过了传统 Web SAPI 的 RINIT
PHP2Shell 之所以可怕,是因为它在 Web SAPI 的请求初始化阶段(脚本执行前)就被触发了。
而 Swoole 是以 CLI 模式(Command Line Interface) 启动的。在 Swoole 启动的那一刻,PHP 引擎的 MINIT(模块初始化)和唯一的一次 RINIT 就已经执行完毕了。此后成千上万个并发 HTTP 请求进来时,根本不会再走 PHP 内核的 php_request_startup 和 php_request_shutdown 流程! 既然没有每次请求的 RINIT 周期,攻击者就无法利用初始化与清理阶段的不对称来制造悬垂指针。
2. 接管并重写了 HTTP 与 Multipart 解析器
CVE-2026-109618 和 109619 的核心突破口是 PHP 内核自带的 multipart/form-data 解析器(main/rfc1867.c)以及绑定在它上面的 session.upload_progress 机制。
而在 Swoole 的 Swoole\Http\Server 中:
- HTTP 协议的解析、Header 的拆解、POST Body 以及文件上传(Multipart)的解析,全部由 Swoole 自己用 C/C++ 编写的高性能异步解析器(基于 llhttp / multipart_parser)接管。
- 解析出的数据会被封装到局部的
$request->post和$request->files对象中传递进协程,完全不经过 PHP 内核的全局变量$_POST初始化逻辑,更不会去触发 PHP 内核鸡肋的session.upload_progress钩子。
3. 协程上下文隔离 vs 全局状态复用
在 Swoole 中,会话管理(Session)和上下文状态全部由框架层(如 Hyperf 的 Context)绑定在独立的协程 ID(Coroutine ID)下,请求结束即随协程栈销毁,不使用原生 ext/session 的阻塞式文件反序列化机制。因此,CVE-2026-109619 所依赖的“跨请求残留全局 Session 状态”在 Swoole 体系下根本不存在生存土壤。
⚠️ 补充说明(严谨起见):
说 Swoole 不受影响,指的是使用 Swoole 自身作为 HTTP Server 承载流量的架构。如果你的架构依然是前面顶着 Nginx + PHP-FPM 接收请求,仅仅在 FPM 脚本里把 Swoole 当作一个普通的 Client 扩展(例如Swoole\Client)去连后端服务,那么外层的 PHP-FPM 依然会中招。此外,如果你使用的是独立打包了 PHP 运行时的swoole-cli来跑传统脚本,也建议随官方同步升级底层版本。
四、 修复建议与立体化防范指南
目前互联网上(包括 watchTowr 等安全机构的蜜罐)已经观测到针对 PHP2Shell 的大规模自动化在野利用(Exploited in the wild)。任何位于公网的传统 PHP 服务都必须按最高优级别处理。
1. 治本之策:立即升级 PHP 核心版本
PHP 官方已于 2026 年 10 月 21 日发布了修复补丁(核心 Commit 31c0ea7d7ff:阻止 OOM 在非安全点直接通过 longjmp 跳出,并引入了新的硬内存限制机制 memory_hard_limit_percent)。请立即将生产环境升级至以下安全版本:
- PHP 8.5 分支:升级至
>= 8.5.12 - PHP 8.4 分支:升级至
>= 8.4.27 - PHP 8.3 分支:升级至
>= 8.3.36 - PHP 8.2 分支:升级至
>= 8.2.35 - EOL 老旧版本(PHP 7.4 / 8.0 / 8.1):官方已破例释出了最小化的源码向后移植补丁(Minimal source backports),使用 Ubuntu/Debian Ondřej Surý PPA (
packages.sury.org) 或 RHEL/Remi 源的团队请立即运行包管理器更新。
2. 针对不同运行时的特殊清理动作
只更新系统软件包往往不够,务必确认实际承载流量的进程已被替换:
- PHP-FPM / Apache:升级后务必执行
systemctl restart php8.x-fpm或apachectl graceful,并通过ps aux确认没有残留的老版本 Worker 进程。 FrankenPHP / Laravel Octane:
- 如果使用 Docker,立即重新拉取包含 PHP
8.3.36/8.4.27/8.5.12的最新 FrankenPHP 官方镜像并重新构建部署。 - 如果通过 Laravel Octane 本地二进制运行,必须先删除项目目录下的
frankenphp二进制文件,再重新启动 Octane 让其拉取最新修复版(因为 Octane 默认只要检测到版本>= 1.13.0就不会主动覆盖更新)。
- 如果使用 Docker,立即重新拉取包含 PHP
- RoadRunner:升级宿主机的 PHP 二进制后,执行重启以替换长生命周期的 Worker 进程池。
3. 临时缓解措施(当无法立即停机升级时)
如果你维护的是庞大的遗留系统,无法在几小时内完成升级,必须在 php.ini 与边界网关(Nginx / WAF)层立刻实施以下拦截策略:
(1)修改 php.ini 砍断主要利用链
; 强烈建议永久关闭!即使打完补丁,官方也建议如果不使用该特性请保持关闭
session.upload_progress.enabled = 0
; 针对 PHP 8.5 环境,临时关闭致命错误堆栈回溯保存,阻断 109619 的另一条路径
fatal_error_backtraces = 0(注:部分社区文章提到临时设置 memory_limit = -1 来避免 OOM 触发 109618,但在公网环境下这极易引发真实的物理内存耗尽导致宿主机 OOM Killer 杀进程,不建议在生产环境盲目开启,优先在网关层拦截。)
(2)在 Nginx / WAF 边界拦截畸形载荷
CVE-2026-109618 要求攻击者发送超过 1MB 的超长表单字段名(Key)来撑爆内存,而正常业务绝不可能出现超过 1000 字节的表单字段名。
- 切勿仅依赖
Content-Length请求头做限制:攻击者可以通过Transfer-Encoding: chunked(分块传输)或 HTTP/2 轻松绕过 Header 长度检查。 WAF 正则拦截规则:
- 针对
application/x-www-form-urlencoded请求体,匹配并阻断超长键名:(^|&)[^&=]{1000,}。 - 针对
multipart/form-data请求体,在 Part Header 上匹配并阻断超长字段名或文件名:(?i)\bname\s*=[^\r\n]{1000,}。
- 针对
- 速率限制(Rate Limit):由于攻击者在利用漏洞绕过 ASLR(地址空间配置随机加载)时,需要在约 15 分钟内从单个 IP 发送约 590 次超大 POST 请求进行暴力探测,在 Nginx 层限制单 IP 每分钟大型 POST 请求不超过 10 次即可有效阻断爆破阶段。
五、 写在最后:对这次 PHP2Shell 事件的一点思考
回顾这次惊心动魄的 PHP2Shell 事件,站在后端架构与工程演进的视角,有几点感触尤为深刻:
第一,“历史包袱”往往是底层安全最大的阿喀琉斯之踵。
仔细看这次被利用的 session.upload_progress(文件上传进度追踪),它是 PHP 5.4 时代(十多年前)为了配合早期前端展示上传进度条而塞进内核的一个特性。在如今前端分片上传、对象存储直传(OSS/S3 Presigned URL)早已普及的 2026 年,几乎没有任何现代项目还会去用 Session 轮询上传进度,但它却依然在 php.ini 中默认开启。一个几乎无人使用、缺乏充分模糊测试(Fuzzing)的历史遗留特性,最终成为了横跨十余年版本的核弹级 RCE 跳板。这也给所有开源基础软件提了个醒:勇敢地在主版本迭代中默认禁用甚至剥离过时的边缘特性,比盲目保持向后兼容更重要。
第二,“伪常驻内存”与“真异步常驻内存”的安全边界值得重新审视。
过去几年,为了弥补传统 PHP-FPM 的性能短板,社区涌现出了类似 FrankenPHP 这样在 C/SAPI 外壳上套一层 Go 循环、试图把传统同步架构平滑变成 Worker 模式的方案。这种方案虽然迁移成本极低,但它也让原本为“用完即毁”设计的 C 语言全局变量机制暴露在了长生命周期复用之下。一旦遇到像 zend_bailout (longjmp) 这种跨函数栈的暴力跳出,极易发生状态污染跨请求逃逸。相比之下,无论是像 Swoole 这样彻底抛弃传统 Web SAPI、重构协程上下文的纯正 CLI 常驻方案,还是直接采用 Go 这类从语言设计之初就以并发安全、显式错误处理(无隐式 longjmp)和协程隔离为核心的编译型语言,在面对复杂高并发状态管理时,其底层的架构确定性无疑要干净得多。
第三,现代云原生环境下的“供应链可见性”亟待加强。
以前运维排查 PHP 版本,敲一行 php -v 就完事了;而现在,你的服务器里可能同时跑着宿主机的 PHP、Docker 容器里的 Alpine PHP、Laravel Octane 自动下载的 FrankenPHP 独立二进制文件,甚至是某个第三方监控面板自带的 php-cgi。在技术栈日益封装化的今天,搞清楚“代码到底跑在哪个二进制运行时上”,已经成了每个研发和架构师的必修课。
不管怎样,如果你手头还有维护中的传统 PHP 或 FrankenPHP 项目,今晚别犹豫,赶紧去检查版本号、升补丁、重启 Worker 吧!
参考资料与延伸阅读: