PHP2Shell代码分析:一个没初始化的桶,把OOM变成system()"

admin 2026-10-24 14:40:43 网络安全文章 来源:ZONE.CI 全球网 0 阅读模式

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)分三层:

  1. 软内存限制:接近限额时把致命错误推迟到安全点再抛,让进行中的操作完成初始化——堵住\"半初始化\"的产生;
  2. 硬限制直接 _exit(137):真到山穷水尽,进程立刻退出,跳过全部清理逻辑——宁可死得干脆,不带着脏状态跑析构;
  3. 请求级全局状态(错误/异常、序列化、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 行动项更新

昨天清单基础上增补:

  1. 升级:8.2.35 / 8.3.36 / 8.4.27 / 8.5.12,昨晚 22:00 已全部可用,容器镜像重新 pull 部署;
  2. EOL 分支(7.4 / 8.0 / 8.1):只有源码 backport,发行版用户等 RHEL/Ubuntu 的回移包,自建的要么自己编译要么收暴露面;
  3. 临时缓解按序叠加:memory_limit=-1 + 前置层做请求体积和内存限制(注意 Datadog 特别提醒:别用 RLIMIT_AS/RLIMIT_DATA,行为不同);session.upload_progress.enabled=0;8.5 加 fatal_error_backtraces=0。每个都是部分的,叠加也只是降低可利用性;
  4. WAF 大 body 规则补上(0x6 的请求特征),但要清楚昨天 Sansec 的判断依然成立:利用发生在应用代码之前,WAF 只是延迟,不是防御;
  5. 回溯排查: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

免责声明:本文仅用于安全研究与学习,未经授权不得用于任何非法渗透测试活动。

    PHP2Shell:PHP引擎级双UAF,从5.4潜伏到8.5" 网络安全文章

    PHP2Shell:PHP引擎级双UAF,从5.4潜伏到8.5"

    本文分析php引擎两个UAF漏洞CVE-2026-109618与CVE-2026-109619,影响php5.4.0至8.5.11所有版本,修复版本为8.2.35/8.3.36/8.4.27/8.5.12。618利用session.upload_progress与LFI实现RCE,619通过unserialize的var_destroyUAF触发POP链。建议升级至修复版本,老版本无补丁需迁移或隔离。
    Gartner发布2027年十大战略技术趋势" 网络安全文章

    Gartner发布2027年十大战略技术趋势"

    Gartner发布2027年十大战略技术趋势,涵盖大型定量模型、语音推理模型、世界模型、AI上下文平台、编码智能体框架、AI智能体身份、自主式暴露修复、主权技术、可抛弃型应用及湿件即服务。强调企业需从被动观察者转变为主动构建者,应对AI系统架构风险,关注智能体身份安全与自主修复能力,并建议企业关注主权技术以防供应商锁定。
    评论:0   参与:  0