一、漏洞概要

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
2
3
4
/* main/SAPI.c:463 */
if (PG(enable_post_data_reading) && SG(request_info).content_type) {
sapi_read_post_data();
}

之后的顺序在 main/main.c 第 1873 到 1874 行。

1
2
php_hash_environment();
zend_activate_modules();

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
2
3
4
5
if (!PS(id) || !ZSTR_VAL(PS(id))[0]) {
PS(id) = PS(mod)->s_create_sid(&PS(mod_data));
...
PS(send_cookie) = 1; /* session.c:457 */
}

如果请求里没有有效的会话 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
2
3
4
5
6
struct _zend_string {
zend_refcounted_h gc;
zend_ulong h;
size_t len;
char val[1];
};

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
2
3
4
/* Zend/zend_hash.c,zend_hash_destroy */
if (ht->pDestructor) {
ht->pDestructor(zv);
}

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
3
4
请求体     2,583,400 字节
memory_limit 134,217,728 字节
结果 Allowed memory size exhausted (tried to allocate 4096 bytes)
worker 存活,无 Set-Cookie

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
2
3
4
5
6
7
8
9
10
11
12
13
display_errors = On
display_startup_errors = On
error_reporting = E_ALL

memory_limit = 8M
post_max_size = 64M
upload_max_filesize = 64M

session.save_path = "/tmp"
session.use_strict_mode = 0
session.use_cookies = 1
session.upload_progress.enabled = On
session.upload_progress.cleanup = On

Apache 用 prefork,单 worker,关闭 keep-alive。

1
2
3
4
5
6
7
8
9
10
KeepAlive Off

<IfModule mpm_prefork_module>
StartServers 1
MinSpareServers 1
MaxSpareServers 1
MaxRequestWorkers 1
ServerLimit 1
MaxConnectionsPerChild 0
</IfModule>

图一

图一:实验环境。宿主机通过 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
2
3
4
5
6
7
[*] stage 1: heap leak (attempt 1/5)
[+] chunk base = 0x7f3c6de00000
[*] stage 2: libphp leak (attempt 1/5)
[+] libphp base = 0x7f3c6f9fa000
[*] stage 3: HTDATA=0x7f3c6de690d8 ZIF=0x7f3c6fe5f370
[+] W3 attempt 1
PROOF: uid=33(www-data) gid=33(www-data) groups=33(www-data)

三个地址是同一轮里拿到的。堆基址 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
2
zif_system       0x465370
zval_ptr_dtor 0x65eb60

这两个值不是我猜的,是在容器里对 libphp.so 跑 nm 得到的。不同镜像、不同构建,值都不一样,必须自己取。

我这次用到的完整数据。

1
2
3
4
5
6
7
chunk base     0x7f3c6de00000
libphp base 0x7f3c6f9fa000
HTDATA 0x7f3c6de690d8
ZIF 0x7f3c6fe5f370
zif 偏移 0x465370
dtor 偏移 0x65eb60
htdata 偏移 0x690d8

六、我踩的两个坑

第一个坑,偏移用错了。现成的实验包里带了一组默认偏移,是另一个构建的。拿它去跑这个镜像,阶段一的堆泄漏能成功,阶段二的库泄漏连续三次失败,脚本报 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
2
3
multipart/form-data; boundary=B
multipart/form-data, boundary=B
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。本文的偏移、命令输出和复现结果为本机实测。