【漏洞复现】PHP Core 远程代码执行漏洞(CVE-2026-109618/109619)
原创
博智非攻研究院
博智非攻研究院
博智非攻研究院
2026年10月22日 22:46
日本
去阅读
漏洞信息
| 项目 | 说明 |
|---|---|
| 漏洞编号 | CVE-2026-109618 (RCE via OOM corruption) CVE-2026-109619(RCE via stale global state) |
| 评分 | CVSS v4.0 9.2(Critical) CVSS:4.0/AV:N/AC:H/AT:P/PR:N/UI:N/VC:H/VI:H/VA:H/SC:N/SI:N/SA:N |
| 影响与修复 | 影响 PHP < 8.2.35、< 8.3.36、< 8.4.27、< 8.5.12(更早的 EOL 分支不再维护);修复版本 8.2.35 / 8.3.36 / 8.4.27 / 8.5.12 |
| 攻击面 | session.upload_progress 的 multipart 解析,发生在 RINIT 之前;无需认证 |
| 已确认 SAPI | Apache mod\_php(完整 RCE);PHP-FPM(109619 的 session 生命周期已验证;109618 的启动期路径会被 FPM 退出子进程阻断) |
| 披露与修复 | 2026 年 10 月 21 日披露;修复提交 3c832b523e(Avoid state corruption via bailout) |
很多人对 session.upload_progress的印象还停留在上传进度条。它确实只是个小功能,但实现位置很特殊:multipart 解析发生在请求启动阶段,早于 RINIT,也早于应用代码。2026 年 10 月披露的两个漏洞,都从这个时间差里长出来。
一个漏洞是 OOM 之后的请求内存损坏,另一个是 worker 把上一条请求的指针带进了下一条请求。两个编号覆盖的弱点类型包括 CWE-416、CWE-755、CWE-824、CWE-1325 与 CWE-1419。单独看,它们都不像传统意义上的“输入过滤不严”;真正的问题在于,PHP 在请求生命周期的边界上没有把状态收干净。官方给两者的评分都是 CVSS v4.0 9.2,修复版本为 8.2.35、8.3.36、8.4.27、8.5.12。
一、RINIT 之前的空档
PHP 收到请求后,先调用 php_request_startup()。在这个函数里,php_hash_environment()负责解析 multipart,rfc1867 回调就在这一步触发;直到后面的 zend_activate_modules(),各个扩展的 RINIT 才执行。也就是说,当应用还没开始跑第一行代码时,session 扩展已经被 upload\_progress 拉起来干活了。
| 顺序 | 发生什么 | 为什么危险 |
|---|---|---|
| 1 | php_hash_environment() 解析 multipart,触发 php_session_rfc1867_callback() |
session 初始化在 RINIT 之前发生,应用代码和框架中间件都还没加载 |
| 2 | FILE_START 、FILE_DATA、FILE_END调用 php_session_rfc1867_update();MULTIPART_EVENT_END在 cleanup 开启时调用 php_session_rfc1867_cleanup() |
两条路径都会走到 php_session_initialize() |
| 3 | php_session_initialize() 调用 php_session_track_init() |
它会释放旧的 PS(http_session_vars),再重建 _SESSION |
| 4 | zend_activate_modules() 执行 RINIT,随后脚本开始执行 |
业务逻辑此时才介入,但早期状态已经建立 |
php_session_track_init()的逻辑很直接:如果 PS(http_session_vars)不是 UNDEF,就把它 zval_ptr_dtor掉,然后重新建一个 _SESSION。正常情况下这没有问题;但如果这个指针指向上一条请求已经释放的内存,这里就会踩到 UAF。
这就是两个 CVE 共同的起点。session 的全局状态在 RINIT 之前就已经可访问,而请求边界上的清理并不总是发生。
二、CVE-2026-109618
CVE-2026-109618 的关键词是 OOM。PHP 在内存耗尽时用 longjmp()跳出当前上下文,直接结束请求。问题在于,请求内存在这个时刻可能只初始化了一半;如果失败发生在某个“已经发布、但还没完成”的对象上,PHP 没有把这次提前退出记全,后面的 request shutdown 就会继续读这块不可信的内存。官方说得很直白:失败的那次分配甚至不需要和坏内存直接相关。
攻击者要做的,是在 multipart 解析阶段制造一次可预测的 OOM。官方列了几种放大方式,它们共同的特点是:请求看起来还在 post_max_size的限制内,实际消耗的内存却远高于线上字节数。
| 策略 | 原理 | 结果 |
|---|---|---|
| 不声明可用的 Content-Length | 请求可能绕过最初的 post_max_size检查,继续向解析器提供解码后的 body 数据 |
解析器按实际数据量分配内存 |
| 单个 part 内放大量 header | header 在 PHP 内部的表示远比线上字节占内存,形成放大效应 | 请求体不大,内存占用很高 |
| 空文件控件 | 空文件不计入 max_file_uploads,但仍会创建完整的文件元数据数组 |
数量放大 |
| 嵌套字段名 | a[b][c]... 形式会让解析器生成多层数组 |
内存放大 |
| 应用侧大分配 | 攻击者可控输入触发足够大的分配或重复内存使用 | 配合其他路径 |
不同 SAPI 的差别很大。mod\_php 即使 request startup 失败也会执行 shutdown,所以官方已经在默认 input/memory limits、正常 prefork 多 worker 配置下通过 HTTP 验证了完整 RCE。FPM 在启动失败时会退出子进程,挡住了这条启动期路径;但 FPM 并不是完全免疫,原因在下一节。FrankenPHP 的行为接近 mod\_php,LSAPI 更接近 FPM,官方对两者的判断分别是“很可能受影响”和“应用相关利用很可能,但未验证”。
| SAPI | 行为 | 官方结论 |
|---|---|---|
| Apache mod\_php | 即使 request startup 失败,也会无条件执行 request shutdown | 已完整验证 RCE :默认 PHP input/memory limits、正常 prefork 多 worker 配置下,通过 HTTP 完成利用 |
| PHP-FPM | request startup 失败时直接退出子进程,阻止 shutdown 消费坏状态 | 该启动期路径被阻断;但 FPM 仍可能通过其他路径受影响(见 109619) |
| FrankenPHP | 与 mod\_php 类似,启动失败后仍可能执行 shutdown | 官方判断“很可能受影响” |
| LSAPI | 模型接近 FPM | 官方判断“应用相关利用很可能,但未验证” |
这也是为什么 mod\_php 是风险最高的已确认场景:它没有 FPM 那层“启动失败就退出子进程”的保护。
三、CVE-2026-109619
109619 讲的是另一个时间尺度:不是同一条请求里的 shutdown,而是 worker 把状态带到了下一条请求。PHP worker 是复用的,error、session、object、extension 这些全局状态会留在进程里。正常情况下,它们必须在请求内存释放前被 reset;但有些操作会在下一次请求的正常初始化之前运行,于是读到了上一条请求留下的指针。
一次完整利用需要一对 producer 和 consumer。
| 角色 | 请求 | 关键动作 | 结果 |
|---|---|---|---|
| Producer | 请求 A | 把请求 A 的内存地址写进 worker 全局状态,随后被 fatal 或 OOM 打断 | 堆已释放,全局指针仍在 |
| Consumer | 请求 B (同一 worker) | 在正常初始化之前读取、释放或分支这个旧指针 | UAF,堆布局可控时 RCE |
官方确认了两类 consumer。INI 诊断这条路径主要出现在 mod\_php 的 .htaccess/ Apache 目录配置,以及 FPM 收到 PHP_VALUE/ PHP_ADMIN_VALUE的场景;session upload progress 这条路径更特殊,它不依赖 INI 诊断,在 mod\_php 和 FPM 上都验证过。
| Consumer | 触发条件 | 已验证范围 |
|---|---|---|
| INI 诊断 | 请求初始化前应用了会产生 warning/notice/deprecation 的 INI 配置,例如非法/废弃的设置、php_value/PHP_ADMIN_VALUE等 |
mod\_php 的 .htaccess/ Apache 目录配置已确认;FPM 在特定 PHP_VALUE/PHP_ADMIN_VALUE下条件性暴露 |
| session upload progress | upload progress 在普通 session 初始化之前运行,消费上一次请求遗留的 session/decoder 状态 | mod\_php 与 FPM 均已验证 |
这里有几个容易误判的地方。.user.ini是在正常初始化之后才应用的,不能当作早期 consumer;FPM pool 里的值在 worker 启动时应用,也不是每请求的早期 consumer;error_reporting、@、display_errors=0也挡不住 INI 诊断,因为 PHP 在决定是否显示或记录之前,已经读过 error 状态了。
producer 也不一定是 OOM。parse/compile 失败、未捕获异常、require失败、Maximum execution time exceeded,甚至 shutdown 后期扩展清理产生的非致命诊断,都可能把请求状态留下来。真正关键的是:producer 和 consumer 要落到同一个 worker。多 worker 只会降低单次成功率,不会消除条件。
109618 和 109619 不是两个孤立的故事。109618 是 OOM/early-exit 造成的请求内存损坏;109619 是这种损坏(或其他 fatal)留下的全局状态被下一条请求消费。公开的 php2shell 链就是先把 OOM 变成悬空的 session 指针,再在下一个请求里接住它。PHP 在同一个修复提交 3c832b523e(Avoid state corruption via bailout)里处理了两者:OOM 不再用 longjmp,而是延迟的软 OOM + 到达更高阈值时的 _exit();同时重置启动早期可能被访问的全局状态。
四、没有 LFI,也没有 session\_start
公开利用链的名字叫 php2shell。它的目标页面可以只有一行,不需要 session_start(),不需要 LFI,也不需要往目标写 PHP 文件。原因还是第一节的那句话:session 初始化发生在 RINIT 之前,和脚本内容无关。
<?php echo \"OK\
\"; ?>
整条链大致分四步。第一步是 worker 轮换:发送大量字段和一个超大字段,让 session 初始化在请求启动阶段 OOM,当前 worker 退出,prefork 再 fork 出一个映射布局一致的新 worker,为后续标定提供可预测的起点。
第二步做堆泄漏。请求里塞入 1001 个字段,触发 max_input_vars警告;在 display_errors=On的情况下,警告被输出,headers_sent被置位。攻击者再用一个特殊的 PHPSESSID让 session 模块生成新 id,并通过 Set-Cookie的越界读把 Zend MM chunk 指针带进响应,拿到堆基址。
第三步做 libphp 泄漏。通过一个 56 字节的 HashTable 页,让 ZSTR_LEN(PS(id))读到 nTableSize,数据区正好落在 nNextFreeElement | pDestructor上;pDestructor指向 ZVAL_PTR_DTOR,于是从 Set-Cookie里可以解出 libphp 基址。ASLR 到这里就绕过了。
第四步执行命令。用一个存活的 zend\_string 承载 fake HashTable,把 fake reference 写到 PS(http_session_vars),再把 pDestructor改成 zif_system。下一条请求进入 session 初始化时,php_session_track_init()会释放这个伪造的引用,调用链最终走到 zif_system。
| 阶段 | 动作 | 目的 |
|---|---|---|
| 一 | 发送大量字段 + 超大字段,触发 session 初始化内的 OOM | 让当前 worker 退出,prefork 重新 fork 出映射布局一致的 worker |
| 二 | 利用 max_input_vars警告和 display_errors泄漏 Zend MM chunk 指针 |
获得堆基址 |
| 三 | 用 56 字节 HashTable 页泄漏 ZVAL_PTR_DTOR地址 |
获得 libphp 基址,绕过 ASLR |
| 四 | 伪造 reference 和 HashTable,把 pDestructor指向 zif_system |
在后续 session 初始化时执行命令 |
第四步的调用链如下表。
| 步骤 | 调用 | 作用 |
|---|---|---|
| 1 | php_session_track_init() |
释放悬空的 PS(http_session_vars) |
| 2 | zend_reference_destroy(R0) |
进入伪造的 reference |
| 3 | zval_ptr_dtor(&ref->val) |
把引用值当作 IS_ARRAY_EX处理 |
| 4 | zend_array_destroy(fake HT) 、zend_hash_destroy(fake HT) |
遍历伪造的 HashTable |
| 5 | pDestructor(&bucket->val) |
指向 zif_system,执行命令 |
这里只用了 libphp 内部的 zif_system,不需要 libc;ASLR 则靠两次 Set-Cookie泄漏分别拿到 chunk 基址和 libphp 基址。堆偏移、字段数量、字符串尺寸都与具体构建相关,换一个 PHP 版本或镜像,公开 PoC 就需要重新标定。
需要特别说明的是,这条链和“把 PHP 代码写进文件名,再用 LFI 包含 session 文件”不是一回事。后者需要 LFI,默认 session.upload_progress.cleanup=On时还要竞态;跨目录 session.serialize_handler不一致导致的 __destruct对象注入,以及通用 unserialize()的 var_destroyUAF,也都是不同的触发面。对比 8.3.36 源码,add_assoc_string_ex(..., \"name\", *data->filename)仍然存在,文件名依旧原样写入——本次修复并没有改这里。判断一个 PoC 是否在打本次 CVE,看它是否依赖 RINIT 前的 session 初始化、OOM/fatal 遗留状态、以及同一 worker 的后续请求。
五、复现
下面的截图来自本地隔离环境:官方 php:8.4.26-apache镜像,PHP 8.4.26、Apache prefork、mod\_php,配置为前文提到的可利用区间。先把命令写进 out/c,再用 sh</out/c触发;exploit 依次完成 worker 轮换、堆泄漏、libphp 泄漏,最后把 cat /etc/passwd的结果写回宿主机的 out/p1。
截图里能看到 stage 1 的 chunk base、stage 2 的 libphp base、stage 3 的 HTDATA/ZIF,以及最终的 PROOF输出。这里要说明一点:PROOF后面跟的是 /etc/passwd的第一行(root:x:0:0:...),不是进程 UID;这条命令证明的是代码执行和文件读取,不能据此判断 worker 以 root 运行。目标 Web worker 的实际权限仍是 www-data,前文本地测试里的 id输出是 uid=33(www-data)。
六、 PoC 的适用边界
公开 PoC 并不是零配置就能跑通。它针对的是官方 php:8.4.26-apache(Debian 12、Apache prefork、mod\_php、amd64),并且要求目标处在特定的配置区间。我们在隔离环境里对同一个镜像做了对照,只改配置,结果如下。
| 配置 | 结果 |
|---|---|
display_errors=On / memory_limit=8M/ post_max_size=64M/ 单 worker |
成功 (3/3) |
display_errors=On / memory_limit=8M/ post_max_size=64M/ 默认 5 workers |
成功,但阶段重试明显增多 |
display_errors=On / memory_limit=8M/ post_max_size=8M/ 单 worker |
失败:第一阶段堆泄漏拿不到 |
display_errors=On / memory_limit=128M/ post_max_size=64M/ 单 worker |
失败:第一阶段 |
display_errors=Off / memory_limit=8M/ post_max_size=64M/ 单 worker |
失败:第一阶段 |
官方镜像默认(display_errors=On/ 128M/ 8M/ 多 worker) |
失败:第一阶段 |
这些结果说明三件事。memory_limit必须低到能让约 12MB 的字段在 php_session_initialize()内 OOM;128M 时分配成功,悬空状态不会出现。post_max_size必须大于攻击请求体;8M 时请求到不了解析器,16M 就已经够用,64M 只是余量。display_errors=On是当前 PoC 的硬条件,因为第一阶段依赖警告输出触发 headers_sent;生产环境常关,所以这条链在真实目标上的成功率要单独评估。
单 worker 不是绝对门槛。我们让默认 5 个 worker 的 Apache 跑同一套 php.ini,最终也能打通,只是第一阶段和第二阶段的重试次数明显增加。单 worker 的价值在于确定性,而不是“没有它就完全打不了”。
| 条件 | 作用 | 备注 |
|---|---|---|
低 memory_limit(如 8M) |
让约 12MB 的字段在 php_session_initialize()内 OOM |
128M 时分配成功,悬空状态不会出现 |
post_max_size 大于攻击请求体(约 12MB) |
让 multipart 解析真正进行 | 实测 16M 已足够,64M 是余量 |
display_errors=On |
让 max_input_vars警告输出,触发 headers_sent,从而泄漏 Set-Cookie |
当前公开 PoC 的硬条件;生产环境常关 |
max_input_vars=1000 |
1001 个字段触发警告 | 默认值;调大后该路径失效 |
session.use_cookies=1 、session.use_strict_mode=0、upload_progress.enabled=1 |
维持 session 初始化与 SID 行为 | 默认值通常已满足 |
| Apache prefork + mod\_php | 利用 fork/worker 复用与启动期解析 | 这条公开链未覆盖 FPM |
| 单 worker | 提高确定性与成功率 | 不是绝对门槛 :多 worker 在重试下也能打通 |
这里有一个必须说清楚的边界:默认官方镜像打不通这个公开 PoC,不等于默认 PHP 安全。官方通告明确写了,mod\_php 在默认 input/memory limits、正常 prefork 多 worker 配置下已经通过 HTTP 完整验证 RCE。公开 PoC 只是选了“12MB 字段打爆 8M”这一种 OOM 构造;通告里还列了无 Content-Length、海量 header、空文件控件等策略,可以在默认 post_max_size下制造 OOM。对漏洞本身来说,默认 mod\_php 仍然是被官方确认的高危面。
攻击者不能远程修改目标的 php.ini。这些值是目标既有配置;公开 PoC 的成功,只说明目标恰好落在可利用区间。
七、检测
检测这类攻击,不能只盯着应用层日志。利用发生在 RINIT 之前,中间件、业务日志、部分 WAF 规则可能看不到完整请求。更值得看的是请求形态和 PHP 进程本身的行为。
| 信号 | 说明 |
|---|---|
| 大量字段的 multipart 请求 | 接近或超过 max_input_vars,可能触发 warning 路径 |
| 超大普通字段 / 超长文件名 / 单个 part 内大量 header | OOM 放大策略的典型特征 |
PHP_SESSION_UPLOAD_PROGRESS + 异常 PHPSESSID |
NUL、超长、非预期字符;用于 SID/堆布局控制 |
| 同一来源短时间内重复 multipart POST | worker 轮换、重试、堆布局尝试 |
响应中的超大或畸形 Set-Cookie |
可能是越界读泄漏 |
Allowed memory size ... exhausted 、zend_mm_heap corrupted、worker SIGABRT / FPM child exit |
OOM 与状态损坏的迹象 |
| Web worker 启动 shell、下载工具或其他非预期子进程 | 可能已进入命令执行阶段 |
配置审计同样重要。先看 session 和请求解析相关的开关:上传进度是否真的需要,内存上限和 POST 上限是否给了过大的空间,输入变量、multipart part 和上传数量是否设了边界。再看错误输出:display\_errors、PHP 8.5 的 fatal\_error\_backtraces,以及 error\_reporting 是否会把早期诊断暴露出来。
如果站点使用 Apache 的 .htaccess / php\_value,或者 nginx 通过 fastcgi\_param 传 PHP\_VALUE / PHP\_ADMIN\_VALUE,还要确认这些每请求配置是否会制造早期的 INI 诊断。FPM pool 的 pm.max\_requests 和 worker 回收策略也值得一起看。
日志里没有记录,不等于没有发生。真正有价值的判断,是把请求形态、PHP 错误、worker 生命周期和进程行为放在一起看。
八、修复
修复只有一个完整答案:升级到 8.2.35、8.3.36、8.4.27、8.5.12,然后重启所有 PHP 进程。PHP-FPM、mod\_php、Octane/queue worker、CLI 常驻进程都要重启,只 reload 不够。
修复提交 3c832b523e先处理 OOM:不再用 longjmp()直接跳出,而是改成延迟的软 OOM,并在内存继续增长到更高阈值时用 _exit()结束。它同时重置了启动早期可能被访问的全局状态。
session 这一侧的变化更直接:upload progress 在 RINIT 前改为 incomplete_classes_only模式解码。对象保持为 __PHP_Incomplete_Class,不会触发 autoload、unserialize_callback_func、__wakeup()或 __unserialize();序列化时再把 C:/E:等原始载荷写回。这样即使早期 session 数据里带着对象,也不会在用户代码加载之前跑起来。
| 分支 | 修复版本 |
|---|---|
| PHP 8.2 | 8.2.35 |
| PHP 8.3 | 8.3.36 |
| PHP 8.4 | 8.4.27 |
| PHP 8.5 | 8.5.12 |
补丁之外的配置只能拆链路,不能替代补丁。
| 措施 | 覆盖 | 局限 |
|---|---|---|
前端阻断 multipart/form-data |
同时阻断 109618 的 multipart OOM 路径与 109619 的 upload progress 路径 | 会破坏文件上传;可对上传端点做白名单 |
session.upload_progress.enabled=0 |
阻断 109619 已确认的 session 路径 | 不影响 109618;需重启 worker |
fatal_error_backtraces=0 (PHP 8.5) |
移除 8.5 已确认的 fatal backtrace producer | 不影响其他状态族 |
memory_limit=-1 |
移除 109618 可预测的 OOM 触发点(官方主配置缓解) | 不完整;需用容器/cgroup 限制内存;不要用 RLIMIT_AS/RLIMIT_DATA |
降低 post_max_size、限制输入变量/part/header/upload 数量 |
降低 OOM 与 DoS 概率 | 不完整,不能替代补丁 |
移除每请求 INI 覆盖(php_value、PHP_VALUE等) |
阻断 109619 的 INI 诊断 consumer | 需要把配置移到 php.ini或 FPM pool |
pm.max_requests=1 |
让 worker 无法把状态带到下一请求 | 性能代价高;不覆盖单请求路径 |
| 修复攻击者可触发的 fatal error / 超时 | 减少 109619 的 producer | 需要代码审计,不能覆盖所有扩展/回调路径 |
| WAF/CDN 规则 + 源站不可直连 | 阻断已知 multipart 攻击流量 | 攻击者直连源站即绕过;不能替代补丁 |
官方明确说,没有完整的通用配置缓解。配置的作用是争取时间,真正的修复仍然是升级。
还有一些做法不要尝试。.user.ini和 ini_set()在请求启动之后才生效,挡不住早期 consumer;error_reporting、@、display_errors=0也不能“隐藏”问题,因为 PHP 在决定是否显示或记录之前就已经读取了 error 状态;只关 PHP handler 来下线更危险,Web 服务器可能把 .php源码直接返回;RLIMIT_AS/ RLIMIT_DATA也不适合当内存限制,它们会让 mmap()失败,反而触发漏洞路径。
九、时间线与参考
| 时间 | 事件 |
|---|---|
| 2026-10-21 | PHP 安全团队公开两份 GitHub Security Advisory,CVSS v4.0 9.2 |
| 2026-10 | 修复版本 8.2.35 / 8.3.36 / 8.4.27 / 8.5.12 发布 |
| 2026-10 | 公开利用研究(php2shell)展示完整纯 HTTP RCE 链与 ASLR 绕过 |
- GHSA-w8hw-w9fx-j79w — RCE via OOM corruption(CVE-2026-109618)
- GHSA-6wmf-4rv5-p3rp — RCE via stale global state(CVE-2026-109619)
- PHP 8.3.36 changelog
- 修复提交 3c832b523e — Avoid state corruption via bailout
- PHP 源码:main/main.c(请求启动顺序)
- PHP 源码:ext/session/session.c(session 初始化与 upload progress)
- php2shell 研究站点
版权声明
本站仅做备份收录,仅供研究与教学参考之用。
读者将信息用于其他用途的,全部法律及连带责任由读者自行承担,本站不承担任何责任。









评论