-
-
[原创]PHP2Shell 复现连载(三):环境搭建、UAF 验证,与两条路径的完整辨析
-
发表于: 1天前 65
-
上一篇(第二篇):https://bbs.kanxue.com/thread-293417.htm
【测试声明】
一、本文全部操作在本地自建靶场(127.0.0.1)完成,未对任何第三方系统发起请求;
二、本节不含可直接运行的实现代码。涉及链路的部分只描述机制与前置条件,用于帮助防守方判断自身风险,不构成可照做的步骤;
三、任何对非自有资产的测试行为均需获得书面授权。
0x00 本篇范围
第二篇讲清了根因和"为什么打不穿"。这一篇做三件事:
一、从干净系统搭出隔离靶场(含三个真实踩过的坑);
二、把 UAF 原语真正打通,并把屏幕输出逐行读懂;
三、辨析"我最后确实拿到了 shell"这件事,为什么和这个 UAF 没有关系。
第三件事是整篇的重点。网上大量文章把"UAF 可触发"直接等同于"可以 RCE",这个推导不成立,而且后果很坏:它会让防守方把"需要精确堆布局的内存破坏"和"改两行配置就能消除的暴露面问题"看成同一件事,然后去修错误的东西。
0x01 环境搭建
起点是干净的 Windows 11,目标是一个只听 127.0.0.1 的 PHP 8.4.4 靶场。
0x01.1 前置检查
wsl --status
wsl -l -v
docker version
三种情况:报"未安装适用于 Linux 的 Windows 子系统"就从头装;能列发行版且 docker 可用就跳过;只缺 docker 就单独装 Docker。
再确认硬件虚拟化:
Get-CimInstance Win32_Processor | Select-Object VirtualizationFirmwareEnabled
返回 True 即可。False 先去 BIOS 开 VT-x/SVM,这步不过后面全白搭。
0x01.2 启用 Windows 功能(坑一)
需要管理员权限,两个功能都要开:
dism.exe /online /enable-feature /featurename:Microsoft-Windows-Subsystem-Linux /all /norestart
dism.exe /online /enable-feature /featurename:VirtualMachinePlatform /all /norestart
坑在这里:dism 在"成功但需要重启"时返回码是 3010,不是 0。
我第一次用 if errorlevel 1 判断,结果两个功能都装成功,脚本却报 FAILED,日志里明明写着"操作成功完成"。
正确判断:
set RC=%ERRORLEVEL%
if "%RC%"=="0" echo OK
if "%RC%"=="3010" echo OK - REBOOT REQUIRED
if not "%RC%"=="0" if not "%RC%"=="3010" echo FAILED
启用完必须重启。重启后用 Get-Service vmcompute 验证,能看到这个服务(哪怕 Stopped)就说明组件到位。
0x01.3 安装 WSL 运行时(坑二)
wsl --install 会去微软服务器下载约 350 MB,国内经常这样断掉:
正在下载: 适用于 Linux 的 Windows 子系统 3.0.3
与服务器的连接被重置=====60.1%===
不是本地问题,是到下载服务器的连接被重置。重试十次也断在相近位置。
改走离线安装,从 GitHub 下 MSI:
curl.exe -L --retry 10 -o "$env:USERPROFILE\Downloads\wsl-setup.msi" `
"f09K9s2c8@1M7s2y4Q4x3@1q4Q4x3V1k6Q4x3V1k6Y4K9i4c8Z5N6h3u0Q4x3X3g2U0L8$3#2Q4x3V1k6E0K9h3y4J5L8%4y4G2k6Y4c8Q4x3V1k6i4f1@1I4Q4x3V1k6J5k6h3I4W2j5i4y4W2M7#2)9J5c8X3c8G2N6$3&6D9L8$3q4V1i4K6u0r3x3#2)9J5k6e0m8Q4x3X3f1K6i4K6u0r3N6%4y4D9i4K6u0W2x3#2)9J5k6e0m8Q4x3X3f1K6i4K6u0W2x3q4)9J5k6i4R3$3y4q4)9J5k6h3#2K6K9b7`.`."
用加速镜像时有一条铁律:装之前必须验签名。
$sig = Get-AuthenticodeSignature -FilePath "$env:USERPROFILE\Downloads\wsl-setup.msi"
$sig.Status
$sig.SignerCertificate.Subject
必须看到 Status 为 Valid、Signer 为 CN=Microsoft Corporation。不是就立刻删掉,不要装。MSI 装完有 SYSTEM 权限,来路不明的包等于把机器交出去。
装完验证:
& "C:\Program Files\WSL\wsl.exe" --version
预期 WSL 版本 3.0.3.0、内核 6.18.54.1-1、默认版本 2。
0x01.4 Ubuntu 发行版(坑三的源头)
官方方式有时失败在解析:
wsl --install -d Ubuntu --web-download --no-launch
未能从 raw.githubusercontent.com/.../DistributionInfo.json 提取分发列表
改离线导入。从清华镜像下最小根文件系统(约 29 MB):
New-Item -ItemType Directory -Force -Path D:\WSL\Ubuntu | Out-Null
& "C:\Program Files\WSL\wsl.exe" --import Ubuntu D:\WSL\Ubuntu D:\ubuntu-base.tar.gz --version 2
0x01.5 最坑的一个:ubuntu-base 没有 CA 证书
换好国内源之后,任何 https 源都会失败:
Certificate verification failed: The certificate is NOT trusted.
因为最小根文件系统不带 ca-certificates。这是鸡生蛋:装证书要先能连源,连 https 源要先有证书。
解法是先切 http 装证书,再切回 https。操作前备份源文件:
cp /etc/apt/sources.list.d/ubuntu.sources /etc/apt/sources.list.d/ubuntu.sources.bak
sed -i 's|7a1K9s2c8@1M7s2y4Q4x3@1q4Q4x3V1k6Q4x3V1k6E0K9i4u0J5L8%4u0K6i4K6u0W2N6s2g2F1j5g2)9J5k6i4c8K6K9h3&6Y4K9s2g2S2i4K6u0W2k6h3c8#2i4K6u0W2j5$3&6Q4x3V1k6#2j5Y4g2F1N6s2g2Q4x3V1k6Q4y4@1y4Z5N6s2c8H3i4K6y4m8i4K6u0r3i4K6u0r3L8h3W2J5M7X3!0J5M7#2)9J5k6i4c8#2L8X3q4Q4x3X3g2@1M7$3W2F1k6$3S2#2j5g2)9J5k6h3g2V1N6g2)9J5k6h3y4F1i4K6u0r3N6h3u0#2L8Y4c8#2i4K6u0r3i4K6N6o6k6H3`.`.' /etc/apt/sources.list.d/ubuntu.sources
apt-get update -y
DEBIAN_FRONTEND=noninteractive apt-get install -y --no-install-recommends ca-certificates
update-ca-certificates
sed -i 's|654K9s2c8@1M7q4)9K6b7g2)9J5c8W2)9J5c8X3#2A6M7Y4u0G2M7Y4y4Q4x3X3g2@1N6h3&6S2i4K6u0W2N6s2y4A6L8X3N6Z5N6h3q4Q4x3X3g2W2k6s2g2Q4x3X3g2U0L8W2)9J5c8Y4g2T1N6h3&6@1N6g2)9J5c8W2)9%4b7$3S2@1N6s2m8K6i4K6y4m8i4K6u0r3i4K6u0r3L8h3W2J5M7X3!0J5M7#2)9J5k6i4c8#2L8X3q4Q4x3X3g2@1M7$3W2F1k6$3S2#2j5g2)9J5k6h3g2V1N6g2)9J5k6h3y4F1i4K6u0r3N6h3u0#2L8Y4c8#2i4K6u0r3i4K6N6o6k6H3`.`.' /etc/apt/sources.list.d/ubuntu.sources
apt-get update -y
http 源只是临时手段,装完一定切回来。
接着装基础工具并启用 systemd:
DEBIAN_FRONTEND=noninteractive apt-get install -y --no-install-recommends
curl gnupg systemd systemd-sysv sudo nano iproute2 tar gzip python3
写入 /etc/wsl.conf:
[boot]
systemd=true
[user]
default=root
然后从 Windows 侧 wsl --shutdown 才生效。验证 systemctl is-system-running,输出 running 或 degraded 都正常。
0x01.6 Docker 与资源限制
装 docker-ce(用清华源),装完 docker --version 应为 29.x,systemctl is-active docker 为 active。
配置镜像加速写入 /etc/docker/daemon.json:
{
"registry-mirrors": [
"aa2K9s2c8@1M7s2y4Q4x3@1q4Q4x3V1k6Q4x3V1k6V1L8$3y4C8k6i4u0Q4x3X3g2E0i4K6u0W2k6r3q4G2j5$3I4G2N6h3c8Q4x3X3g2A6L8H3`.`.",
"fbdK9s2c8@1M7s2y4Q4x3@1q4Q4x3V1k6Q4x3V1k6V1L8$3y4C8k6i4u0Q4x3X3f1I4L8i4y4Q4x3X3g2J5N6h3^5`.",
"032K9s2c8@1M7s2y4Q4x3@1q4Q4x3V1k6Q4x3V1k6V1L8$3y4C8k6i4u0Q4x3X3g2^5N6h3q4F1P5i4g2S2L8W2)9J5k6h3#2W2"
],
"log-driver": "json-file",
"log-opts": { "max-size": "10m", "max-file": "2" }
}
两点:加速地址会失效,配置里可列多个;第三方加速会缓存镜像,私有镜像不要走加速。
最后限制 WSL 占用,在 Windows 侧建 C:\Users<用户名>.wslconfig:
[wsl2]
memory=6GB
processors=8
swap=2GB
[experimental]
autoMemoryReclaim=gradual
autoMemoryReclaim=gradual 让 WSL 空闲时把内存逐步还给 Windows。不加这行,16G 机器长时间跑容器后 Windows 侧会明显变卡。改完要 wsl --shutdown。
0x02 靶场结构与易受攻击状态
获取靶场:
cd /root
git clone --depth 1 e37K9s2c8@1M7s2y4Q4x3@1q4Q4x3V1k6Q4x3V1k6Y4K9i4c8Z5N6h3u0Q4x3X3g2U0L8$3#2Q4x3V1k6K6P5i4u0W2P5o6p5H3x3e0y4Q4x3V1k6H3K9s2l9J5M7$3S2W2L8r3H3`.
结构(节选):
lab-uaf/
├── Dockerfile php:8.4.4-fpm-bookworm + nginx
├── docker-compose.yml 端口 19268(web) / 9000(fpm)
├── conf/php.ini 漏洞配置(upload_progress 开启)
├── conf/php-mitigated.ini 缓解配置
├── conf/nginx.conf fastcgi_request_buffering off
├── webroot/index.php 打印版本与配置
└── scripts/php2shell_poc.py 主 PoC
关键配置:
session.upload_progress.enabled = On
session.upload_progress.cleanup = On
session.upload_progress.name = PHP_SESSION_UPLOAD_PROGRESS
session.upload_progress.freq = 1%
session.upload_progress.min_freq = 1
session.save_handler = files
session.save_path = /var/lib/php/sessions
nginx 侧必须 fastcgi_request_buffering off,原因见第二篇 0x05——缓冲会吞掉客户端停顿。
容器内换 Debian 源后再构建:
RUN sed -i "s|deb.debian.org|mirrors.tuna.tsinghua.edu.cn|g; s|security.debian.org|mirrors.tuna.tsinghua.edu.cn|g" /etc/apt/sources.list.d/debian.sources
cd /root/php2shell/lab-uaf
docker compose up -d --build
首次拉 php:8.4.4-fpm-bookworm 约 714 MB。
确认漏洞态:
预期:
PHP 8.4.4 (fpm-fcgi)
session.upload_progress.enabled = 1
session.upload_progress.min_freq = 1
session.upload_progress.name = PHP_SESSION_UPLOAD_PROGRESS
session.save_handler = files
file_uploads = 1
四个条件齐了才算:版本在区间内、enabled=1、min_freq=1、save_handler=files。
官方自检也可以跑一次:
python3 scripts/php2shell_poc.py --verify
预期 Reachable YES、upload_progress ENABLED、PHP-FPM YES。
0x03 触发 UAF 与输出判读
python3 scripts/php2shell_poc.py --monitor --verbose --rounds 5
参数意义:--monitor 在停顿中途读 session 确认指针已武装;--verbose 打印每阶段字节数;--rounds 重复轮数;--stall 停顿时长(必须大于 min_freq,PoC 用 1.5);--spray 堆喷分片数。
预期输出:
── Round 1/5 ──
[>] Phase 1 sent: 5591 bytes (file data triggers FILE_START + FILE_DATA)
[>] Phase 2 sent: 10267 bytes (FILE_DATA writes through cached zval*)
SID: fb719e2894ed430b9ff05fabb60dc294
Status: 200 OK
Armed: YES — bytes_processed = 5331
► Cached zval* pointers were written through (stale path reached)
file[0].bytes_processed = 5331
汇总:
Rounds: 5
Pointers armed: 5/5
Worker crashes: 0/5
逐行拆:
Armed: YES —— session.c:3292 与 :3318 缓存的 zval* 已指向 session HashTable 槽位。
bytes_processed = 5331 —— session.c:3331-3332 的写入穿过了失效指针,5331 就是实际写进去的字节数。
stale path reached —— "释放后再写入"这条路径确实被执行。
Worker crashes: 0/5 —— 最容易误读的一行。
关于最后一行:没有堆喷时,被释放的那块内存仍在 ZendMM 的 320 字节 bin 里,尚未被重新分配。写入落在一块"暂时还属于自己的"内存上,所以表现是静默的。静默写入反而是有价值的信息——它证明写入落在可控位置,缺的只是"让这块内存在写入前被别人占用"这一步(详细机制见第二篇 0x0C 到 0x0E)。
反过来说,如果每次触发都直接把 worker 打崩,说明写入落到了不该落的地方,离可控更远。
时间线:
发送 Phase 1 (5.5 KB) → 停顿 1.5 秒 → 发送 Phase 2 (10.2 KB) → 读取 session
停顿必须大于 1 秒,否则第二次 FILE_DATA 不触发 update,track_init 不释放旧 HashTable,UAF 不发生。
0x04 把靶场关回本机
docker-compose.yml 默认:
ports:
- "19268:80"
- "9000:9000"
在 Docker 里等价于绑定 0.0.0.0。这意味着同一局域网内任何人都能访问你的漏洞靶场。改成:
ports:
- "127.0.0.1:19268:80"
- "127.0.0.1:9000:9000"
docker compose up -d
docker port php2shell-lab
预期:
80/tcp -> 127.0.0.1:19268
9000/tcp -> 127.0.0.1:9000
从局域网地址访问应当失败(curl: (7) Failed to connect),本机仍是 200。
这一步看着琐碎,但它防的是"你的实验环境被别人当成入口"。漏洞靶场本身就是现成的攻击面。
0x05 常见失败排查
Armed: NO - session empty —— 停顿时长不够,加大 --stall,必须大于 1 秒。
Pointers armed: 0/N —— 起成了缓解配置,确认用的是 docker-compose.yml 而不是 mitigated 版本。
upload_progress: DISABLED —— 缓解配置生效,改回漏洞配置重建。
连接被拒绝 —— 容器没起,docker compose ps 看状态。
每轮 200 但没有 Armed —— nginx 缓冲没关,确认 fastcgi_request_buffering off。
最后一条最难查,因为现象是"一切正常但就是打不中"。我在这上面耗了不少时间,最后发现是改了配置没重建容器。
0x06 两条路径的辨析
我最后确实拿到了 www-data 权限,但走的是另一条路。先把两条路并排看清楚。
关于是否依赖漏洞:路径 A(UAF 到 RCE)依赖 CVE-2026-109618/109619 本身;路径 B 不依赖 UAF。
关于额外前提:路径 A 无额外前提,属于纯粹的内存破坏利用;路径 B 需要 php-fpm 端口可达。
关于技术难度:路径 A 极高,需要堆喷加构建版本相关的符号偏移;路径 B 低,属于常规手法。
关于公开材料:路径 A 没有完整实现;路径 B 有。
关于本次是否复现:路径 A 否;路径 B 是。
只有路径 A 才是本次漏洞真正需要警惕的部分。路径 B 是"PHP 配置不当 + FastCGI 暴露"的常见组合,任何版本的 PHP,只要 fpm 端口裸奔都可能被打,和这个 CVE 没关系。
官方 README 的 Limitations 也写得很直接:
"This PoC proves the vulnerable code path is reachable. It does not achieve a crash or code execution. That requires a heap groom (spraying fake structures into ZendMM's 320-byte slab bin) with build-specific symbol offsets."
所以任何声称"一条命令打穿任意 PHP 站"的说法都是夸大。这不是替漏洞辩护,而是把风险说到点子上——夸大的后果是防守方做完一次无效加固就以为安全了。
0x07 路径 B 的机制:为什么它能成立
路径 B 不碰内存,它利用的是 PHP 的一个设计特性,配合一个部署错误。
0x07.1 第一步:把内容写进 session 文件
upload_progress 机制会把上传进度写进 session 文件,格式大致是:
upload_progress_<字段值>|a:5:{s:10:"start_time";i:...;...}
关键在于 <字段值> 是原样写入的,不做转义、不做校验。
也就是说,如果这个字段的值里含有 PHP 开标签,它会以纯文本形式落在 session 文件里。而 session 文件是磁盘上的普通文件——一旦别的地方把这个文件当成 PHP 代码 include 进去,里面的内容就会被执行。
这就是一个"把数据写进文件"的落地点。
0x07.2 为什么需要一个 x 前缀
这是最容易被忽略、也最难定位的细节。
session 文件的解析器要求竖线之前是一个合法的变量名。如果字段值直接以 PHP 开标签开头,变量名解析失败,整条记录被丢弃,落地的内容就不是你以为的样子。
解决方法是在前面加一个无害字符把变量名补成合法的,例如:
x<?php /* ... */ ?>
这样变量名是 x,后面的内容作为代码存在。官方实现里那行注释写得很明白:
'x' prefix keeps the variable name parser happy until the <?php tag
这个细节本身没有武器化价值,但它是理解"这条路为什么能成立"的关键。而且它解释了一个现象:缺少这个前缀时,失败表现和"session 被清空了"完全一样,两者极难区分。
0x07.3 第二步:让 FPM 包含这个文件
php-fpm 允许在 FastCGI 请求参数里用 PHP_VALUE 覆盖 ini 设置。其中一个可被覆盖的项是 auto_prepend_file——它指定一个文件,在每个脚本执行前先 include 一次。
于是逻辑链闭合:session 文件里已经有内容,只要能让 FPM 把它当成 prepend 文件,内容就会被执行。
这一步完全不需要 UAF,只要有:fpm 端口(9000-9003)从攻击者位置可达;session 文件路径可知或可枚举。
注意第二步的位置:它绕过 nginx 直接和 FPM 说话。这就是为什么"fpm 端口是否暴露"是这条路成立与否的唯一关键。
0x08 踩坑:为什么这条路成功率几乎为零
这部分是我花了两小时的地方,也是这一篇最有价值的部分,因为它揭示了两个"现象相同、原因不同"的故障。
0x08.1 坑一:cleanup = On 让竞态窗口极短
靶场配置 session.upload_progress.cleanup = On,含义是上传完成后立刻清理 session 数据。
而官方脚本的节奏是"上传结束后,等 2 秒,再触发 include"。等它等到的时候 session 早被清空了,include 一个空文件当然没有输出。
失败现场很有说服力——session 目录里堆了几千个 0 字节文件:
-rw------- 1 www-data www-data 0 Oct 22 11:41 sess_007dc0b7a2daed321997400488b7dc7b
-rw------- 1 www-data www-data 0 Oct 22 11:41 sess_00aac2013cd70379e99e0317af349831
-rw------- 1 www-data www-data 0 Oct 22 11:46 sess_00b4a3936bba37a667323990a4dbdf35
这些 0 字节文件本身就是"写进去又被清掉"的直接证据。看到这种现场,先怀疑 cleanup,而不是怀疑漏洞不存在。
0x08.2 坑二:漏了 x 前缀
自己写验证脚本时,如果直接把字段值设成裸的 PHP 开标签,变量名解析失败,落地内容不成立。
这个坑最难查,因为它的失败现象和"被 cleanup 清空"一模一样:session 文件是空的,或者有内容但没执行。
两个原因叠加时,修掉其中一个现象不变,很容易得出"这条路走不通"的错误结论。我当时就是先解决了时序问题,发现还是失败,才开始怀疑 payload 本身。
0x08.3 正确的节奏:在上传进行中触发
关键认识:不要让上传结束,而是在上传仍在进行、session 尚未被 cleanup 的窗口里,把文件 include 掉。
实现上做两件事(只描述思路,不提供实现):
一、把上传拆成很多小分片、控制分片间隔,让整个上传持续到数秒级别,从而把窗口撑开;
二、在窗口内(而不是上传结束后)发起那次 include 请求。
改完这个节奏后,首次尝试就命中了。
顺带说一个通用方法:竞态类问题先把时间参数放大到极端(比如把上传拖到 30 秒),确认链路本身通不通;通了再逐步收紧找真实边界。反过来做会让人怀疑一切。
0x09 结果与物证
命令回显:
uid=33(www-data) gid=33(www-data) groups=33(www-data)
侦察节选:
uname -a
Linux 686985b191b8 6.18.54.1-microsoft-standard-WSL2 #1 SMP PREEMPT_DYNAMIC x86_64 GNU/Linux
注意这里仍然是 www-data,不是 root。容器内 fpm worker 以非特权用户运行,这一点配对了至少能限制影响范围。
每次成功后容器状态:
STATUS Exited (255)
日志里能看到执行痕迹:
PHP Warning: Cannot modify header information - headers already sent by
(output started at /var/lib/php/sessions/sess_5009bc5dd3de4ad4aff2b1f95f713a3e:1)
这行 warning 是执行成功的直接物证:session 文件第 1 行产生了输出,导致后续 header 无法发送。
容器退出的原因是 entrypoint.sh 同时拉起 php-fpm 与 nginx,fpm 主进程异常结束后脚本退出,容器随之退出(退出码 255)。
对防守方来说,这行 warning 比任何日志都值得关注:正常业务代码不太可能从 session 文件里产生输出。如果 PHP 错误日志里出现类似记录,且路径指向 session 目录,基本可以按"已被执行过代码"来处理。
0x0A 路径 B 在真实环境的适用性
fpm 端口是否可达,决定一切:
典型 nginx + fpm 同机,监听 127.0.0.1:9000 —— 不可直接利用,需要先拿到 SSRF 或内网跳板。
同样部署但监听 0.0.0.0:9000 且无防火墙 —— 可直接利用。
Docker 编排但未映射 9000 —— 不可直接利用。
Docker 编排误映射 9000:9000 —— 可直接利用。
共享主机走 unix socket —— 不可直接利用。
结论很清楚:大多数正确配置的生产环境不暴露 9000。这是路径 B 最大的限制,也是防守方最该检查的一项。
session 文件路径是否可知并不构成有效防护——save_path 在 Debian/Ubuntu 上默认 /var/lib/php/sessions,部分发行版是 /tmp,候选值就那几个。真正的防护是让攻击者根本连不上 fpm。
路径 B 的实质是"配置缺陷利用",不是"0day 打穿"。真正的风险在于:世界上有大量容器编排把 php-fpm 端口误暴露,配合这个漏洞的知名度,会形成一波自动化扫描。这才是 CVE-2026-109618 的真实威胁模型——不是有人能精确利用 UAF,而是一堆裸奔的 9000 端口等着被批量扫。
这个判断对防守方是有利的:它意味着处置动作明确、成本低、当天能做完。
0x0B 责任边界与下一篇
本篇所有观察只在 127.0.0.1 的自建靶场中产生。
不要把任何 PoC 指向你不拥有、未获书面授权的地址。未经授权访问计算机信息系统在绝大多数司法辖区构成犯罪,不因"只是测试"而免责。
下一篇是防守向的收尾:加固、自查与应急响应。包含五个修复方案、修复后怎么验证、以及怀疑已被利用时的处置顺序。如果只转一篇给运维同事,转那篇。
免责声明:本文仅用于安全研究与防御加固讨论,不含可直接运行的实现。任何测试须确保目标系统为自有资产或已获书面授权,测试环境与生产环境网络隔离。