PHP 远程命令执行漏洞刷屏:危害性没你想的那么大,安全圈成娱乐圈,什么人都来混
原创
网络安全透视镜
网络安全透视镜
网络安全透视镜
2026年10月22日 09:19
江苏
去阅读
QUOTE
漏洞是通用的, PoC 不是通用的 。这两件事,被太多人混成了一件。
—— 网络安全透视镜
10 月 21 日,PHP 一次性发布四个安全版本,连同两个 CVSS 9.2 的高危漏洞一起把 PHP2Shell 推上了热搜。官方定性很直接:未认证远程代码执行。
于是各种「一个 poc 通杀」的截图开始流传。但真正在实验室把命令执行跑通的人会发现,事情远比想象中复杂:触发漏洞很容易,稳定拿 shell 却极其苛刻。
在供应链安全漏洞管理中,新的漏洞出现,相关方做升级修复本是正常现象
推特(x)上高市早苗不知道抽什么风,政治圈混不下去了?也开始混安全圈,发了一条推特(x), 这事一下子就火了。
这篇就把完整利用条件一条条说明白,顺便说清楚这个漏洞真正的危害边界在哪里。
本文看点
01
两个 CVE 怎么配合
02
完整利用条件清单
03
为什么没有通用 PoC
01
VULNERABILITY
漏洞是什么:两个 CVE 各管一段
PHP 8.5.12、8.4.27、8.3.36、8.2.35 已发布,修复了 CVE-2026-109618 与 CVE-2026-109619,两个 CVSS v4.0 评分都是 9.2。这两个漏洞是同源的,需要配合起来看。
CVE-2026-109618 管的是「异常退出留下脏状态」。PHP 用 memory\\_limit 限制单请求内存,内存耗尽时走 zend\\_mm\\_safe\\_error() → zend\\_error\\_noreturn() → zend\\_bailout() 这条路。zend\\_bailout() 本质上是一次 longjmp,直接从当前任意 C 调用栈跳回请求顶层,中间那些还没做完的清理步骤就被跳过了——构造函数没跑完、链表没挂上、全局指针没复位。
CVE-2026-109619 管的是「脏状态被下一个请求用上」。mod\\_php 和 php-fpm 的 worker 都是长驻进程,session 模块的 PS(id)、PS(http\\_session\\_vars) 是进程级全局变量。如果上一个请求是异常结束的,这些全局变量没被复位成 NULL,下一个请求就会把它当成有效对象去析构:
...c
zval\_ptr\_dtor(PS(http\_session\_vars))
→ 访问上一请求留下的悬空指针
→ Use After Free
关键的连接点是 session.upload\\_progress。这个特性从 PHP 5.4 起就默认开启,multipart 解析回调会在你的应用脚本还没执行之前就跑起来。所以目标页面哪怕只有一行 `,不需要session\_start()`,不需要任何上传逻辑,一样会被卷进来。
换句话说:这不是「上传一个文件就执行代码」,而是「构造一个请求把 PHP 自己的状态搅乱」。
02
EXPLOIT CONDITIONS
完整利用条件:触发容易,拿 shell 很难
利用条件必须拆成两层看。触发层很通用,拿命令执行层一点都不通用。
第一层:能触发漏洞的条件
这一层其实相当通用,满足下面几条就能把漏洞打出来:
1
PHP 没升到修复版本。
2
session.upload\_progress.enabled = On(默认就是开,必须在 php.ini 或 php\_admin\_value 里关掉,ini\_set() 来不及生效)。
3
memory\_limit 是有限值。默认 128M 也能触发,实验环境用 8M 提高稳定性。
4
post\_max\_size 大于用来撑爆内存的那个字段。
5
攻击者能向任意 .php 页面发 HTTP POST。
6
两次请求命中同一个 worker。
打出来之后的现象是:第一个请求返回 Fatal error: Allowed memory size of 8388608 bytes exhausted in Unknown on line 0。注意 Unknown on line 0——错误发生在用户脚本开始之前。第二个请求可能看到非法 PHPSESSID、Failed to read session data、worker 退出、zend\\_mm\\_heap corrupted 或者干脆 SIGSEGV。
注意
这些现象能证明漏洞被触发了,但证明不了命令执行。看到 worker 崩溃就宣布 RCE,是这类分析里最常见的误判。
第二层:能稳定拿到 RCE 的条件
这一层就一点都不通用了。要把 UAF 变成 system() 调用,下面这些条件缺一不可,而且每一项都对具体构建高度敏感:
| 条件 | 实验室取值 | 不满足会怎样 |
|---|---|---|
| 精确镜像 | php:8.4.26-apache-bookworm | 堆喷尺寸全部失效 |
| SAPI | mod\_php + mpm\_prefork | FPM 得另写一条利用链 |
| Worker 数 | 1 | 无法稳定完成 A→B 同进程利用 |
| display\_errors | On | Cookie 泄漏链失效 |
| max\_input\_vars | 默认 1000 | 超限 warning 的触发时机变了 |
| memory\_limit | 8M | OOM 分配点漂移,对象落点改变 |
| session.use\_strict\_mode | Off | 带 \x00 的 PHPSESSID 逼不出换 ID |
| libphp.so 符号偏移 | zif\_system = 0x45a3b0 | 阶段 3 可能调错函数 |
| 堆布局常数 | HTDATA = +0x690d8、filler = 841 | 重占失败,通常只是崩溃 |
| 用户态地址范围 | 本机映射约 0x79... | 泄漏解析器会判定未泄漏 |
| 命令长度 | 有效命令不超过 14 字节 | 得换别的长度控制方式 |
还有一个经常被忽略的:RCE 需要两类地址泄漏。一类是 Zend MM 的 chunk 基址(约 2 MiB 对齐),通过构造超长 zend\\_string 让 php\\_url\\_encode(PS(id)) 越界读,从 Set-Cookie: PHPSESSID= 里捞出来;另一类是 libphp.so 的基址,靠读 zval\\_ptr\\_dtor 的地址减去目标构建的文件偏移得到。任一没拿到,伪造出来的 HashTable 就只是指向随机内存,表现出来是 worker 崩溃,不是命令执行。
复现环境长什么样
一个能跑通的靶场,PHP 配置是这些:
...ini
display\_errors = On
memory\_limit = 8M
post\_max\_size = 64M
upload\_max\_filesize = 64M
max\_input\_vars = 1000
session.save\_path = \"/tmp\"
session.use\_strict\_mode = 0
session.upload\_progress.enabled = On
Apache 侧要单 worker:
...apache
KeepAlive Off
StartServers 1
MinSpareServers 1
MaxSpareServers 1
MaxRequestWorkers 1
ServerLimit 1
请求 A 是一个带 PHP\\_SESSION\\_UPLOAD\\_PROGRESS 字段的 multipart POST,再加一个约 12 MiB 的字段把内存撑爆,目标是 `。请求 B 用 841 个 48 字节字段加 79 字节伪造块去做堆重占,把伪造的ZSTR\_LEN设成0x1000,靠越界读从响应里的Set-Cookie泄漏出 chunk 基址。最后一个阶段用 15 字节 carrier 改写悬空 reference 的val.value,再塞进携带伪造HashTable` 的字段,让析构链走成:
...c
php\_session\_track\_init()
→ zval\_ptr\_dtor(PS(http\_session\_vars))
→ zend\_reference\_destroy(R0)
→ zend\_array\_destroy(fake HT)
→ zend\_hash\_destroy(fake HT)
→ pDestructor(&bucket->val) = zif\_system
→ system(\"id>/out/p1\")
注意那个 fake HashTable 是依赖地址和堆布局的二进制结构,不是一个能抽象成「固定 Raw HTTP 包」的东西。直接把 stage3 的 body 复制到别的机器上,多数情况下只会得到一次纯崩溃。
03
WHY NO UNIVERSAL POC
为什么没有一个包通杀
这是最容易被误解的地方。PHP2Shell 是堆 UAF,不是「把 payload 交给某个业务函数执行」那种 RCE。
普通应用层 RCE 的稳定原语是字符串、对象图、业务逻辑,跨版本换个 payload 常常还能用。PHP2Shell 的稳定原语是悬空指针和堆块尺寸——被释放对象的尺寸、下一次分配会不会重用同一个 bin、伪造对象字段的偏移、Zend MM 的堆布局、libphp.so 的符号偏移、ASLR 泄漏的结果、PHP 与 Apache 的初始化顺序,全都要提前知道。
更扎心的是,同一个 PHP 版本也不通用。同样是 8.4.26,两个 Debian 大版本的偏移是这样的:
| 镜像 | zif\_system | zval\_ptr\_dtor |
|---|---|---|
| Debian 13 Trixie | 0x465370 | 0x65eb60 |
| Debian 12 Bookworm | 0x45a3b0 | 0x64eb60 |
而堆喷参数 841、406、+0x690d8 是按 Bookworm 标定的,直接搬到 Trixie 上,5 次尝试全部 stage1 HEAP\\_LEAK\\_FAILED。更有意思的是,即使在同一 Bookworm 镜像上,把脚本支持的指针窗口从 0x7f... 放宽到 0x55...–0x80...,第一次尝试就直接 RCE 成功。
「漏洞触发面是通用的,稳定 RCE 只能针对精确构建做定点利用。」
能通用的是验证包,不能通用的是 RCE 包
04
REMEDIATION
修复与缓解建议
首选是升级到 8.5.12 / 8.4.27 / 8.3.36 / 8.2.35,或者装发行版厂商的 backport。发行版的 backport 可能保持原版本号,所以别只看版本字符串,去 changelog 里确认有没有那两个 GHSA 编号。升级完必须重启所有 PHP worker、FPM pool 和 Apache/mod\_php 进程。
官方补丁的核心改动是:软 OOM 改成写 EG(delayed\\_oom) 并在 VM 安全点执行 zend\\_throw\\_unwind\\_exit(),硬 OOM 走新的 memory\\_hard\\_limit\\_percent,超过就 \\_exit(137) 直接退进程,不再在损坏状态下继续 shutdown;session 侧则在 MULTIPART\\_EVENT\\_START 时就立刻 php\\_rinit\\_session\\_globals() 复位 PS(id) 和 PS(http\\_session\\_vars),解析阶段反序列化只生成 \\_\\_PHP\\_Incomplete\\_Class,不再继续执行不安全回调。
暂时升不动的话,缓解手段按优先级排:
1
session.upload\_progress.enabled = 0,这条能断掉已展示的 session 链。
2
反向代理直接丢弃 multipart/form-data 请求(不上传文件的应用适用)。
3
缩短 worker 生命周期,FPM 侧 pm.max\_requests = 1。
4
限制请求体大小,post\_max\_size 收紧。
5
放宽内存限制 memory\_limit = -1,减少 OOM 触发路径。
别搞反了
不要用 RLIMIT\_AS / RLIMIT\_DATA 去卡内存。那会导致 mmap 失败,反而把进程推进那条脆弱的 OOM/bailout 路径。
05
DETECTION
排查指标与应急处置
如果线上出现下面这些现象,建议按「可能已失陷」处理:
1
zend\_mm\_heap corrupted
2
Allowed memory size ... exhausted in Unknown on line 0
3
异常超长的 Set-Cookie: PHPSESSID=
4
worker 频繁 SIGABRT 或 SIGSEGV
5
session 文件读取异常、非法 session ID
处置动作:轮换密钥凭据、排查 webshell、查 Apache/PHP worker 日志和反向代理访问日志、确认所有 worker 都已重启、核对 changelog 里的 backport 是否真的包含两个 GHSA。
∞
CONCLUSION
结论
PHP2Shell 的漏洞面是内核级的,默认配置就能触发 UAF 和状态损坏,这部分确实该重视;但要把它变成稳定 RCE,需要同 worker、精确的堆布局、精确的 libphp.so 偏移、成功的地址泄漏,以及对特定 INI 和 ELF 构建的预先标定。
最可靠的动作始终是那一条:升级,然后重启。
END
我是 网络安全透视镜,专注网络与信息安全领域。
如果你觉得今天这篇有收获,欢迎 点赞、在看、转发 三连,我们下篇见。
版权声明
本站仅做备份收录,仅供研究与教学参考之用。
读者将信息用于其他用途的,全部法律及连带责任由读者自行承担,本站不承担任何责任。










评论