PHP CVE-2026-109618/109619 — 在原生 PHP 8.5.11 上实现自包含的未认证 RCE(带内 PS(id)→cookie 内存泄露 + UAF 链),完整控制权提升阶梯,以及在 8.5.12 上的修复版本阴性对照
针对 PHP 8.5.11 php2shell 类漏洞(CVE-2026-109618、CVE-2026-109619,2026 年 10 月)的实验复现与漏洞研究包。 状态:已证实 — 自包含 RCE(四个普通 HTTP 请求,无需调试器,无需本地访问),另附下文中的放大效应、补丁缺口与 FPM 陈旧状态发现。
目录: 结果 · 利用链 · 放大效应 · 补丁缺口 · FPM 陈旧状态 · WAF 绕过 · 环境 · 快速开始 · 证明规范 · 目录结构 · 公开背景 · 声明
| # | 发现 | 分类 |
|---|---|---|
| 1 | 在 PHP 8.5.11 上实现端到端自包含 RCE — 四个普通 HTTP 请求,通过 PS(id)→cookie 泄露进行带内地址获取,两个基址均来自一次 512 字节的 PHPSESSID 泄露;12/12 次触发成功,uid=33,覆盖 8 个 ASLR 代次 | 已证实 — 自包含 RCE |
| 2 | 通过重复的 6 字节 multipart 头部实现 52 倍内存放大 — 在 PHP 7.4–8.5.11 上突破 post_max_size(2.57 MB 请求体即可使 128M 限制发生 OOM) | 已证实 — 实验环境,全部四个版本 |
| 3 | 三条利用路径在 8.5.12 安全补丁后依然存活(n=1015 时的 var_destroy 重入 UAF、序列化处理器不匹配、上传进度注入) | 已证实 — 在经 GPG 验证的 8.5.12 上通过 ASAN |
| 4 | FPM 陈旧 BG(unserialize):worker 在会话解码 OOM 后存活,陈旧状态被下一请求的 rfc1867 窗口消费,堆塑形消费导致 worker 发生 SIGSEGV | 已证实 — 机制 + DoS;RCE 未证实 |
| 5 | Content-Type 分隔符绕过(multipart/form-data, boundary=X)— 所有已公开的以 Content-Type 为锚点的 WAF 签名均可被绕过 | 已证实 — SAPI.c 解析器行为 |
| 6 | 两条 CVE 利用链在已修补的 8.5.12 上均失效(configure 令牌一致,源码经 GPG 验证) | 已证实 — 阴性对照 |
| 7 | 公开的“缓存 zval session.upload_progress”理论 — 在 4 个原版 PHP 版本 + 3 个 LiteSpeed 厂商构建、所有清理/并发角落上均被驳斥 | 已驳斥 — 穷尽验证 |
两条独立的内存破坏利用链在原版 php:8.5.11-apache 镜像上均能到达攻击者可控的间接调用;端到端封装将链 B 的机制与一种新颖的带内泄露相结合,实现完全远程触发。
| 链 A — 陈旧 bucket | 链 B — 悬空引用 | |
|---|---|---|
| 机制 | OOM 退出路径留下一个已发布但未初始化的 HashTable bucket;关闭过程将其作为伪造 zval 消费 → 伪造数组 → pDestructor | RINIT 前 OOM 使 PS(http_session_vars) 持有悬空的 zend_reference;一个存活的 31 字节字符串将其回收;伪造引用 → zend_hash_destroy pDestructor |
| 分配器 | tracked/system(USE_ZEND_ALLOC=0) | 原版默认 Zend MM |
| 终端 | libc system()(经 gdb 校准的地址) | zif_system @ libphp+0x5996e0(无 libc) |
| 触发时调试器 | 仅用于校准 | 无 — FIRE_GDB=0 |
| 可复现性 | 2 个随机化父代次 | 可重复,包括跨运行 |
Leak-A 布置一个悬空的 PS(id)(第 1001 个变量触发 max_input_vars →
headers_sent=1;空 cookie 的 reset_id 失败使 send_cookie=1 保持为 1;
一个 24 MiB 的字段在 RINIT 之前触发 OOM)。Leak-B 落下一个 257 元素的数组,其
zend_array 与 PS(id)−0x10 重叠:ZSTR_LEN 读取到 nTableSize=512,并且
URL 编码的 cookie 携带 pDestructor(→ libphp 基址)以及九个 arData
堆指针(多数投票 → chunk 基址)。实测的 chunk−libphp 差值
在不同代际之间并非恒定(−0x1a00000 / +0x3600000 / −0x1c00000),
因此该信息泄露确实起着关键作用。随后利用泄露的地址执行触发。前提条件:display_errors=On、post_max_size 高于
OOM 字段、memory_limit 低于它、单 worker 的 prefork。
A status='HTTP/1.1 200 OK' → OOM fatal, worker survives, PS(id) armed
B Set-Cookie: PHPSESSID=<512-byte encoded disclosure>
FIRE → MARKER=uid=33(www-data) gid=33(www-data) groups=33(www-data)
FIRE_OK (×12 across 8 distinct ASLR layouts)
完整复现与控制阶梯:docs/END-TO-END.md。
优化后的重复头部放大:短的 X: a 头部(线上 6 字节)利用 PHP 为每个保存的头部固定分配 256 字节 SMART_STR_START_SIZE(每 6 字节线上数据约 314 B 内存)。在 8.5.11 上通过精确的 OOM 阈值二分法测量;在 7.4.33 / 8.1.34 / 8.4.26 上结果相同(51–52 倍):
| memory_limit | 最小 OOM 请求体 | 放大倍数 |
|---|---|---|
| 8M | 153 KB | 54.8x |
| 32M | 636 KB | 52.8x |
| 128M | 2.57 MB | 52.3x |
| 256M | 5.14 MB | 52.2x |
所有请求体均能容纳于默认的 post_max_size=8M 之内——实际的上传屏障在每个未修补版本上均被突破。分块传输编码还额外绕过了 Content-Length 预检查(8.85 MB 的分块请求体被解析并触发 OOM,而同一请求体带 Content-Length 时被拒绝)。8.5.12 补丁封堵了这两个向量(未知头部过滤 + 流式 post_max_size 强制执行)。嵌套数组“炸弹”形态不会呈现指数级放大(最大 9.6 倍,1.16 MiB——即使 8M 也无法 OOM)。详情:docs/RELEASE-RECONCILIATION-2026-10-21.md。
在经 GPG 验证、令牌完全一致的 8.5.12 ASAN 构建上验证——两个 CVE 已修复,但仍有三条独立的可利用路径在补丁后存活:
var_unserializer.c:906:67);补丁的 ZEND_HAS_OOM 防护在此路径上无效;BG(serialize_lock) 在 __destruct 调度周围仍未被获取。php_serialize 写入的 | 键会话,在应用阶段 session_start() 之后由 php 处理器解码,会在 mod_php php_value 和 FPM .user.ini 上同样实例化 gadget;补丁的 incomplete_classes_only 防护仅作用于 RINIT 前的上传窗口。在 php:8.5.11-fpm 上经实验室验证:在 memory_limit=32M 下,一个 24 MB 的会话在 php_var_unserialize 内部触发 OOM,而工作进程存活——退出码 70 的屏障仅适用于启动阶段的 OOM。gdb 显示 BG(unserialize).level=1 以及陈旧数据指针持续存在,进入下一个请求的 rfc1867 RINIT 前窗口;一个 8191 字节的 multipart 字段产生 8216 字节的 _emalloc,与陈旧指针重叠,并在请求启动期间于 var_tmp_var 中使工作进程发生 SIGSEGV。实测影响:可重复的工作进程崩溃(DoS)外加一个内存破坏原语;崩溃之外的 RCE 尚未证实。前提条件:较低的 memory_limit、较大的受攻击者影响的会话、一个 session_start() 端点、upload_progress=On,以及 pm=static max_children=1 以实现可靠的同一工作进程配对。因此,对于此类问题,FPM 是有条件地而非绝对地受到保护。
Content-Type: multipart/form-data, boundary=X(逗号分隔符)会被 PHP 接受——SAPI.c 在 ;、, 和空格处截断。所有以 Content-Type: multipart/form-data; 为锚点的已发布 php2shell WAF 签名均可被绕过。防御者指南见 docs/WAF-BYPASS.md。
display_errors=Off 以及高 memory_limit 均仍是真实屏障——参见 docs/RELEASE-RECONCILIATION-2026-10-21.md 中的集群研究);| 属性 | 必需值 |
|---|---|
| 主机 | 原生 Linux x86_64 |
| 目标 | PHP 8.5.11,Apache 模块(Chain B 也运行在 FPM 上以进行陈旧状态相关工作) |
| 镜像 | php@sha256:974e3a920309308e1690ed607e6cc3061d6906f90cc8d30fe519daa48bccf101 |
| Chain A 分配器 | USE_ZEND_ALLOC=0、USE_TRACKED_ALLOC=1(有意使用非默认值) |
| Chain B 分配器 | 默认 Zend MM |
| PHP 限制(链) | memory_limit=16M、post_max_size=32M、upload_max_filesize=32M |
| Apache | prefork,恰好一个 worker |
| 调试器 | 支持 Python 的主机 GDB(仅用于校准/回执) |
仅在自有且隔离的 x86_64 Linux 实验环境中运行:
sudo ./scripts/preflight.sh
sudo ./scripts/reproduce.sh
运行器会校验环境、生成字节完全一致的 5,767,505 字节 fixture、校准一个安全的 type-0x7 槽位,然后运行全新 worker 的 negative → abort → marker 流程,并执行独立的 marker 字节、worker 状态转换、健康检查和 trace 检查,最后写出完整的 SHA-256 清单。受保护放置未命中会被记录,并按每个全新 worker 重试一次——未命中绝不会被报告为已执行。只有当 runs/DATE/VERDICT.json 中的每个门禁都为 true 时,保留的判定才有效;无需触发即可重新验证:python3 scripts/verify_run.py runs/DATE。
sudo ./scripts/lab-stop.sh # stop container, keep state
sudo ./scripts/lab-reset.sh # remove PoC container + network only
| 用例 | 预期效果 | RCE 判定依据 |
|---|---|---|
| 校准 | 观察到 Type-0x7 陈旧槽位;worker 存活 | 无 |
| 阴性 | 存在伪造结构;无调度;同一 worker | 标记缺失 |
| 中止 | 引用计数递减;精确的 abort@plt;SIGABRT | 标记缺失 |
| 标记 | 精确的目标调度;命令输出位于 /tmp 下 | 精确的唯一字节 |
HTTP 错误、空响应、PHP 致命错误或 worker 崩溃不被接受为命令执行证据。FPM 陈旧状态发现之所以被报告为崩溃原语,正是因为它尚未通过这一标准。
id/printf 类命令,且仅在专用容器内写入 /tmp 下。app/ Minimal PHP/Apache target
tools/ php2shell_test.py (v1+v2) + domain_php2shell_scan.py
ninebie/ Chain B PoC (send/env scripts, fire entry point, receipts)
docs/ Proof boundary, port write-ups, reconciliation log
evidence/reference/ Chain A two-generation proof summary + provenance
evidence/validation-*/ Chain A compact evidence, two fresh generations
scripts/ Chain A lab, calibration, body, runner, verifier
tests/ Deterministic fixture and safety tests
artifacts/, runs/ Generated locally; ignored by Git
SOURCE-SHA256SUMS 用于认证目录树:python3 scripts/make_manifest.py --verify。
docs/PUBLIC-CONTEXT-2026-10-21.md — 公开记录与本实验室的对比:
我们验证了什么、驳斥了什么(对缓存 zval 的
session.upload_progress 理论进行了穷尽性检验)。docs/RELEASE-RECONCILIATION-2026-10-21.md — 发布后每项发现的
持续记录:放大效应、补丁缺口、FPM 陈旧状态、
第三方主张的裁定(junaire、bnd9、Whackermelon、ikow Chain E)。docs/WAF-BYPASS.md — Content-Type 分隔符绕过 + 防御者指南。docs/NINEBIE-CLASS-PORT.md — Chain B 机制、偏移量、差异。docs/END-TO-END.md — 自包含的 RCE 凭证与控制阶梯。docs/ASLR-STOCK.md — 默认配置(无 php.ini,128M)下 A 阶段武装 OOM
与 B 阶段 128M 堆泄漏:缓存遍历 + 伪造 PS(id) ->
从 Set-Cookie 获取 Zend-MM 块基址
(tools/php2shell_aslr_stock.py、tools/php2shell_stock_chain.py,
演示位于 demo/aslr-stock/)。