CVE-2026-109618 php2shell 漏洞分析与复现
一、漏洞概要
CVE-2026-109618 与 CVE-2026-109619 是 PHP Core 的跨请求内存破坏漏洞,公开代号 php2shell,CVSS 评分 9.2。
| 项目 | 内容 |
|---|---|
| 漏洞类型 | 远程代码执行、内存泄露 |
| 危害等级 | 高 |
| 触发方式 | 网络远程 |
| 权限要求 | 无需认证 |
| 配置要求 | 默认配置下存在可利用路径 |
| 用户交互 | 无需交互 |
| 利用成熟度 | POC 与 EXP 已公开 |
| 修复复杂度 | 低,官方已发布修复版本 |
缺陷位于 PHP 引擎本身,不依赖上层应用代码。Apache mod_php 与 PHP-FPM 复用 worker 进程的机制会放大这个问题。
攻击者不需要账号,也不需要用户点击。构造特定的 HTTP 请求,就可能读到进程内存,其中包含其他请求的口令、会话令牌、API Key 与证书;在已公开的利用链中,还可以进一步执行任意代码。
二、影响版本
| 分支 | 受影响版本 | 修复版本 |
|---|---|---|
| PHP 8.5 | 8.5.0 至 8.5.11 | 8.5.12 |
| PHP 8.4 | 8.4.0 至 8.4.26 | 8.4.27 |
| PHP 8.3 | 8.3.0 至 8.3.35 | 8.3.36 |
| PHP 8.2 | 5.4.0 至 8.2.34 | 8.2.35 |
5.4.0 到 8.2.34 都在影响范围内。7.4 及更早的分支已经停止维护,没有官方补丁,只能靠隔离、下线或边界防护。
升级后要重启 PHP-FPM 与 Web 服务,确保旧 worker 被替换。只替换文件不重启,残留进程还在跑旧代码。
三、漏洞原理
3.1 引擎层的跨请求状态残留
PHP 处理请求时,如果内存分配失败或发生其他致命错误,部分内部状态的清理与回滚可能没有完成。请求结束后,这块请求内存被释放,但 worker 进程里仍保留着指向它的全局状态。
同一个 worker 处理下一个请求时,这些失效状态会在正常初始化之前被读取或释放,形成跨请求的 use-after-free 与类型混淆。
这里有两个关键点。一是 worker 复用,进程不退出,残骸就一直在。二是初始化顺序,某些用户输入在模块初始化之前就被处理,出错时没有模块来兜底。
3.2 解析早于模块初始化
POST 的读取发生在 sapi_activate() 里。main/SAPI.c 第 463 到 469 行,只要开启了 enable_post_data_reading,并且请求带 POST 数据,就立刻调用 sapi_read_post_data()。
1 | /* main/SAPI.c:463 */ |
之后的顺序在 main/main.c 第 1873 到 1874 行。
1 | php_hash_environment(); |
zend_activate_modules 才是各模块的 RINIT。表单解析发生在模块初始化之前,解析器已经在处理用户输入,会话模块却还没完成初始化。
8.5.11 的行号往后移了,顺序没有变。这个顺序是后面所有问题的前提。
3.3 会话被提前拉起
session.upload_progress.enabled 默认是开着的。前端上传文件时,请求里会带一个 PHP_SESSION_UPLOAD_PROGRESS 字段,PHP 要记录进度,就得先把会话打开。
ext/session/session.c 的 php_session_initialize 做了这几件事。
1 | if (!PS(id) || !ZSTR_VAL(PS(id))[0]) { |
如果请求里没有有效的会话 ID,就新建一个,并把 send_cookie 置 1,意思是稍后要把这个 ID 写进响应头。
会话 ID 的值来自哪里,由 session.use_strict_mode 决定。默认是 0,也就是不校验客户端给的 ID 是否由服务端签发过。客户端可以自己指定一个。
3.4 内存耗尽后的悬空状态
解析器一边读表单,一边往内存里存字段。如果请求体超过内存上限,解析到一半就会耗尽内存,触发 bailout。
这个 bailout 不是事务性的。已经写进全局状态的字段不会回滚,已经分配的内存也不一定按正常顺序释放。具体到会话这块,会出现两个后果。
一是 PS(id) 指向的字符串可能在清理中被释放,指针却还留着。下次再读,就是一块已经不属于它的内存。
二是 PS(send_cookie) 还是 1,但因为前面已经输出过警告,响应头发不出去。php_session_send_cookie 在 session.c:1432 检查 SG(headers_sent),发现已经发送就返回失败。这个待发送的状态留在了进程里,等下一个请求。
3.5 从地址泄漏到函数调用
第二个请求要把一块可控内容放到悬空指针指向的位置。
Zend 的字符串结构在 Zend/zend_types.h 里定义。
1 | struct _zend_string { |
PS(id) 指向的是 val,长度字段 len 在它前面 8 个字节。如果第二个请求能让一个数组的头部正好覆盖这段地址,读 len 的时候,读到的就是数组结构里的某个字段。
构造得当的话,这个字段是 nTableSize,值可以做到 512。
图四:字符串头和数组头重叠之后,长度字段读到的是数组的 nTableSize。
PHP 接着会按这个长度做 URL 编码,代码在 session.c:1447。
1 | e_id = php_url_encode(ZSTR_VAL(PS(id)), ZSTR_LEN(PS(id))); |
它以为会话 ID 有这么长,就把后面这段内存原样编码,写进 Set-Cookie。这段内存里有函数指针,也有堆地址。
有了地址,就能伪造结构。Zend 的数组销毁时会遍历每个 bucket,调用数组自己保存的析构函数。
1 | /* Zend/zend_hash.c,zend_hash_destroy */ |
pDestructor 是函数指针,参数 zv 指向 bucket 里的 zval。把它改成 system,把 zval 的内容改成命令字符串,销毁数组时就等于调用了 system("...")。
走到这个调用点有两条路。一条是未初始化的 bucket,内存不足时解析器中断,bucket 已经挂进哈希表但内容没写,关闭阶段被当成正常 zval,形成假数组。另一条是悬空的 zend_reference,会话的 http_session_vars 留下指针,下一个请求用字符串占回那块内存,引用被伪造成可控结构。
两条路的终点一样,区别在分配器和目标函数。
图二:请求进入 php_request_startup 后,解析、会话初始化、内存耗尽都发生在同一个窗口,间接调用出现在请求关闭阶段。
3.6 默认配置为什么也能触发
很多人看到 post_max_size=8M、memory_limit=128M,会以为请求体不够大,打不出内存耗尽。实际不是。
PHP 每保存一个请求头,就按固定的 256 字节初始容量分配一次。重复的小请求头,线上体积小,占的内存却按条数成倍放大。六个字节的头部,内存里能占到三百多字节,放大约五十倍。
我在官方 php:8.4.26-apache 镜像上实测过这个放大。
1 | 请求体 2,583,400 字节 |
2.5 MB 的请求体把 128M 的内存打爆了,完全在默认 post_max_size=8M 之内。所以默认配置不是安全边界,只是让单次请求的构造更复杂一些。
四、复现
我这次用的是一条已经公开的 ASLR 利用链,跑在官方 php:8.4.26-apache 镜像上。全程只有 HTTP 请求,不用调试器,不用读进程文件。
公开通告里还有一条默认配置的复现,环境是 memory_limit=128M、post_max_size=8M、MaxRequestWorkers=5、display_errors=off,应用是 WordPress 与 phpMyAdmin,196 KB 堆泄露。那条我没有独立复现,这里只记录我跑通的这条。
4.1 环境
| 项目 | 要求 | 我使用的值 |
|---|---|---|
| 主机 | x86_64 Linux,可 sudo | Arch Linux,内核 7.2.7 |
| 内存 | 建议 4 GB 以上空闲 | 5.8 GB |
| Docker | 能拉镜像,能建内部网络 | 29.9.1 |
| 镜像 | php:8.4.26-apache |
Apache 2.4.69,Debian 12 |
| 访问方式 | 仅本机回环 | 127.0.0.1:8089 |
目标容器的配置。
1 | display_errors = On |
Apache 用 prefork,单 worker,关闭 keep-alive。
1 | KeepAlive Off |
图一:实验环境。宿主机通过 Docker 内部网络访问容器,php.ini 和目标页面只读挂载。
4.2 四个阶段
整条链分四步,全部是普通的 multipart POST。
阶段零,轮换 worker。先打一个请求把当前 worker 换掉,保证后面的请求落在同一个新进程里。这一步不影响漏洞,只是为了复现稳定。
阶段一,泄漏堆地址。请求带一千零一个字段,触发 max_input_vars,带一个空的 PHPSESSID,一个上传进度字段,一个文件字段,最后是一个十二兆的大字段。内存在解析中途耗尽,PS(id) 悬空。第二个请求用一个数组占住那块地址,把长度字段改大,reset_id 把后面的堆内容写进 Set-Cookie。
从 Cookie 里读出的指针按 2MB 对齐取多数票,得到 Zend 内存池的基址。
阶段二,泄漏 libphp 地址。做法和阶段一类似,但这次要让 PS(id) 前面的位置落上一个数组头,使 Cookie 的第八到十六字节正好是 pDestructor。这个指针指向 zval_ptr_dtor,减去这个镜像里的偏移,就是 libphp 的基址。
阶段三,执行。用两个地址伪造 zend_reference 和 HashTable,把 pDestructor 改成 zif_system,参数指向命令字符串。请求结束后,Zend 销毁数组,调用发生,命令执行。
4.3 结果
一次跑通的输出。
1 | [*] stage 1: heap leak (attempt 1/5) |
三个地址是同一轮里拿到的。堆基址 0x7f3c6de00000,库基址 0x7f3c6f9fa000,加上偏移算出的系统函数地址 0x7f3c6fe5f370。最后一行的 uid=33 是命令执行的回显,写在容器挂载的目录里,宿主机上能直接读到。
这条链的关键不在地址,而在偏移。下面单独说。
图三:验证执行的四个层次,检查项、产物、调用点、进程变化。
五、偏移是什么,为什么要从镜像里取
程序加载到内存里,函数的位置不是固定数字。Linux 每次启动会给共享库一个随机基址,这叫 ASLR。同一个函数,这次在 0x7f3c 开头,下次可能在 0x7f6f 开头。
函数相对库起点的距离是固定的,这个距离就是偏移。绝对地址等于基址加偏移。
图五:基址每次变,偏移在同一个构建里不变。换一个构建,偏移也会变。
编译器和链接器决定每个函数排在文件里的位置。源码变了、编译选项变了、链接的库变了,函数的大小和顺序都会变,偏移跟着变。版本号一样也可能不同,同一个标签重新构建一次,二进制就可能变。
利用链里有两类偏移。
代码偏移指向函数。比如这个镜像里 zif_system 在 0x465370,伪造的析构函数指针填基址加这个值。填错一个字节,调用会落到指令中间或者别的函数上,进程直接崩,命令不会执行。
数据偏移指向结构。伪造的数组要放在距离内存池基址 0x690d8 的位置。这类偏移错了不一定立刻崩,而是读到错的数据。
确认偏移对不对,有个简单办法。泄漏出来的指针,低 12 位应该等于目标符号偏移的低 12 位。这个镜像里 zval_ptr_dtor 是 0x65eb60,低 12 位是 0xb60。泄漏出来的 pDestructor 如果对不上,脚本会直接判定这条泄漏失败,不会拿去算基址。
取偏移用 nm。zif_system 是本地符号,用不带 -D 的 nm 才能看到。
1 | zif_system 0x465370 |
这两个值不是我猜的,是在容器里对 libphp.so 跑 nm 得到的。不同镜像、不同构建,值都不一样,必须自己取。
我这次用到的完整数据。
1 | chunk base 0x7f3c6de00000 |
六、我踩的两个坑
第一个坑,偏移用错了。现成的实验包里带了一组默认偏移,是另一个构建的。拿它去跑这个镜像,阶段一的堆泄漏能成功,阶段二的库泄漏连续三次失败,脚本报 LIBPHP_LEAK_FAILED,连触发都走不到。
原因就是阶段二的校验。它拿泄漏出来的指针低 12 位跟 zval_ptr_dtor 的偏移低 12 位比,对不上就丢弃。默认偏移的低 12 位是 0xc50,这个镜像是 0xb60,永远对不上。
第二个坑,提取偏移的脚本没写进去。实验包的目录属于容器里的 www-data,宿主机用户没有写权限,offsets.py 报 PermissionError,脚本退回默认值,于是又回到第一个坑。
把目录权限放开,重新对容器跑一次符号提取,拿到 0x465370 和 0x65eb60,再把这两个值传给利用脚本,一次就通了。
这两个坑都不涉及漏洞本身,但足以让人得出错误结论。我第一次跑的时候,看到的是“库地址泄漏三次全失败”,很容易写成“这条链需要调试器”。实际不是,是指差错了一个字节。
七、修复与缓解
升级优先级最高。8.5.12、8.4.27、8.3.36、8.2.35 已修复,升级后重启 FPM 与 Web 服务。7.4 及更早没有补丁,只能隔离或下线。
临时缓解有两条,都有代价。
- PHP-FPM 部署可以关闭
session.upload_progress.enabled,并把memory_limit设为 -1。 - Apache mod_php 部署可以把
memory_limit设为 -1。
memory_limit=-1 等于取消内存上限,触发路径里的内存耗尽不会发生,但也会让恶意请求直接吃满系统内存,放大拒绝服务风险。这是应急手段,升级后要改回来。
WAF 规则要改。很多规则按 Content-Type 里的 multipart/form-data; 匹配,PHP 还认逗号和空格。
1 | multipart/form-data; boundary=B |
按分号写死的规则,后两种直接过去。
disable_functions 挡不住这条链。它是在函数表里标记禁用,内存破坏直接改函数指针跳到目标地址,不经过函数表。实验环境的配置里 system、exec、shell_exec 全被禁用,命令照样执行。
八、检测
单次请求带超过一千个字段、超大的 multipart 字段、异常的 PHPSESSID,同时伴随 5xx 或 worker 重启,这几个信号放在一起看。单独看任何一个都容易误报。
流量侧可以关注重复度极高的小请求头、Content-Type 用逗号或空格分隔的 multipart、以及同一来源短时间内反复触发 500 的请求。
主机侧看 PHP 进程异常退出、FPM 的 worker 频繁重启、Web 目录出现无法解释的新文件。这些信号要结合访问日志一起判断。
九、参考
PHP 官方发布页:8.5.12、8.4.27、8.3.36、8.2.35。
CVE 编号:CVE-2026-109618、CVE-2026-109619。
公开技术分析:calif.io 的 php2shell 分析,Wiz 的博客文章。
利用脚本来自 9bie 的 ASLR PoC,实验编排来自 jf0x3f 的 docker lab。本文的偏移、命令输出和复现结果为本机实测。











