漏洞
PHP 两个未授权 RCE:一次 longjmp 与一份残留状态
你的 PHP 站点即使要求登录,也可能被一个不带凭据的请求打穿。漏洞不在你的应用代码里,在 PHP 处理请求的机制里:内存耗尽时的退出方式会留下半初始化的请求数据,跨请求的旧状态又会指向已经释放的内存。两个洞都发生在应用代码开始执行之前,Apache mod_php 已有利用演示。

文章导航
2026 年 10 月 21 日,PHP 在四个分支同时发安全版本,修两个远程代码执行漏洞。这两个洞有个共同点,决定了它们比一般的 PHP 提权或绕过更值得紧张:攻击面在应用代码之前。你写的登录校验、权限检查、中间件,全都还没轮到执行,PHP 已经在处理请求的机制里把事办坏了。
官方对 mod_php 的措辞是最高风险,且已有演示利用。以下原理全部来自 php-src 8.5.11 与 8.5.12 的实际源码比对,两个源码包的 SHA256 与 php.net 公布值一致,不是二手转述。
1 攻击发生在你的代码之前
先确认你跑的是哪种 PHP 接入方式,也就是 SAPI。Apache mod_php 优先,官方给了演示利用。PHP-FPM、php-cgi、FrankenPHP、LiteSpeed 也在受影响范围内,没用 mod_php 不代表可以跳过。
为什么这件事和"应用需要登录"没有关系:请求进 PHP 之后,先后顺序是 PHP 先建立请求上下文、解析输入、初始化各扩展状态,然后才把控制权交给你的脚本。两个漏洞都在这段前置流程里触发。你的 session 校验、JWT 验证、路由守卫,一个都还没跑。
NVD 截至抓取时还没有这两个 CVE 的评分,所以别等分数再排期。已有的判断依据足够:不需要认证、可以在应用代码之前触发、mod_php 已有利用演示。这些不等于已经确认在野攻击,也不等于你的服务器已经失陷。
2 一次 longjmp,留下一份半初始化的请求
CVE-2026-109618 对应 GHSA-w8hw-w9fx-j79w,官方标题是 RCE via OOM corruption。要理解它为什么能变成 RCE,得先看 8.5.11 里内存耗尽时到底做了什么。这段是 8.5.11 的 Zend/zend_alloc.c:
static ZEND_COLD ZEND_NORETURN void zend_mm_safe_error(zend_mm_heap *heap,
const char *format, size_t limit, size_t size)
{
heap->overflow = 1;
zend_try {
zend_error_noreturn(E_ERROR, format, limit, size);
} zend_catch {
} zend_end_try();
heap->overflow = 0;
zend_bailout();
exit(1);
}
关键在 zend_bailout()。它不是普通函数返回,是 longjmp。这一行决定了后面所有事:
第一,longjmp 不做 C 栈展开。它直接跳回 zend_try 设下的 setjmp 落点。中间被跳过的每一层 C 栈帧原封不动留在那里,栈上的局部变量、临时分配的 C 结构、正在修改中的容器,没人清理,也没人通知。这不是内存泄漏那种温和问题,是"状态改到一半就撒手"。
第二,触发时刻恰好是最坏的时机。OOM 发生在内存分配失败时,而内存分配贯穿整个请求处理过程。此刻 PHP 可能在解析 multipart 请求体、在往哈希表里插入字段、在为一个 zval 写完类型标记但还没写值。这些操作都是多步的,少一步就是残品。
第三,跳转后的落点要继续用这些残品。控制流进入请求 shutdown 阶段。shutdown 的职责是清理,它按一个前提工作:这些数据结构都是完整的。于是它开始遍历那些半成品,读到未初始化的 zval、指向已释放内存的指针、长度字段和实际内容对不上的字符串。
官方 advisory 对这一步的表述是:PHP 用 longjmp 处理 OOM 来退出当前上下文并中止请求处理;如果内存分配在内部请求数据结构只完成部分初始化时失败,随后的清理代码就会看到无效数据。
2.1 从"读到无效数据"到 RCE 的那一步
读到无效数据本身只是未定义行为,为什么能变成代码执行?这里官方公告没有展开,以下是本文的推导,标注清楚免得混淆。
PHP 内部用 zval 表示值,zval 里有一个类型标记和一个联合体,联合体按类型解释,可能是指针。当 shutdown 阶段把一个半初始化的 zval 当成合法值读取时,PHP 相信类型标记,并按这个标记去解释联合体。如果类型标记说"这是个字符串",PHP 就拿联合体里的指针当 char*,去读它的长度和内容。
关键在于:那块内存的内容,攻击者能影响。控制请求体大小、表单字段数、multipart part 数、header 数量,就是在控制分配节奏和内存布局。OOM 把一个正在构建的结构打断在半路,随后这块内存里放什么、被谁复用,攻击者有一份话语权。于是 PHP 拿着一个自己以为是 zval 的东西,实际读的是攻击者摆好的字节。指针被当合法指针解引用,长度被当合法长度去遍历,类型标记被信任。这条链走到头就是任意内存读写,再往前走就是控制流劫持。
所以这个漏洞的性质不是"内存不够用导致报错",是"内存不够用时的退出方式破坏了请求状态的一致性,而后续代码信任这份一致性"。
3 上个请求的内存没了,状态还在
CVE-2026-109619 对应 GHSA-6wmf-4rv5-p3rp,官方标题是 RCE via stale global state。这个洞的机制在补丁里暴露得很清楚。8.5.12 的 ext/session/session.c 新增了这段注释和函数:
/* The upload progress handler (php_session_rfc1867_update()) decodes and
* re-encodes the session during request startup, before RINIT, when no user
* code may run. Objects are then kept as __PHP_Incomplete_Class: no
* autoloading, no unserialize_callback_func, no __wakeup() or __unserialize(),
* and they are written back unchanged. */
static void php_session_unserialize_init(php_unserialize_data_t *var_hash)
{
PHP_VAR_UNSERIALIZE_INIT(*var_hash);
if (PS(in_rfc1867_update)) {
php_var_unserialize_set_incomplete_classes_only(*var_hash, true);
}
}
这段代码说明了一个具体的时序问题:session 的 upload progress 处理器在 RINIT 之前就要把 session 解码再编码。RINIT 之前不能跑用户代码,所以没有 autoloader、没有 unserialize_callback_func、没有 __wakeup()、没有 __unserialize()。而 session 里存的可能是对象。修复的做法是让这些对象保持为 __PHP_Incomplete_Class 原样写回,不去惊动它们。
官方对这个 CVE 的总描述更宽:PHP 有一批全局状态由 worker 保留,包括 error、session、object 和扩展状态。正常情况下请求结束会重置,但如果出现致命错误、执行超时,或者状态在重置之前就被使用,下一个请求就会用到上一次留下的东西。
3.1 为什么残留状态能变成 RCE
核心在于状态和它依赖的内存有各自的生存期。一个请求申请内存来存放状态,请求结束时那块内存被释放,但指向它的全局字段还在。下一个请求进来,初始化是分步进行的,某条代码路径可能在重置这个字段之前就读了它。
读到的是已释放的内存。如果这块内存已经被下一次分配复用,那"旧状态"实际指向的是新的、可能由攻击者影响的数据。PHP 按旧状态的类型标记解释这份新数据,又回到和上一个漏洞同样的处境:类型标记被信任,联合体被按错误的类型解释,指针被解引用。同一个失效模式,不同的入口。
补丁修的是两条已确认的路径:session upload progress 和 fatal backtrace(8.5.12 新增了 fatal_error_backtraces 开关,Zend/zend.c 里默认值 1)。官方明确说这两条之外还有别的变体没堵住,所以缓解措施不等于安全。
4 修复做了什么:把"打断"从分配路径上移走
8.5.12 的修法值得细看,因为它没有在出问题的地方加检查,而是换掉了整个退出机制。这套设计本身说明了作者认为问题出在哪一层。三段代码对应三级设计:
// 软 OOM:只置标志,不跳转(Zend/zend_alloc.c)
static ZEND_COLD void zend_mm_delayed_verror(const char *format, va_list args)
{
if (ZEND_HAS_OOM()) return;
/* 记录 message、filename、lineno */
EG(delayed_oom).message = zend_string_init(buf, strlen(buf), /* persistent */ true);
GC_MAKE_PERSISTENT_LOCAL(EG(delayed_oom).message);
EG(delayed_oom).filename = script_filename;
EG(delayed_oom).lineno = script_lineno;
EG(delayed_oom).state = ZEND_OOM_STATE_PENDING;
zend_atomic_bool_store_ex(&EG(vm_interrupt), true);
}
// 在 VM 的安全检查点才处理(Zend/zend_alloc.c)
ZEND_API void ZEND_FASTCALL zend_handle_delayed_oom(void)
{
zend_oom_info *info = &EG(delayed_oom);
zend_execute_data *execute_data = EG(current_execute_data);
if (info->state != ZEND_OOM_STATE_PENDING) return;
info->state = ZEND_OOM_STATE_HANDLING;
AG(mm_heap)->overflow = 1;
/* 转移 message、展开栈、抛 E_ERROR */
if (execute_data) {
EG(exception) = NULL;
zend_throw_unwind_exit();
}
zend_try {
zend_error_zstr_at(E_ERROR, info->filename, info->lineno, info->message);
} zend_catch {
} zend_end_try();
/* 可能从 VM 中断处理器进入 */
}
// 真撑不住就立刻死,不执行清理(Zend/zend_alloc.c)
if (!heap->fallible && add_size > heap->hard_limit - heap->real_size) {
zend_mm_hard_limit_error(heap, add_size ZEND_FILE_LINE_RELAY_CC);
}
逻辑是连贯的。分配失败时不再立刻改变控制流,只把失败记在 EG(delayed_oom) 里并置一个 VM 中断标志,当前这次多步操作有机会走完,不会在半路被拽走。统一在 VM 的固定检查点处理,调用点写在 Zend/zend_execute.c、Zend/zend_execute_API.c、Zend/zend.c 里,都是执行流程中已知一致的位置,处理时走 zend_throw_unwind_exit() 加正常 E_ERROR 路径,栈会被正确展开。硬上限兜底时 zend_mm_hard_limit_error() 内部直接 _exit(137),注意这里连 shutdown 都不执行,因为一旦执行清理就又要面对"数据可能不一致"的老问题。宁可整个进程死掉,也不读半成品。
配套还新增了一组 fallible 分配接口(femalloc、fecalloc、ferealloc),分配失败返回 NULL 交给调用方判断,而不是跳转。以及两个新 INI:memory_hard_limit_percent(默认未设,需显式指定,必须大于等于 100 或为 -1)和 max_memory_limit(默认 -1,仅系统级可改)。
读这套修复能直接读出作者对根因的判断:问题不在某个分配点少了检查,而在"分配失败"和"控制流被打断"被绑成了同一件事。解开这个绑定,问题就消失了。这个判断比补丁本身更值得记住。
5 为什么 mod_php 最危险
官方说 mod_php 风险最高、已有演示利用,但没在公告里展开原因。以下是本文的推导,标出来供你判断。
mod_php 把 PHP 解释器嵌进 Apache 的工作进程,请求生命周期跟 Apache 的进程模型绑在一起。相比之下 PHP-FPM 是独立的 worker 池,边界清楚。在 mod_php 里,PHP 的请求数据结构和 Apache 的请求结构在同一个进程内交错分配,OOM 打断时被留在半初始化状态的,除了 PHP 的 EG() 全局,还可能牵涉 Apache 侧的结构。而且 mod_php 没有 FPM 那种明确的 worker 隔离来限制状态残留的影响范围。
官方把演示利用放在 mod_php 上,与这个判断一致。
6 WordPress 用户要额外注意什么
很多人第一反应是问:这漏洞要不要配合 WordPress 才能打成?答案是不需要配合,但 WordPress 会让它更容易踩中。这两件事得分开说。
先说不需要配合的部分。两个漏洞的触发点都在 PHP 处理请求的前置流程里,也就是建立请求上下文、解析输入、初始化扩展状态这段。WordPress 的代码要等这段跑完才开始执行。所以不管 WordPress 有没有登录校验、有没有 nonce、有没有权限判断,攻击都发生在它们前面。这一点和 WordPress 自己那些需要认证的漏洞性质完全不同。
再说 WordPress 确实有影响的部分,这是本文实测 WordPress 6.9 源码发现的:wp-includes/default-constants.php 里有一段逻辑,检测到 memory_limit 可被修改时,会把主站内存上限设成 40M、多站点设成 64M,然后调用 ini_set('memory_limit', WP_MEMORY_LIMIT)。同一文件还给后台操作定义了 WP_MAX_MEMORY_LIMIT,默认 256M。
这条 ini_set 的意义在于:CVE-2026-109618 的触发条件是内存分配失败,而 WordPress 主动把一个有限的、偏小的上限装回运行时。上限越紧,分配失败越容易发生,那个"半初始化就被打断"的窗口也就越常出现。所以 WordPress 不是漏洞的前提,是把风险放大的因素。
这也正好解释了官方缓解措施为什么特意要求用 php_admin_value 来固定 memory_limit=-1,而不是用 ini_set。因为 php_admin_value 让这个设置变成不可修改,WordPress 里那句判断 wp_is_ini_value_changeable('memory_limit') 会返回假,于是它把 WP_MEMORY_LIMIT 定义成当前值,不会再把 40M 装回去。如果只是在 php.ini 里设了 memory_limit=-1 却没锁住,WordPress 照样会把它降回 40M,缓解等于白做。这是实操里最容易漏的一步。
至于 CVE-2026-109619 的 session 路径,WordPress 基本用不上。实测 6.9 源码,session_start 只出现在站点健康检查的说明文字里,WordPress 自己的登录状态走 cookie 和数据库,不用 PHP 原生 session。所以 session.upload_progress.enabled=0 这条缓解对 WordPress 收益不大。但 fatal_error_backtraces 那条路径仍然适用,因为 WordPress 插件和主题里触发致命错误的场景相当常见,尤其是上传、导入、清理过程。
所以对 WordPress 站点的实际结论是:升级 PHP 是第一优先,不要指望用 WordPress 层面的东西挡住它。临时缓解里,锁死 memory_limit 并防止被降回 40M 是真正有效的那一步,session 开关可以顺手加但别指望它。
7 方法论:怎么自己找出同类漏洞
这两个洞不是模糊测试炸出来的,是同一种审计思路的产物。下面五个方法可以直接拿去用,比记住两个 CVE 编号更有价值。
7.1 方法一:审计"不清理就退出"的路径
要回答的问题是:哪些代码路径会在数据结构不一致时离开当前上下文,而后续代码仍然假设它一致?
具体做法是列出进程内所有非局部跳转原语,C 里是 longjmp 和 setjmp,PHP 内部是 zend_bailout()。然后对每个原语找出全部触发点,搜 zend_bailout、zend_error_noreturn(E_ERROR)、exit()。接着检查这些触发点是否位于多步修改的中途,典型模式是先分配再赋值后注册,或者哈希表 rehash 过程中,或者对象已建好但还没挂到容器上。最后检查跳转落点做什么,如果落点是清理逻辑,而清理逻辑遍历那些可能半初始化的结构,这就是候选。
关键判据只有一个:这次跳转会不会跳过 C 栈上的清理。如果会,那些栈帧拥有的资源就不只是内存,还有锁、引用计数、注册表条目,全都处于没人管的状态。
这次的具体发现路径就是这样:zend_mm_safe_error() 里的 zend_bailout() 出现在分配失败的处理路径上,也就是"内存已经紧张的时刻",而那一刻恰恰最可能是某个多步构造的中途。
7.2 方法二:审计状态生存期与内存生存期的分离
要回答的问题是:哪些字段和它指向的内存有不一样的生存期?
先找出所有跨请求保留的 worker 级状态结构,PHP 里是 EG()、PG()、PS()、CG()。对每个字段问两个问题:这块内存谁分配、谁释放,这个字段谁重置。如果释放点和重置点不在同一处,或者释放发生在重置之前哪怕只有一个分支,就是候选。然后检查请求初始化流程里有没有"先用后重置"的路径,特别关注 RINIT 之前执行的代码,那时还没走到正常的重置流程。
关键判据是:有没有代码在 reset 之前读这个字段。只要有,就要追问那块内存现在归谁。
这次的具体发现路径是 php_session_rfc1867_update() 在 RINIT 之前解码 session。RINIT 之前不能跑用户代码,但 session 里存的是对象,这些对象在反序列化时处于半合法状态,它们依赖的类定义和 autoloader 都还没准备好。
7.3 方法三:用补丁反推,这是最可靠的验证手段
怀疑某个组件有这类问题但不确定时,拿修复前后两个官方版本源码做全量 diff。先核对哈希,php.net 的 releases/index.php?json&version=X 会给官方 SHA256,对不上就别往下做。然后按目录归类差异,这次 287 个文件变化,安全相关的集中在 Zend/zend_alloc.c、ext/session/session.c、main/main.c。
接着看新增的函数名和 INI 选项名。新名字会暴露作者意图:delayed_oom、hard_limit、in_rfc1867_update、incomplete_classes_only,每一个都指向一个具体的不一致窗口。然后看改动的测试文件,Zend/tests/gh11189.phpt 和 get-return-after-bailout.phpt 这类被改的测试,复现的就是修复前的行为。最后对比新旧两条路径的差异,差异本身就是"原来错在哪"的说明书。
7.4 方法四:证明能走到不一致状态,不必写完整 exploit
要确认这类漏洞真实存在,不需要构造出完整利用链,只需要证明能到达不一致状态。
做法是让分配在指定位置失败,PHP 里可以设极小的 memory_limit,或者用 fallible 接口。然后让失败恰好发生在多步构造中途,控制请求体大小、表单字段数、multipart part 数、header 数量,这些都是攻击者能精确控制并且会影响分配节奏的量。接着观察清理阶段有没有读到异常值,开 USE_ZEND_ALLOC=0 配合 Valgrind,或者用 ASAN 构建,能直接看到 read of uninitialized value 或 use after free。
如果清理阶段读到的 zval 的 type 字段是攻击者可影响的值,那就找到了通往 RCE 的路。
7.5 方法五:盯住"安全点"这个概念
修复引入了安全点,OOM 只在 VM 已知一致时才处理。这提示一类通用审计点:任何延迟处理机制,都要问处理时机的状态是否保证一致。
信号处理是同一个问题,信号在任意指令边界到达,handler 动到的数据结构安全吗。请求取消是同一个问题,操作被取消时正在进行的中间状态谁清理。执行超时也是同一个问题,打断位置任意,和 OOM 属于同一类,官方这次就把超时和致命错误一起列进了 CVE-2026-109619 的触发条件。
8 现在该做什么
按你的分支装当天发布的版本:8.5.12、8.4.27、8.3.36、8.2.35。还在 7.4、8.0、8.1 的话,官方给了最小源码 backport,也可以看发行版或 LTS 厂商有没有回补。注意 PHP 8.1 及更早已经不由 PHP 项目维护,这次源码修补不代表旧分支恢复维护。
用 rpms.remirepo.net 的部署不要空等,这个志愿者维护的仓库当天不会更新,官方建议改用发行版包或自行编译。
无法立刻升级时,按两个 CVE 各自的条件做临时缓解。CVE-2026-109618 的思路是别让 PHP 走进那条分配失败路径:通过 php_admin_value 固定 memory_limit=-1,确认 pool、目录和应用没有把它设回有限值,然后用容器或 cgroup 在外层约束内存,最好只终止受影响的 worker。不要用 RLIMIT_AS 或 RLIMIT_DATA 压低进程上限,它们会让 PHP 分配失败,触发的正是存在漏洞的那条路。同时在 Web 服务器或代理层限制解码后的请求大小、字段数、multipart part 数、header 数和嵌套深度,没有 Content-Length 的请求也要受限。
CVE-2026-109619 的思路是关掉已确认的两条入口:session.upload_progress.enabled=0 和 PHP 8.5 上的 fatal_error_backtraces=0,两者都必须在请求解析前生效,所以要用 php.ini、FPM pool 或服务器配置,ini_set() 和 FPM 的 .user.ini 来不及。设完必须重启所有 worker。这两个开关只堵已确认的路径,不覆盖所有变体。
官方明确说只有升级是完整方案。道理就在前面那套修复里:要修的是运行时处理请求状态的规则,不是一个入口的开关。关掉一个已知入口,剩下的变体还在。