feat(posts): publish 4-part windows-mcp series across historical dates with full EN+ZH assets
Deploy / build (push) Successful in 22s

This commit is contained in:
hoelee committed 2026-10-25 18:45:43 +08:00
1 parent 4cfbc33a6f
commit d5ab7dec81
36 files changed
+1537 -127

No files matched your search

@@ -7,6 +7,7 @@ tags: ["php", "security", "cve", "cyberpanel", "litespeed", "wordpress", "patchi
ogImage: /og/zh-php2shell-patching-every-php-runtime-i-manage.jpg
banner: /banners/php2shell-patching-every-php-runtime-i-manage.jpg
draft: false
updatedDate: 2026-10-23
---
## 这次和过去十个「PHP 高危漏洞」不一样
@@ -25,7 +26,7 @@ draft: false
## 停止猜,改成实测
我管的机器分布在几处:群晖 DSM、unRaid、一台跑 CyberPanel/OpenLiteSpeed 的 Ubuntu 虚拟机(托管 8 个线上站),以及一台 VPS(Traefik + mailcow + 5 个容器化的 PHP 站)。加起来大概 170 个容器。
我管的机器分布在几处:群晖 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`。
@@ -33,13 +34,13 @@ draft: false
| 主机 | 运行时 | 实测版本 | 谁能访问 | 有修复版本吗 |
|---|---|---|---|---|
| VPS 41.216.187.102 | 5 × `php:8.3-fpm-alpine` | **8.3.33** | 公网(5 个站) | 有——8.3.36 |
| VPS | mailcow `phpfpm` | **8.2.29** | 仅内部 | 有——8.2.35 |
| VPS 41.216.187.102 | 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** | 内网 + 隧道 | **没有** |
| DSM | `phpmyadmin:5.2.3` | **8.3.33** | SSO 后面 | 有——重拉镜像 |
| DSM / unRaid | `passbolt` 5.15 / 5.14 | **8.4.24** | SSO / 内网 | 需要重拉并复核 |
| 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 已经在代码里躺了十年。
@@ -147,7 +148,7 @@ nestz.com.my 200 222780 (一致)
truetrading.com.my 200 134535 (一致)
test.hoelee.com 200 80869 (一致)
sifumail.com 200 44861 (一致)
numerology.digikedai.com 200 142762 (留在 8.4.27)
numerology.digikedai.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 个插件,这正是字节数校验值得做的原因。)
@@ -167,15 +168,81 @@ numerology.digikedai.com 200 142762 (留在 8.4.27)
教训不是「检查括号」。而是:**会静默失效的安全控制,比没有控制更糟**——它把信心卖给了你,却没给保护,而本该告诉你真相的那条警告,正躺在你已判定为噪音的日志里。
## 我故意没有升级的部分
## 厂商补丁在我干活干到一半时来了
其中有几个系统根本没有补丁可打,把这件事说清楚也是工作的一部分。
我本来已经把 DSM 判死了。群晖归档里最新的 `PHP8.4` 是 8.4.19-0050,所以 Web Station 接下来的几周只能靠 `session.upload_progress.enabled = 0` 加一条防火墙规则撑着。然后 10 月 22 日 19:45,群晖发布了 SA-26:14,带的是真的修复包。几小时后我把它装上了:
**群晖 DSM** 的 PHP 是以 Package Center 套件形式发布的。群晖公开归档里最新的 `PHP8.4` 仍是 **8.4.19-0050**——修复前版本,而群晖的 PHP 套件历来落后几天到几个月。所以 Web Station 只做缓解、不做升级:PHP 配置里 `session.upload_progress.enabled = 0`、`session.use_strict_mode = 1`、`session.use_only_cookies = 1`,并立一条硬规矩——运行时没打补丁期间,Web Station 的站点根绝不暴露到公网。
```text
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
```
**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 只在内网应答,没有走隧道。
麻烦的是访问权。那台机器的 SSH 只认一个我手上没有的密码,管理界面要登录,而能装 `.spk` 的只有宿主机上的 `synopkg`。我手里有的是这台 NAS 的 Docker API。于是让包管理器在一个特权容器里跑,把宿主根目录挂进去,用 `chroot` 调用它:
**numerology.digikedai.com** 是我主动留在 8.4.27 的。它是 CodeIgniter 4 应用,框架版本 4.7.4,官方文档只声明支持到 PHP 8.4。一个处理订单和交付的应用,是最不该拿去试 8.5 弃用警告的地方——所以它现在拿的是「已打补丁的运行时」,分支迁移放到框架升级 + staging 测试之后。
```python
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` 回的是:
```json
{"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 的显示:
```text
/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` 重新解析了一遍,四个容器重启了大约三十秒。没有东西坏掉,但「只动一个容器」这种改动,值得在落地过程中盯着,而不是事后才发现。
## 我说不能动的那个应用
我原本把 `numerology.digikedai.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`。那是一次带停邮窗口的应用升级,不是拉个镜像,所以它要单独排一个窗口。在那之前这个容器是修复前状态,把这句话写下来,比做一张看起来全都完事的表更有用。
## 如果重来一次,我会改什么
@@ -183,12 +250,25 @@ numerology.digikedai.com 200 142762 (留在 8.4.27)
2. **把面板数据库当成运行时状态来看。** 我一半的困惑来自「面板说 8.4」对上「进程说 8.5」。两个真相来源本身就是 bug。
3. **当天就删旧分支。** 8.1 和 8.2 是装着的、没用的、不再支持的——194MB 纯靠惯性活下来的攻击面。现在它们没了。
4. **每次都把配置测试的输出读出来。** 那个括号 bug 每次重启都在打印自己的诊断,整整一个月。
5. **假设厂商补丁会在事故中途到达。** 我早上把 DSM 判死,当天 19:45 就拿到了修复包。那份缓解方案本来只是占位,我却开始把它当成结论——在把「没有修复版本」当成发现写出去之前,回头再看一次公告。
## 结果
一条命令,在两分钟内关掉了 8 个公网站上的 9.2 分未授权 RCE。接下来一小时里,其中 7 个迁到 PHP **8.5.12**,第 8 个按计划留在 **8.4.27**,回退分支升到 **8.3.36**,两条过时分支被删除——每个站渲染出的字节数与升级前完全一致,零 PHP 错误,配置测试干净通过。
一条命令,在两分钟内关掉了 8 个公网站上的 9.2 分未授权 RCE。到那晚结束时,22 号量过的每一个运行时都动了,除了两个确实没有可装的:
顺带还拿到一个真实发现:八条从未生效的 bot-block 规则,在一个公网站点上,在一个解析器一直在抱怨的配置文件里。
| 位置 | 现在 | 怎么做的 |
|---|---|---|
| 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 的零日很少见。静默失效的安全控制不少见。