2026 年 10 月 21 日,php2shell 的利用细节公开。它把 PHP 的 session.upload_progress 变成了一个写入点:上传文件的原始文件名没有经过处理,被写进会话文件;只要应用里还有一个本地文件包含,攻击者就能让 include 去执行文件名里的 PHP 代码。

我照着 kuwoo/php2shell-Lab 把 Chain A 跑了一遍。这个靶场只覆盖三条利用链里最直观的一条,但它足够把问题讲清楚:这不是某个函数少转义了一个字符,而是“不可信输入被写进可执行文件”和“应用自己提供了 include”叠在一起。

本文只用于本地靶场和授权测试。不要在未授权的站点上验证。

漏洞速览

  • 编号:CVE-2026-109618 / CVE-2026-109619
  • 类型:session.upload_progress 文件名注入,配合 LFI 导致远程代码执行
  • 影响:PHP 5.4.0 到 8.5.11
  • 修复:8.2.35、8.3.36、8.4.27、8.5.12
  • 评分:CVSS 4.0 9.2
  • 前置条件:应用存在可控的 include 或 require(Chain A)

先把结论说清楚:php2shell 不是单一漏洞,而是一组利用链。靶场覆盖的 Chain A 需要 LFI;另外两条链分别涉及反序列化后的 UAF 和 session handler 与 POP gadget 的组合,利用门槛更高,也不是这个靶场能演示的。

会话文件里为什么会有文件名

PHP 处理文件上传时,如果表单里有 PHP_SESSION_UPLOAD_PROGRESS 字段,会启用 upload_progress 机制,把上传进度写进当前会话。会话默认以文件形式保存在 save_path 下,文件名是 sess_<session_id>,内容是序列化后的键值对。

问题在于:进度数据里的 filename 来自上传文件的原始文件名,PHP 没有把它当成“只能展示的字符串”做转义,而是直接写进了会话文件。会话文件不是 PHP 代码,但它是一个普通文件;只要应用后面会 include 它,文件里的 PHP 标签就会被当作代码执行。

靶场把 save_path 固定到 /tmp/sessions,并给了目录 777 权限,所以会话文件路径很好预测:/tmp/sessions/sess_<sid>。真实环境不一定这么巧,但“会话文件可写、路径可猜、应用有 LFI”这三点凑齐,链就成立了。

第一步:把 session id 固定下来

正常情况 PHP 只从 Cookie 里读 PHPSESSID,session.use_only_cookies=1 会拒绝 URL 里的 session id。但公开分析指出,upload_progress 的处理路径会从请求体里接受 PHPSESSID,这就给了一个绕过点。

攻击者先自己指定一个 session id,再发上传请求。服务端用这个 id 创建会话文件,攻击者也就知道了文件名。靶场的 PoC 把 PHPSESSID 放在 multipart 表单字段里,而不是 Cookie 里。

第二步:把 PHP 代码放进文件名

upload_progress 会把上传文件的 filename 写进会话文件。于是构造一个带 PHP 标签的文件名:

<?php system($_GET['c']); ?>

再带上 PHP_SESSION_UPLOAD_PROGRESS 字段和任意文件内容,发一个 multipart 请求。会话文件里会出现类似这样的内容:

upload_progress_chainA|a:5:{s:10:"start_time";i:...;s:14:"content_length";i:...;s:15:"bytes_processed";i:...;s:4:"done";b:0;s:8:"files";a:1:{i:0;a:5:{s:10:"field_name";s:1:"f";s:4:"name";s:31:"<?php system($_GET['c']); ?>";...}}}

文件名里的尖括号和问号没有被转义,PHP 标签原样躺在会话文件里。

第三步:让 include 去执行它

靶场的 index.php 有一个很直白的 LFI:

<?php
$page = $_GET['page'] ?? '';
if ($page !== '') {
    include($page);
}

请求:

