PHP2Shell 代码分析:一个没初始化的桶,把 OOM 变成 system()
原创
Kratos
Kratos
Kratos Sec
2026年10月22日 18:40
北京
去阅读
昨晚 22:00,PHP 按时放出了紧急安全 release,两个 9.2 分 RCE 的根因随之公开——CVE-2026-109618 和 CVE-2026-109619,Datadog 安全实验室起了个名字叫 PHP2Shell。本号昨天发了预警(当时根因还在 embargo),今天补上代码层拆解。先说三个预警时未知、现在确认的事实:第一,漏洞确实在 Zend 引擎的哈希表代码里,触发点是内存耗尽(OOM)处理把初始化做到一半的请求数据丢给了清理逻辑;第二,昨天提到的 session 线索属实,109619 的攻击面正是 session.upload_progress;第三,也是最不舒服的一条——AWS 的蜜罐网络 MadPot 捕获到10 月 17 日的在野利用,比补丁早四天,FBI 已经发了公开通告。这不是\"补丁发布后才开始防\"的局,是从上周就该查日志的局。
0x0 概述
| 项目 | CVE-2026-109618 | CVE-2026-109619 |
|---|---|---|
| 类型 | OOM 中断导致的内存破坏(计数时序缺陷) | 跨请求过期状态 UAF |
| 位置 | Zend/zend_hash.c``_zend_hash_str_add_or_update_i() |
请求级全局状态未复位(session 上传进度等) |
| 认证 | 无需认证,一个 POST | 无需认证,同 worker 连续请求 |
| 利用环境 | 已复现:mod\_php / FPM 等全部 HTTP SAPI | mod\_php 确认,FPM 需应用配合一次后置操作 |
| 在野利用 | 10 月 17 日起已捕获(AWS MadPot) | 同批,FBI 已发通告 |
受影响与修复版本(Datadog 确认口径):8.2 < 8.2.35、8.3 < 8.3.36、8.4 < 8.4.27、8.5 < 8.5.12。7.4 / 8.0 / 8.1 同样受影响但只提供源码 backport——EOL 分支没有官方发行包,昨天说的\"存量暴露面\"现在坐实了。
0x1 PoC 长什么样
Datadog 在 PHP 8.5.11(Debian 12 ARM64,Apache prefork + mod\_php,memory_limit=16M、post_max_size=64M)上完整复现,请求就是一个未认证的 POST,表单形态:
=<276字节的二进制值>&a=1&<约3.5MB的超长字段名>[x]=1
三段各司其职:第一段是堆布局的\"种子\",第二段触发内存复用,第三段的一根巨长字段名把内存配额顶爆、在关键位置留下一个半初始化的结构。最终效果:HTTP 200,约 3.1 秒返回,worker 不崩,命令以 www-data 身份执行。注意这个形态对蓝队意味着什么——成功利用的请求在访问日志里就是个正常 200,没有 500、没有崩溃签名。
0x2 109618 根因:计数在分配之前
问题出在 Zend 引擎哈希表插入路径的一段时序上。zend_hash.c 里 _zend_hash_str_add_or_update_i() 处理字符串键插入的核心序列(8.5.11,约 960-964 行):
idx = ht->nNumUsed++; // ① 先把\"已用槽位\"计数加一
ht->nNumOfElements++; // ② 元素计数加一
p = ht->arData + idx; // ③ 指向这个桶
p->key = key = zend_string_init(str, len, ...); // ④ 才分配并复制键
顺序是致命的:①② 先改了账本,④ 才真正干活。PHP 解析 POST 表单时,每个字段名都会作为字符串键插进 $_POST 的 HashTable。如果第 ④ 步的 zend_string_init() 分配内存时超出 memory_limit,PHP 触发 OOM 致命错误,longjmp() 直接跳出当前上下文——插入半途而废,但 ①② 已经把账记上了:这个桶被计入元素总数,key 和值却从没写进去,里面是上一次使用这块内存时留下的旧字节。
错误处理的设计本身有它的道理:OOM 时用 longjmp() 弃船逃生,别在内存耗尽时再做任何可能再次分配内存的事。问题在弃船之后——请求收尾阶段 PHP 要销毁 $_POST,遍历每一个\"账面上的\"桶,把 key 当 zend_string*、把值当合法 zval 去析构。于是那个半初始化桶里的堆上残留字节被当作合法的 PHP 值结构来解释。残留字节谁说了算?上一个用这块内存的请求说了算——也就是攻击者自己的前一个表单字段说了算。
Datadog 对这个漏洞类的总结很准:内存错误在内部状态只初始化了一半时中断操作,而清理逻辑把这份残缺状态当有效数据处理。CVE-2026-109619 是同一个思想在另一个维度的展开(见 0x4)。
0x3 利用工程:三步搭一个假数组
理解了\"半初始化桶解释旧字节\",PoC 的三段式就好读了。
第一步,种字节。=<276字节二进制值>:一个空名字字段,值是攻击者精心构造的二进制串。276 字节加上对象头正好落进 320 字节的 size class——和后续 HashTable 桶数组分配用的同一个小对象规格。这个值随后被释放,但字节留在堆上。
第二步,占坑。a=1:一个再普通不过的字段,但它促使引擎分配新的哈希桶数组,恰好复用第一步释放的那块内存。至此,未来会被解释的那个桶的位置,内容已经是攻击者写的了——一个伪造的数组结构,其析构回调指针指向 system(),参数指向命令字符串。
第三步,引爆。超长字段名[x]=1:约 3.5MB 的字段名(带数组语法走字符串键插入路径)在复制键时把 16M 的配额顶爆,OOM 中断留下那个\"账面上存在、内容是假数组\"的桶。请求收尾,销毁逻辑读到假数组,析构函数一路调用到 system()。
ASLR 怎么过: 伪造结构里要写进程内地址。Wiz 演示了通过 PHPSESSID cookie 泄露内存布局的路子——具体机制他们还没放全细节,但结论明确:ASLR 是成本问题,不是门槛问题。VulnCheck 的可利用性评估口径一致:ARM64 已验证,x86 无 ASLR 已验证,x86 带 ASLR 需要泄露原语配合。
一个反直觉的缓解:memory_limit=-1(不设限)反而阻断当前公开利用路径——没有限额就没有 OOM 中断,第三步的\"恰到好处地失败\"做不出来了。但这是意外副作用不是修复,补丁才是。
0x4 109619:状态活过了请求的生命周期
第二个洞的病理相同、位置不同:本该随请求生灭的状态,活过了请求。
PHP 内部有不少 worker 级全局字段,会在请求期间临时指向请求私有的内存。正常流程是请求结束时复位。109619 的场景是:请求处理上传时遭遇致命错误中断(又是那条 longjmp() 弃船路径),底层请求内存被释放,但指向它的全局状态指针没清——下一个请求打到同一个 worker 上,初始化之前就读了这个悬垂指针,UAF 成立。
两个已确认可利用的场景:
- session 上传进度(重点,就是昨天预警里那条线索):
session.upload_progress.enabled默认开启,而且不调用session_start()也生效——很多开发者以为没用 session 就没这功能,实际它在上传处理路径里始终活动。致命错误中断后 session/解码器状态残留,同 worker 的下一次上传读到脏状态。mod\_php 和 FPM 都确认 RCE,FPM 多一个条件:应用要恰好执行一次适配的后置操作; - 致命错误回溯(仅 8.5):
fatal_error_backtraces是 8.5 的新特性,fatal error 的回溯状态在关停后残留,后续配置诊断读未初始化数据,mod\_php 上确认 RCE。
两个场景都要求请求粘性(同一 worker),FPM 的 pm.static/低并发池、mod\_php prefork 模型天然满足。对应的部分缓解:session.upload_progress.enabled=0、8.5 上 fatal_error_backtraces=0——有用的止血带,但同样不是修复。
0x5 补丁怎么修的,以及为什么说没修完
修复 commit(31c0ea7d)分三层:
- 软内存限制:接近限额时把致命错误推迟到安全点再抛,让进行中的操作完成初始化——堵住\"半初始化\"的产生;
- 硬限制直接
_exit(137):真到山穷水尽,进程立刻退出,跳过全部清理逻辑——宁可死得干脆,不带着脏状态跑析构; - 请求级全局状态(错误/异常、序列化、session)在复用前复位——堵 109619。
值得所有 C 项目维护者读的是 Datadog 的保留意见:\\zend_hash.c 里\"先计数后分配\"的时序在 8.5.12 里依然原样\\,修的只是错误处理路径。他们明确说这是个高风险模式,后续大概率还会孵出同类漏洞。这不是批评,是事实——账本和数据的一致性约束没有从结构上建立,只是不再让错误路径去撞它。
0x6 检测:三层特征
Datadog 给的检测框架直接可用:
请求层:application/x-www-form-urlencoded 正文里出现 MB 级的超长字段名、字段值含原始二进制/空字节。先确认 WAF 是否真的解析大 body——很多规则只看前 8KB,3.5MB 的字段名轻松溜过。
日志层: PHP 错误日志里 Allowed memory size ... exhausted 且位置是 \\Unknown on line 0\\(脚本还没开始跑就在初始化阶段爆了,正常业务 OOM 会带具体文件行号);Apache 的 AH00052: child pid ... exit signal Segmentation fault (11)——那是利用者 ASLR 猜错了在撞运气,猜错了反而帮你报警。
进程层(最硬):httpd / php-fpm worker 派生 sh、bash。再强调一次:成功的利用不崩 worker、返回 200,进程链是唯一可靠的执行证据。三层关联,单看任何一层都有误报/漏报。
排查窗口直接拉到 10 月 17 日之前——在野利用比补丁早四天,这不是从昨晚开始查的事。
0x7 行动项更新
昨天清单基础上增补:
- 升级:8.2.35 / 8.3.36 / 8.4.27 / 8.5.12,昨晚 22:00 已全部可用,容器镜像重新 pull 部署;
- EOL 分支(7.4 / 8.0 / 8.1):只有源码 backport,发行版用户等 RHEL/Ubuntu 的回移包,自建的要么自己编译要么收暴露面;
- 临时缓解按序叠加:
memory_limit=-1+ 前置层做请求体积和内存限制(注意 Datadog 特别提醒:别用RLIMIT_AS/RLIMIT_DATA,行为不同);session.upload_progress.enabled=0;8.5 加fatal_error_backtraces=0。每个都是部分的,叠加也只是降低可利用性; - WAF 大 body 规则补上(0x6 的请求特征),但要清楚昨天 Sansec 的判断依然成立:利用发生在应用代码之前,WAF 只是延迟,不是防御;
- 回溯排查:10 月 17 日前的访问日志按 0x6 特征过一遍,重点
Unknown on line 0的 OOM 记录和 worker 派生 shell 的进程审计记录。
参考
- https://securitylabs.datadoghq.com/articles/php2shell-critical-vulnerabilities-in-php
- https://watchtowr.com/intelligence/php-cve-2026-109618-faq
- https://www.php.net/index.php#2026-10-20-1
免责声明:本文仅用于安全研究与学习,未经授权不得用于任何非法渗透测试活动。
版权声明
本站仅做备份收录,仅供研究与教学参考之用。
读者将信息用于其他用途的,全部法律及连带责任由读者自行承担,本站不承担任何责任。










评论