PHP 漏洞迷局 48 小时:两个 CVE、两条 RCE 链,与一次 AI 协同的深层复盘
还原 CVE-2026-109618 与 CVE-2026-109619 的攻防推演全过程:从基于上传进度的 LFI 组合链,到纯 HTTP 内存级 ASLR 绕过与函数劫持,以及人机协同在极限漏洞挖掘中的反思与架构重构。
10 月 21 日,长亭科技发布了一则关于 PHP 核心组件的安全预警,涉及两个 CVSS 9.2 的远程代码执行(RCE)高危漏洞(CVE-2026-109618 与 CVE-2026-109619)。当时官方尚未正式释出安全补丁,通告也仅指出了“特定请求顺序与应用会话状态下会导致内存释放后使用(UAF)并引发崩溃(SIGSEGV)”。
官方没有触发路径,民间也没有公开 PoC。在这场 48 小时的攻防竞速中,我们借助 Claude Code 编排与私有化 GLM-5.3 的人机协同,快速打通了第一个 CVE 的高可靠利用链;而在攻坚另一个纯内存级的 UAF 漏洞时,直到开源社区研究员 9bie 公开了首份绕过 ASLR 的利用脚本,最难的一块拼图才告彻底补齐。
本文梳理这两个漏洞背后的机制差异、跨环境运行特征,并聊聊人与 AI 在极限攻防复盘中的得与失,以及这些教训如何重塑了我们的 SecAgent v2(新一代漏洞挖掘与武器化 Agent 架构)。
一、迷局:两个 CVE 与两条截然不同的攻防路径
通告虽然简短,但经验丰富的安全研究员很快能看出,这两个编号(官方后续在补丁中作为成对修复合并处理)在攻防推演中迅速分化为了两个完全不同的技术维度:
- 分支一(围绕 CVE-2026-109618):围绕 PHP 底层文件上传流与 Session 会话追踪机制的逻辑与文件落盘缺陷;
- 分支二(围绕 CVE-2026-109619):直面解释器内存底层的释放后使用(UAF)与状态残留,目标是在不依赖任何目标应用漏洞的前提下,实现纯内存级的代码执行。
一个偏向逻辑与机制组合,另一个则是极其硬核的二进制解释器堆利用。我们的探索,正是从相对清晰的第一条路径开始破局。
二、分支一:基于上传进度的文件包含利用链(CVE-2026-109618 机制挖掘)
这个漏洞的切入点源于 PHP 一个拥有十余年历史的内置特性——session.upload_progress(文件上传进度追踪)。
很多开发者对它并不陌生:当客户端上传大文件时,只要在表单中附带一个特殊的隐藏字段(默认名 PHP_SESSION_UPLOAD_PROGRESS),PHP 就会在底层 C 语言扩展(RFC1867 协议解析模块)中拦截请求,并将“当前上传了多少字节”实时写入服务器本地的 Session 文件中。
这个特性默认开启,且在目标业务代码执行前就已经由底层解析完毕。
1. 人机协同定位:防君子不防小人的单字符守卫
在上传进度机制中,写入 Session 文件的键名是由攻击者传入的值拼接而成的:
upload_progress_<攻击者传入的内容>
直觉上,如果我们把一段 PHP 木马代码直接当作名字传进去,它就会被原样写进 Session 文件。早期的 PHP 开发者为此加了一道防御:如果键名包含竖线 |,则直接拒绝写入。 因为在传统的 PHP 会话序列化格式中,竖线是划分键名与类型的分隔符。
借助 Claude Code + GLM-5.3 对底层源码的高速排查,我们快速锁定了 ext/session/session.c 中针对默认 php 序列化处理器的编码校验逻辑:
/* ext/session/session.c php_session_encode() */
if (memchr(key, '|', key_len)) {
return FAILURE; // 发现竖线分隔符,拒绝写入
}
这处逻辑非常有意思:底层的防御用的是单字符查找函数(memchr),它真的只检查了 |(PS_DELIMITER)这一个符号。
而常规的一句话木马:
<?php system($_GET['c']); ?>
里面根本没有竖线。这道原本用来防御恶意键名的守卫,对整段 PHP 代码完全视若无睹,直接批准落盘。
2. 存活周期:大文件慢速上传的“时间静止术”
代码能写进去了,但官方配置中默认开启了 upload_progress.cleanup = On——文件一旦上传完毕,PHP 就会立即把这条进度键从 Session 中删掉。正常的上传在毫秒级内就会结束,木马在硬盘上停留的时间稍纵即逝。
怎么让木马稳定留在磁盘上?答案藏在 HTTP 协议的流式特性里:让上传处于“未完成”状态。
客户端构造一个请求,在头部声明 Content-Length: 100MB,但在发送请求体时,以极其缓慢的速率(如 1KB/s)发送数据,甚至发送到一半故意挂起连接:
- 由于数据一直没传完,PHP 的上传结束清理函数(
MULTIPART_EVENT_END)永远不会被触发; - 但为了汇报实时进度,底层解析器每接收到一小段数据块,就会主动将包含恶意木马的进度键刷入磁盘上的 Session 文件。
只要攻击者的网络连接挂在那里,服务器硬盘的 Session 文件(默认在 /tmp/sess_<自选ID>)里就会持续躺着一段完整可执行的 PHP 木马代码。
3. 绕过排他锁:为什么必须配合文件包含?
木马已经在服务器硬盘上了,如何触发它?
常规情况下,如果直接访问一个带有 session_start() 的页面试图执行该文件,请求会被死死卡住——因为刚才那个慢速上传的连接正霸占着 Session 文件的操作系统排他写锁(flock)。
但这难不倒有经验的安全工程师:PHP 的文件包含语句(include)直接走操作系统的底层读句柄,它根本不走应用层的 Session 锁!
只要目标 Web 站点上存在一处本地文件包含点(LFI,例如代码中随处可见的 include($_GET['page'])),攻击者只需并发发起一个请求:
?page=/tmp/sess_<自选SessionID>
include 绕过 Session 写锁,直接将 Session 文件中的恶意代码作为 PHP 脚本解析执行,一条稳定的 RCE 攻击链就此达成。
4. 漏洞特征与跨环境运行表现
针对不同的 SAPI 运行模式、反向代理与缓冲机制,我们在实测中整理了以下环境特征矩阵:
| 维度 | 环境/组件组合 | 实测表现 | 关键机制与参数规律 |
|---|---|---|---|
| SAPI 模式 | Apache mod_php | 稳定触发(首发命中) | 底层流式解析,慢发期间 Session 毒键持续存在 |
| SAPI 模式 | php-fpm(FastCGI 直连) | 稳定触发 | FCGI 慢速透传,同样能维持盘上木马存在 |
| SAPI 模式 | LiteSpeed SAPI (LSAPI) | 稳定触发 | 机制与 FPM 一致,流式解析窗口均成立 |
| 反向代理 | Apache mod_proxy_fcgi → FPM | 稳定触发 | Apache 默认透传流式请求体,保留时间窗口 |
| 反向代理 | HAProxy (TCP mode) → mod_php | 稳定触发 | 纯 TCP 转发,不破坏客户端慢发流式节奏 |
| 反向代理 | Nginx → php-fpm (默认配置) | 稳定触发(需注意请求体大小) | Nginx 出厂默认开启 fastcgi_request_buffering 攒批请求体。但 PHP 后端是逐 FILLUNIT(5120B)解析的,只要请求体大小低于 client_max_body_size(实测 768KB),后端解析期间毒键依然在盘 |
| 反向代理 | Nginx proxy_pass → Apache | 稳定触发 | 原理同上,后端解析耗时区间内均可被包含读取 |
| PHP 配置 | session.use_strict_mode = 0 | 默认配置即可 | 攻击者自选不存在的 SID,PHP 会自动在 /tmp 创建 |
| PHP 配置 | session.use_strict_mode = 1 | 需已知有效 SID | 需先拿到一个服务端合法的 SID(可通过探测确定) |
| 版本覆盖 | PHP 7.4.x / 8.1.x / 8.2.x / 8.5.x | 全版本通用 | 底层解析机制与序列化检查一致,均可稳定复现 |
借助 Claude Code + GLM-5.3 的快速审计与自动化测试脚本编写,我们在短时间内就完成了这条利用链的完整验证。但更艰难的挑战,在分支二的纯内存利用上。
三、分支二:纯内存级 UAF 与 ASLR 绕过分析(CVE-2026-109619 深度攻坚)
分支二所指向的利用条件更为苛刻,但危害也更加纯粹:目标服务器无需包含任何业务漏洞或文件包含点,即使页面仅有一句静态输出 <?php echo "OK"; ?>,也能直接通过纯网络请求获取 Shell。
1. 钥匙其实在我们手中:OOM 异常逃逸
要实现纯内存利用,首先必须打破 PHP 的内存完整性。正常情况下,PHP 的垃圾回收机制极其严密,即便脚本正常结束或抛出警告,所有申请的内存也会被按部就班地释放打扫。
在深挖底层源码的过程中,我们在实验第 19 轮独立发现了一个绝杀点:内存耗尽熔断(Out of Memory, OOM)。
在生产环境中,常见的配置往往是 post_max_size(如 64MB)大于 memory_limit(如 8MB 或 128MB)。当我们在单个上传请求中构造大量超长字段耗尽脚本内存时,Zend 引擎会调用 zend_bailout()。
zend_bailout() 底层通过 C 语言的 longjmp() 直接强行跳转退出——它跳过了正常情况下的所有打扫战场逻辑!
负责释放 Session 全局指针的清理函数全部被略过,PHP 进程带着悬挂的脏指针直接结束了当前请求。
但当时我们卡在了最后一道难关上——如何远程绕过现代操作系统的 ASLR(内存地址随机化)?
在现代操作系统中,函数和内存地址每次启动都是随机的。在完全没有业务回显、不能在本地读取 /proc/self/maps 的纯远程黑盒环境下,你根本不知道 system() 函数的实际内存地址在哪里,盲目跳转只会让进程崩溃。我们尝试在本地构建指针读写,但始终无法在远程环境中构建出一个稳定的“基址泄露信道”。
2. 9bie Exploit 的精妙闭环
就在全球安全团队苦思如何跨越 ASLR 时,开源社区的安全研究员 9bie(GitHub 项目 php2shell-bypass-aslr-exp 作者)公开了一份令人惊艳的完整 Exploit。
看到代码后,所有技术拼图瞬间完整了。9bie 的利用构思极其精巧,堪称 Web 场景下二进制堆利用的典范:
第一步:借用 Apache Prefork 维持基址固定
在 Apache 常见的 Prefork 模式下,所有的子进程都是主进程派生(fork)出来的,它们天生拥有完全相同的 ASLR 内存随机化基址。即便某个子进程因异常退出重启,新派生出来的进程地址空间依然保持一致。这为跨多个 HTTP 请求分步泄露内存提供了物理前提。
第二步:用 HTTP 响应头作为天然的“内存窃听器”
远程黑客如何得知服务器的内部内存布局?9bie 找到了一个极为巧妙的输出口——HTTP 响应头中的 Set-Cookie。
在第一个请求用 OOM 撑爆内存造成指针悬挂后,紧接着发起第二个请求:
- 构造一个精确到 79 字节的数据包,精准占领刚才被悬挂的那块内存;
- 在该内存中伪造一个虚假的超长字符串结构,将其长度恶意标定为
0x1000; - 当 PHP 试图向客户端发送正常的
Set-Cookie: PHPSESSID=...时,底层的 URL 编码函数信以为真,按照这个伪造的超长长度一路向后越界读取堆内存,将包含堆地址和libphp函数指针的数据原封不动地编码进了 Cookie 返回给客户端!
通过在返回的 Cookie 中模式匹配出 2MB 对齐的特征值,黑客在远程直接解密出了 Zend 内存分配器和 libphp 的物理基址,ASLR 防御在最平常的 HTTP 响应头面前被瞬间化解。
第三步:李代桃僵,劫持对象析构链
获取基址后,第三个请求在受控堆上伪造 HashTable 结构,将垃圾销毁函数指针 pDestructor 直接偷换为 PHP 内部的系统命令执行函数 zif_system,参数指向伪造的命令字符串。
下一个请求初始化触发析构链时,代码在不知情的情况下跳进了被篡改的函数指针中:
zif_system("id; whoami");
终端回显:uid=33(www-data),整条纯内存利用链完美闭环。
四、复盘反思:为什么此前未独立完成纯内存全链利用?
梳理整个研发过程,我们在第一条链上推进迅速,但在第二条链上未能独立走完全程。在第 19 轮推演中,我们其实已经准确记录了“唯一跳过清理的是 OOM bailout”这把核心钥匙,为什么最终没有独立写出这个全链利用?
冷静复盘下来,三个方法论断层浮出水面:
-
判据选型错误:拿“不崩溃”替代“不可利用”
在早期测试中,我们在 Release 和 ASAN 环境下尝试了大量模糊测试数据,看到服务均正常响应且 0 崩溃,便过早得出了“该悬挂指针未被消费、路径不可达”的消极结论。实际上,精巧的堆利用完全可以在不触发 SIGSEGV 崩溃的情况下优雅完成。 -
原语视角受限:将核心机制降级为辅助手段
当我们发现了 OOM 会跳过清理后,下意识的反应是:“这正好可以让我们的恶意木马在磁盘上永久留存,省得客户端慢发了”。我们把这把顶级的底层利用钥匙,仅仅当成了辅助文件写入的便利工具,缺乏向“进程全局脏指针复用”进一步深挖的系统性推演。 -
信道审计缺失:依赖本地环境,缺乏协议原生视野
在推演 ASLR 绕过时,我们习惯性使用本地的/proc/self/maps查看内存,得出了“远程无回显不可绕过”的悲观假设,没有跳出底层去审视最平常的 Web 协议层:问一问“目标软件自身的哪些正常回显(如 Set-Cookie、报错页),可以被借用为向外带出数据的信道?”
五、沉淀:这些教训如何重塑 SecAgent v2
复盘的价值,在于把实战踩坑的教训,固化为不可逾越的自动化工程防线。针对本次暴露出的软肋,我们在 SecAgent v2(新一代漏洞挖掘与武器化 Agent 架构) 中做出了针对性重构:
┌────────────────────────────────┐
代码库/仓库 ───→ │ 通道 A:静态污点分析(传统漏洞) │ → 逻辑与注入类漏洞
└────────────────────────────────┘
CVE 通告/补丁 ─→ ┌────────────────────────────────┐
(运行时组件) │ 通道 B:运行时状态审计(内存安全) │ → 状态破坏与生命周期漏洞
│ ↓ Gate 0 武器化代差与待办回放 │
│ ↓ Gate 1 异常出口清单 │
│ ↓ Gate 2 例外强制升级 │
│ ↓ Gate 3 协议反射拓扑模板 │
│ ↓ Gate 4 通用分配器探针库 │
│ ↓ Gate 5 外部情报与生态监控 │
└────────────────────────────────┘
-
Gate 0 武器化代差阶梯与收敛熔断(防自嗨)
建立明确的能力分级:L0(崩溃) → L1(信息泄露) → L2(条件依赖 RCE) → L3(无依赖通用 RCE)。任何分支如果连续产生 3 个局部原语(如活指针读、写槽回环)却未能跨越到更高等级,强制触发线间裁决并暂停发散,防止模型陷入局部优化的“虚假繁荣”。同时,所有待办事项必须带轮龄强制进入队列,杜绝遗忘。 -
Gate 1 异常出口清单(破除“不崩即不可达”)
分析底层漏洞时,第一步强制枚举所有非正常退出流(OOM / abort / longjmp / 信号中断),逐一排查“跳过了什么清理、残留了什么状态、后续谁来消费”,严禁在未穷尽异常出口前判定死路。 -
Gate 3 协议反射拓扑模板(终结信道盲区)
强制要求 Agent 在分析初期列出目标协议的原生输出面(响应头注入、错误信息、时序差异等)。未完成协议信道排查前,禁止以本地调试接口(如/proc)作为最终结论。 -
Gate 4 通用分配器探针库(工程腿自动化)
将堆布局与分箱标定工作沉淀为底层自动化探针。模型只需给出声明式参数,由底层执行网格搜索并返回成功率,避免模型在上下文里手写大量脆弱易错的测试脚本。 -
Analyst 与 Falsifier(破壁者)上下文物理隔离
构造攻击链的模型与负责反驳的破壁模型实行上下文物理隔离。破壁模型仅获取判据矩阵,专门从异常出口和未覆盖维度挑刺,杜绝单模型在同一上下文内的思维传染与妥协。
六、防御建议:为什么升级不是唯一解?
-
官方补丁边界
PHP 官方释出的 8.4.27 与 8.5.12 补丁主要增加了针对bailout的状态清理,封堵了 CVE-2026-109619 的纯内存利用链。但正如前文所述,官方补丁并没有拦截恶意代码写入 Session,因此只要目标业务存在文件包含漏洞,CVE-2026-109618 依然可能造成危害。 -
立竿见影的缓解手段:关闭上传进度
对于无法立即升级或运行在老旧 EOL 版本(如 PHP 7.x)的环境,建议在php.ini中直接关闭该特性:session.upload_progress.enabled = Off该操作能从入口处同时切断上述两条攻击路径。由于现代 Web 开发早已普及前端分片与异步上传,关闭此特性通常对业务完全无感。