GET /index.php?page=/tmp/sessions/sess_<sid>&c=id

include 把会话文件当 PHP 解析,文件名里的 system($_GET['c']) 执行,命令回显在响应里。靶场里看到的是 uid=33(www-data),不是 root;Apache 以 www-data 运行,拿到的是 Web 进程权限。

为什么靶场要关掉 cleanup

这是最容易误解的地方。PHP 的 session.upload_progress.cleanup 默认是 On:上传完成后,进度数据会被清掉。也就是说,真实攻击不能慢悠悠地上传完再包含,得在上传进行中抢时间窗口。

靶场的 docker-compose 把 cleanup 设成 0,让会话文件里的恶意文件名稳定保留,方便复现。这不是说真实环境也这么简单;它只是把“竞态”这个变量拿掉了。真正评估时,cleanup=On 只会提高利用难度,不会让漏洞消失。

三条链,别混在一起

公开分析把 php2shell 拆成三条链:

  • Chain A:session.upload_progress 文件名注入加 LFI。靶场复现的是这条,前置条件是应用有可控 include。
  • Chain B:反序列化会话数据触发的 UAF,属于内存破坏,通常需要 ASAN 才能稳定观察。
  • Chain C:session handler 与 POP gadget 的组合,需要应用里有可用的 gadget 链。

三条链的入口都和 session 有关,但利用方式、前置条件、影响范围不一样。看到“CVSS 9.2”就以为随便一个 PHP 站点都能直接 RCE,是误读。

怎么发现

如果应用没有 LFI,Chain A 走不通;但攻击者仍可能用 upload_progress 做别的事。防守方可以关注几个信号:

  • 请求体里出现 PHPSESSID 字段,尤其是和 multipart 上传一起出现
  • 上传文件名里包含 PHP 标签、system、base64_decode 这类片段
  • 会话目录里出现内容异常、带 PHP 标签的 sess_ 文件
  • 响应突然 Set-Cookie: PHPSESSID,而请求本身没有带 session id;这只能当侧信道,不是铁证
  • WAF 日志里 multipart filename 和 PHP_SESSION_UPLOAD_PROGRESS 同时出现

日志要看原始 multipart,不要只看解码后的字段名;很多 WAF 默认不检查 filename。

怎么修

优先级从高到低:

  • 升级 PHP 到 8.2.35、8.3.36、8.4.27、8.5.12 或更高。已经 EOL 的老版本,尽快迁到受支持分支。
  • 如果暂时不能升级,关闭 session.upload_progress.enabled。这能直接去掉 Chain A 的写入点,但它不是完整修复,不能替代补丁。
  • 修掉 LFI。include 和 require 的路径必须走白名单,不要让用户输入决定文件路径;这是 Chain A 能落地的前提。
  • 不要让请求体里的 PHPSESSID 生效。检查应用和框架的 session 配置,确认 session.use_only_cookies 打开,并且没有额外逻辑从 body 取 session id。
  • 会话目录不要放在 Web 根目录下,权限收紧;但这只能增加难度,不能替代前四条。

还有一个常见误区:只上 WAF。文件名注入的 payload 可以编码、分段、换字段名,WAF 规则很容易漏;根因在 PHP 和应用的 include,得在代码和版本上解决。

最后

这个靶场最有价值的地方,不是给了一个“一键 RCE”的脚本,而是把一条链拆成了可观察的几步:会话 id 怎么固定、文件名怎么写进去、include 怎么触发。理解这三步之后,再看 Chain B 和 Chain C,就不会把“PHP 版本漏洞”和“应用 LFI”混为一谈。

只在自己的靶场或授权环境里跑。公网上的 PHP 站点,不要拿这个练手。

参考:

  • https://github.com/kuwoo/php2shell-Lab
  • https://github.com/ikow/php2shell-poc
  • https://www.php.net/security
  • https://securitylabs.datadoghq.com/articles/php2shell/