PHP2Shell:我管的每台机器,PHP 运行时全都中了
这次和过去十个「PHP 高危漏洞」不一样
大多数打着 PHP 名号的高危通告,其实是应用层的 bug 借了框架的名字:某个插件、某个 CMS 路由、某个反序列化写坏了的库。它们也危险,但补法很明确——升级那一个东西,影响面就是它服务的那些页面。
PHP2Shell 在 PHP 自己身上。 两个漏洞:CVE-2026-109618,内存耗尽(OOM)让一次操作半途中断、请求状态只初始化了一半,而 PHP 自己的清理逻辑随后把这份无效状态当成有效的;CVE-2026-109619,陈旧全局状态跨过请求边界存活下来。两个都是 CVSS v4.0 9.2,默认配置下未授权即可远程执行代码。Wiz 的研究团队在 wordpress:latest、drupal:latest、joomla:latest 上打出了 pre-auth RCE,另外还演示了一条纯内存泄漏路径:不需要执行任何代码,就能读到其他请求的密码、token 和 API key。
三个细节决定了我要怎么花这一天:
- 从 PHP 5.4 到 8.5 全中。 不是「部分版本」。一个只打印 Hello World 的
index.php就够被打穿——所以「我这站是自己写的,没人会盯上」不构成任何缓解。 - 披露前就已经在被利用。 AWS 的威胁情报在蜜罐里于 10 月 17 日就观测到 PHP2Shell 的利用尝试,比通告早四天;Cloudflare 在补丁发布当天就报告了攻击流量,包括试图绕过其防护的变种。
- 只有打补丁能真修。 到处转的
session.upload_progress.enabled = 0只砍掉了一条利用路径,不是这一类 bug。PHP 自己的安全公告这么说,Datadog 的分析也这么说。把它当安全带可以,当修好不行。
修复版本(2026 年 10 月 21 日发布):8.2.35 / 8.3.36 / 8.4.27 / 8.5.12。PHP 8.1 及更早已停止支持,永远不会有了。
停止猜,改成实测
我管的机器分布在几处:群晖 DSM、unRaid、一台跑 CyberPanel/OpenLiteSpeed 的 Ubuntu 虚拟机(托管 8 个线上站),以及一台 VPS(Traefik + mailcow + 6 个容器化的 PHP 站)。加起来大概 170 个容器。
我第一反应是看镜像 tag 就收工——这恰恰是最容易出错的判断。php:8.3-fpm-alpine 是浮动 tag,它可以是任何 8.3.x;而一个从未被重建过的容器,永远停在它启动那一刻的版本。Docker 的 tag 不是版本号。 所以我改成进入每一个候选运行时里量:在容器里、或在宿主运行时自己的二进制上跑 php -v 和 php -i。
结果:
| 主机 | 运行时 | 实测版本 | 谁能访问 | 有修复版本吗 |
|---|---|---|---|---|
| VPS (203.0.113.10) | 6 × php:8.3-fpm-alpine | 8.3.33 | 公网(6 个站) | 有——8.3.36 |
| VPS | mailcow phpfpm | 8.2.29 | 公网(它自己的 web UI) | 有,藏在 mailcow 2026-10 里 |
| CyberPanel VM | lsphp84(8 个站) | 8.4.25 | 公网(8 个站) | 有——8.4.27 |
| CyberPanel VM | lsphp81 / lsphp82 / lsphp83 | 8.1.34 / 8.2.33 / 8.3.33 | 未使用 / 默认回退 | 8.1 永远没有,其余有 |
| DSM | Web Station PHP 套件 | 8.2.30 / 8.3.30 / 8.4.19 | 内网 + 隧道 | 当时还没有——22 日 19:45 才出 |
| DSM | phpmyadmin:5.2.3 | 8.3.33 | SSO 后面 | 有——同一个 tag 被重建了 |
| DSM / unRaid | passbolt 5.15 / 5.14 | 8.4.24 | SSO / 内网 | 有——5.17.0 带 8.4.27 |
| unRaid 7.1.4 | webGUI 的 PHP | 8.3.19 | 仅内网 | 没有 |
每一行都中。这不是巧合,这就是「只给应用打补丁、不给运行时打补丁」的机群长什么样,而这类 bug 已经在代码里躺了十年。
这张表同时给出了排序。决定先修谁的是两件事:暴露面(谁能碰到它)和到底有没有修复版本。公网上跑着 WordPress 的旧运行时排第一;只在内网应答的管理界面排最后。而「厂商还没出包」是一个必须说出口的真实状态,不能拿一个不存在的更新来糊过去。
第一步:一条命令关掉 8 个公网站
最急的是 CyberPanel 那台:8 个公网站(其中 5 个 WordPress)全跑在同一个 lsphp84 8.4.25 上。
LiteSpeed 的软件源里已经有修复版本,所以第一步的收益最高、风险最低——同一个 8.4 分支内的点升级:
sudo apt update
sudo apt-get install --only-upgrade $(dpkg -l | awk '/^ii +lsphp84/{print $2}')
/usr/local/lsws/lsphp84/bin/lsphp -v # PHP 8.4.27 (litespeed)
sudo /usr/local/lsws/bin/lswsctrl restart
两分钟后这 8 个站全部落在修复版上。我要说清楚:真正把洞堵住的是这一步,不是后面那场 8.5 迁移。后面那些只是「我想停在我想要的位置」,与「安全了」无关。
验证是大多数人会跳过、却最要紧的一步。进程名什么都证明不了:OpenLiteSpeed 为每个 vhost 起的 PHP 子进程,argv[0] 就是字面上的 lsphp,所以 ps aux | grep lsphp 只会给你八个一模一样的进程,看不出各自跑的哪个二进制。要按站点用户去读 PID 背后真正的可执行文件:
ps -eo user:20,pid,args | grep '[l]sphp' | while read u p a; do
echo "$u -> $(readlink -f /proc/$p/exe)"
done | sort -u
client_wood -> /usr/local/lsws/lsphp84/bin/lsphp
hoele6451 -> /usr/local/lsws/lsphp84/bin/lsphp
app_portal -> /usr/local/lsws/lsphp84/bin/lsphp
...
第二步:上 8.5.12,以及那个不存在的包
CVE 关掉之后,才有资格从容地做迁移。目标是:能上 8.5 的站全部上 8.5.12,保留一条 8.3 作回退,把两条过时分支(8.1、8.2)删掉。
第一次安装就失败了,失败方式值得记下来。我照着 8.4 的包列表做查找替换,生成了 lsphp85-opcache——整个 apt 事务直接中止:
E: Unable to locate package lsphp85-opcache
PHP 8.5 里 OPcache 已经并进主包(用 lsphp85 -i | grep 'Zend OPcache' 可验证 → enabled),所以那个分支在 LiteSpeed 源里根本没有 -opcache 子包。正确做法是直接从仓库自己的清单取包名,而不是从上一个分支抄:
apt-cache search '^lsphp85' | awk '{print $1}' | tr '\n' ' '
还有一个坑:lsphp -m 会打印用法说明然后退出,因为 LSAPI 这个 SAPI 没有脚本可跑。要用 lsphp -i | grep …,或者同目录的 CLI 二进制 /usr/local/lsws/lsphp85/bin/php -m。
一个站的 PHP 版本到底写在哪
这部分让我意外,也是我没有去点面板下拉框的原因。这台机器上每个 vhost 带自己的 PHP handler——站点的运行时是它自己配置文件里的一行:
/usr/local/lsws/conf/vhosts/<domain>/vhost.conf
extprocessor <siteuser> {
type lsapi
address UDS://tmp/lshttpd/<siteuser>.sock
path /usr/local/lsws/lsphp85/bin/lsphp <- 就是这一行
...
}
httpd_config.conf 里那个全局的 extProcessor lsphp(现在仍指向 lsphp83)只是默认 vhost 的回退。改它对你的站点毫无影响。
所以切换 = 改一行、测配置、平滑重启:
cp -a /usr/local/lsws/conf/vhosts/<domain>/vhost.conf \
/root/vhost-backup/<domain>.vhost.conf.bak-$(date +%F-%H%M)
sed -i 's#/usr/local/lsws/lsphp[0-9]*/bin/lsphp#/usr/local/lsws/lsphp85/bin/lsphp#' \
/usr/local/lsws/conf/vhosts/<domain>/vhost.conf
/usr/local/lsws/bin/lshttpd -t # 有 ERROR 就停手
/usr/local/lsws/bin/lswsctrl restart # 平滑,约 2 秒,全部 vhost
面板的数据库也算运行时状态的一部分。CyberPanel 把版本记在 cyberpanel.websiteFunctions_websites.phpSelection;如果 vhost 改成 8.5 而库里还写着 PHP 8.4,那么下一次面板侧保存(或建 staging 副本)就会理直气壮地把 vhost 改回面板以为的值:
UPDATE cyberpanel.websiteFunctions_websites
SET phpSelection = 'PHP 8.5'
WHERE domain IN (...);
我是一个站一个站推的:先测试站,再一个低流量客户站,然后其余,最后才是我自己的主站。任何时候回滚都是同一条 sed 反向执行加重启,五秒钟。
用字节数验证,不要用状态码
「200 OK」是很弱的断言。页面片段里的 PHP fatal、某个插件悄悄不渲染了、短代码返回空字符串——这些都还是 200。所以每次切换前我都抓下渲染好的首页和它的字节数,切换后要求字节数完全一致:
curl -skL -o /dev/null -w '%{http_code} %{size_download}\n' https://<host>/
hoelee.com 200 227390 (切换前后完全一致)
client-wood.com 200 266192 (一致)
client-property.com.my 200 222780 (一致)
client-trading.com.my 200 134535 (一致)
test.hoelee.com 200 80869 (一致)
client-mail.com 200 44861 (一致)
app.client-portal.com 200 142762 (后来迁到 8.5.12,见下文)
我还会在每个响应里 grep Fatal error / Uncaught / Deprecated:,并请求 /wp-json/——那里返回 200 说明 WordPress 真的启动、查库、序列化了一份响应,比一个可能被缓存的首页渲染强得多。全部零错误。(WordPress 6.9 与 7.x 官方声明完全支持 PHP 8.5,残余风险在插件不在核心。我的站每个装了 19–44 个插件,这正是字节数校验值得做的原因。)
这个漏洞顺手帮我找出的旧 bug
测试时我注意到,某个客户站的 /xmlrpc.php、/.env、/readme.html 仍然是 200——2026 年 10 月,在一个我九月特意加固过、写了八条 deny context 的 vhost 上。
原因不在那些 context,而在我自己早前那次编辑留在文件里的两个多余 }。vhost.conf 是 virtualHost 块的主体(外层大括号在 httpd_config.conf 里),所以那两个括号什么也没关闭,而它们之后追加的每一条 context 都落在 vhost 之外,压根没有注册。配置解析器其实每一次运行都在说这件事:
[ERROR] [PlainConf] [virtualHostConfig:] parseline found more '}' in
/usr/local/lsws/conf/vhosts/client-trading.com.my/vhost.conf:80
我把它当噪音读了好几周。而它本身就是全部诊断。删掉那两个括号、重载之后,同样三个 URL 变成了 403,配置测试从两条错误变成零。
教训不是「检查括号」。而是:会静默失效的安全控制,比没有控制更糟——它把信心卖给了你,却没给保护,而本该告诉你真相的那条警告,正躺在你已判定为噪音的日志里。
厂商补丁在我干活干到一半时来了
我本来已经把 DSM 判死了。群晖归档里最新的 PHP8.4 是 8.4.19-0050,所以 Web Station 接下来的几周只能靠 session.upload_progress.enabled = 0 加一条防火墙规则撑着。然后 10 月 22 日 19:45,群晖发布了 SA-26:14,带的是真的修复包。几小时后我把它装上了:
PHP 8.2 8.2.30-0170 -> 8.2.35-0171
PHP 8.3 8.3.30-0050 -> 8.3.36-0051
PHP 8.4 8.4.19-0050 -> 8.4.27-0051
麻烦的是批量执行通道。为了平时收敛攻击面,那台 NAS 没开常规 SSH 密钥直连,而在 DSM 网页界面手动点三次 .spk 上传又慢又无法脚本化;当时我手头自动化巡检端现成打通的是这台 NAS 的 Docker API。于是我直接通过 API 起了一个一次性特权维护容器,把宿主根目录挂进去,用 chroot 调宿主的 synopkg 完成静默安装:
HostConfig = {
"NetworkMode": "host",
"Privileged": True,
"PidMode": "host", # 关键就是这一行
"Binds": ["/:/h:rw", "/proc:/h/proc", "/sys:/h/sys",
"/dev:/h/dev", "/run:/h/run"],
}
# 然后在容器里:
# chroot /h /usr/syno/bin/synopkg install \
# /volume1/docker/pkgs/PHP8.4-x86_64-8.4.27-0051.spk
PidMode 那一行就是全部的教训。我第一次漏了它,synopkg 回的是:
{"error": {"code": 299, "description": "failed to run preuninst worker"},
"stage": "install_failed_and_start_failed", "success": false}
二进制还停在旧版本,而包已经死了,状态是 broken / install_corruption。用 PidMode: host 重跑同一个安装包,走的是修复路径(stage: installed_and_started),一次过。光挂 /proc、/sys、/dev、/run 不够——PID 命名空间必须是宿主机的。
还有两个小坑。SPK 直链的架构写在文件名里(PHP8.4-x86_64-8.4.27-0051.spk),去掉 -x86_64- 就是 404。另外,跑 Calendar 的那个服务并不是你刚重启的那个:synopkg restart PHP8.4 会回你「成功」,而 Calendar 的 FPM 还在跑旧二进制,因为 Calendar 用的是 PHP 8.2——pkg-synocalendar-phpfpm.service,ExecStart=/usr/local/bin/php82-fpm。得按名字重启那个 unit,然后从二进制本身证明版本,而不是相信 Package Center 的显示:
/usr/local/bin/php82 -v -> PHP 8.2.35 (built 2026-10-22 11:50)
/usr/local/bin/php83 -v -> PHP 8.3.36 (built 2026-10-22 11:54)
/usr/local/bin/php84 -v -> PHP 8.4.27 (built 2026-10-22 11:58)
有一个副作用值得点名:装包会把包启动起来。那台机器上的 PHP 8.3 一直是「装了但没跑」的状态,升级把它拉了起来,等于凭空多给我一个监听面,于是我把它停回去。另外 Calendar 这个包本身还差一个版本(3.0.3-21434,公告要求 3.0.4-21435,SPK 还没上公开 CDN),但它应答请求用的运行时已经打好了——那才是要紧的部分。
同一个 tag 被重建过,就是另一个运行时
phpmyadmin:5.2.3 是上面那套「浮动 tag 不可信」的反例,也是我现在除了版本号还要看重建日期的原因。
tag 从我部署那天起就没变过,里面的 PHP 变了:镜像在 10 月 21 日被重建,烘进去的是 PHP 8.3.36。所以修法是 pull 加重建容器,不用改版本号、不用读 changelog——容器里现在报 PHP 8.3.36 (built: Oct 21 2026),之前是 8.3.33。
这次 pull 的代价也不只在本地。通过 API 重建那一个 stack,顺带把 mysql:8.4.11 重新解析了一遍,四个容器重启了大约三十秒。没有东西坏掉,但「只动一个容器」这种改动,值得在落地过程中盯着,而不是事后才发现。
我说不能动的那个应用
我原本把 app.client-portal.com 按在 8.4.27:CodeIgniter,而框架文档只写到 8.4。这个理由值得核对而不是直接相信,所以我去核了。CodeIgniter 从 4.7.0 起支持 PHP 8.5——框架自己的 composer.json 允许 ^8.2,4.7.4 也明确覆盖——而且这个应用的路由表在 8.5.12 上按 E_ALL 加载干净:spark routes,零弃用警告。于是它也跟着迁了,八个站最后落在同一个版本上。
这次切换还牵出第二个跟 CVE 毫无关系的运行时。应用的三条 cron 调的是 /usr/bin/php——发行版自带的 PHP 8.3.6 CLI,没有 ext-intl——而站点本身走的是 LiteSpeed 的构建。失败又安静又彻底:Class "Locale" not found at SYSTEMPATH/CodeIgniter.php:189,自动出件和发信队列两个 worker 日志里各有 429 行,每一次定时任务都在干活之前就死了。把三条 crontab 指到 /usr/local/lsws/lsphp85/bin/php(也就是站点自己用的那个二进制)之后才结束。
我看的时候队列里正好没有卡住的信。那是运气,不是设计——那两条 cron 正是已付款订单出件和收据寄出的通道。
那个「复制了个寂寞」的备库
升级第二台 passbolt 时,冒出了那晚最不舒服的一个发现。
主库(5.15.0)和灾备备库(5.14.0)跑的都是 PHP 8.4.24,都需要换到 5.17.0 镜像,那里面才是 8.4.27。备库起来后,它自带的每一项检查都通过——指纹在、私钥能解密、JWT 对有效——只不过它的数据库是 34 张表,主库是 56 张,而 Seconds_Behind_Source: 0。
两个数字都是真的,但都不是看上去的意思。复制从来没带上 passbolt 的写入:主库的 binlog 被 --binlog-do-db=appdb 过滤着,而 appdb 这个库早就不存在了,所以那台机器上的每个 binlog 都是 181 字节。空流上的 Seconds_Behind_Source: 0 不是健康,是打着绿勾的沉默。
修它试了三次,中间有一个真坑。--replicate-do-db=passbolt 会按会话默认库去过滤 DDL,于是复制过来的 CREATE TABLE 被丢掉,而后面跟着的插入照样匹配——SQL 线程就停在 Table 'passbolt.x' doesn't exist。能用的是 --replicate-wild-do-table=passbolt.%,它按真实表名匹配,DDL 和 DML 都管。然后全量重灌(--source-data=2,让 dump 自带复制坐标)、CHANGE REPLICATION SOURCE … GET_SOURCE_PUBLIC_KEY=1(非 TLS 链路上不带这个参数,caching_sha2_password 认证不过)、再把 read_only 收回去。验证方式是两边对账(56 张表、2 users、542 resources、644 secrets、525 permissions),以及往主库写一行探针、看它在五秒内出现在备库上。这个故事值得单独一篇。这里的重点是:给运行时打补丁,同时也是一次审计,而这次审计发现了一套一直只是摆设的灾备方案。
还没修好的部分
unRaid 把 PHP 打包在系统镜像里。我这台是 7.1.4,webGUI 的 PHP 是 8.3.19;当前稳定版(7.3.3)甚至 7.4.0-rc.1 带的都是 8.4.26——仍然低于 8.4.27。没有补丁可打,所以控制手段是可访问性:webGUI 只在内网应答,没有走隧道。
mailcow 是我第一轮判断错的地方。我把它的 phpfpm 容器(8.2.29)归成了「仅内部」,它不是:它的 web UI 在一个公网主机名上应答。镜像被 mailcow 的发布版本钉死(ghcr.io/mailcow/phpfpm:8.2.29-3),所以没有单独升的余地——修复是随 mailcow 自己的版本发出的,2026-10(10 月 21 日)带的是 phpfpm:8.2.35-1。那是一次带停邮窗口的应用升级,不是拉个镜像,所以它要单独排一个窗口。在那之前这个容器是修复前状态,把这句话写下来,比做一张看起来全都完事的表更有用。
如果重来一次,我会改什么
- 把版本探测做成定时任务,而不是事故发生时才做。 上面这套实测花了我一小时,因为是在压力下做的。一个每周在每台主机、每个容器里跑
php -v的小脚本,本来能在通告落地之前就把这张表给我。 - 把面板数据库当成运行时状态来看。 我一半的困惑来自「面板说 8.4」对上「进程说 8.5」。两个真相来源本身就是 bug。
- 当天就删旧分支。 8.1 和 8.2 是装着的、没用的、不再支持的——194MB 纯靠惯性活下来的攻击面。现在它们没了。
- 每次都把配置测试的输出读出来。 那个括号 bug 每次重启都在打印自己的诊断,整整一个月。
- 假设厂商补丁会在事故中途到达。 我早上把 DSM 判死,当天 19:45 就拿到了修复包。那份缓解方案本来只是占位,我却开始把它当成结论——在把「没有修复版本」当成发现写出去之前,回头再看一次公告。
结果
一条命令,在两分钟内关掉了 8 个公网站上的 9.2 分未授权 RCE。到那晚结束时,22 号量过的每一个运行时都动了,除了两个确实没有可装的:
| 位置 | 现在 | 怎么做的 |
|---|---|---|
| 8 个 CyberPanel 站 | 8.5.12 | 留 lsphp83 8.3.36 作回退 |
| 6 个 VPS 容器 | 8.3.36 | 浮动 tag,重建镜像 |
| DSM Web Station | 8.2.35 / 8.3.36 / 8.4.27 | SA-26:14 套件 |
| DSM phpMyAdmin | 8.3.36 | 同一个 tag,重建镜像 |
| passbolt 主库 + 备库 | 8.4.27 | 5.17.0 |
| mailcow php-fpm | 8.2.29 | 被 mailcow 版本钉死——2026-10 已排期 |
| unRaid webGUI | 8.3.19 | 厂商没有补丁 |
每个站渲染出的字节数与升级前完全一致,零 PHP 错误,配置测试干净通过。
同一次扫荡还带出两个跟 PHP2Shell 无关的发现:八条从未生效的 bot-block 规则,在一个公网站点上;以及一个「一直在复制、其实什么都没复制」的备库。
PHP 的零日很少见。静默失效的安全控制不少见。
需要有人帮你把这些做一遍?
我平时就在做自托管 PHP 机群的打补丁与维护——CyberPanel/OpenLiteSpeed 上的 WordPress、Traefik 后面的容器化 PHP、以及群晖和 unRaid 主机。如果你的站点不确定跑在哪个运行时上,我会上门把每一个都量出来、老实告诉你哪些厂商还没出包、其余的按你的流量节奏排期。
邮箱 [email protected],WhatsApp +60 12-797 2969,或看我在做什么:hoelee.com。
服务器频繁异常重启、云账单过高,或缺一位能穿透内核日志的架构师?
真正的生产环境稳定性,靠的是穿透表象抓出根因。我独立管理着跨 3 台物理/云主机、162 个容器的高可用自托管生产网络。