feat(posts): PHP2Shell — every PHP runtime I manage was vulnerable
Deploy / build (push) Successful in 23s

Adds EN + ZH post php2shell-patching-every-php-runtime-i-manage: measured
PHP versions across the fleet, the order the fixes shipped in, the 8.5
package trap, the OLS vhost handler switch, and the silent bot-block bug
the sweep uncovered. Ships og cards (EN/ZH), banner + webp, 2 thumbs.
This commit is contained in:
hoelee committed 2026-10-23 02:02:50 +08:00
1 parent bebc3272ce
commit 1b795cee9d
9 files changed
+521

No files matched your search

@@ -0,0 +1,202 @@
---
title: "PHP2Shell:我管的每台机器,PHP 运行时全都中了"
description: "CVE-2026-109618 与 CVE-2026-109619 是 PHP 语言本身的未授权远程代码执行,披露前就已被利用。这篇记录我在自己管理的系统上实测到的版本、先修哪里,以及真正把它关掉的那几条命令。"
pubDate: 2026-10-23
category: devops
tags: ["php", "security", "cve", "cyberpanel", "litespeed", "wordpress", "patching", "self-hosting"]
ogImage: /og/zh-php2shell-patching-every-php-runtime-i-manage.jpg
banner: /banners/php2shell-patching-every-php-runtime-i-manage.jpg
draft: false
---
## 这次和过去十个「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。
三个细节决定了我要怎么花这一天:
1. **从 PHP 5.4 到 8.5 全中。** 不是「部分版本」。一个只打印 Hello World 的 `index.php` 就够被打穿——所以「我这站是自己写的,没人会盯上」不构成任何缓解。
2. **披露前就已经在被利用。** AWS 的威胁情报在蜜罐里于 **10 月 17 日**就观测到 PHP2Shell 的利用尝试,比通告早四天;Cloudflare 在补丁发布当天就报告了攻击流量,包括试图绕过其防护的变种。
3. **只有打补丁能真修。** 到处转的 `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 + 5 个容器化的 PHP 站)。加起来大概 170 个容器。
我第一反应是看镜像 tag 就收工——这恰恰是最容易出错的判断。`php:8.3-fpm-alpine` 是浮动 tag,它可以是任何 8.3.x;而一个从未被重建过的容器,永远停在它启动那一刻的版本。**Docker 的 tag 不是版本号。** 所以我改成进入每一个候选运行时里量:在容器里、或在宿主运行时自己的二进制上跑 `php -v` 和 `php -i`。
结果:
| 主机 | 运行时 | 实测版本 | 谁能访问 | 有修复版本吗 |
|---|---|---|---|---|
| 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 |
| 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 / 内网 | 需要重拉并复核 |
| unRaid 7.1.4 | webGUI 的 PHP | **8.3.19** | 仅内网 | **没有** |
每一行都中。这不是巧合,这就是「只给应用打补丁、不给运行时打补丁」的机群长什么样,而这类 bug 已经在代码里躺了十年。
这张表同时给出了排序。决定先修谁的是两件事:**暴露面**(谁能碰到它)和**到底有没有修复版本**。公网上跑着 WordPress 的旧运行时排第一;只在内网应答的管理界面排最后。而「厂商还没出包」是一个必须说出口的真实状态,不能拿一个不存在的更新来糊过去。
## 第一步:一条命令关掉 8 个公网站
最急的是 CyberPanel 那台:8 个公网站(其中 5 个 WordPress)全跑在同一个 `lsphp84` 8.4.25 上。
LiteSpeed 的软件源里已经有修复版本,所以第一步的收益最高、风险最低——同一个 8.4 分支内的点升级:
```bash
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 背后真正的可执行文件:
```bash
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
```
```text
chaij6203 -> /usr/local/lsws/lsphp84/bin/lsphp
hoele6451 -> /usr/local/lsws/lsphp84/bin/lsphp
numer9393 -> /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 事务直接中止:
```text
E: Unable to locate package lsphp85-opcache
```
PHP 8.5 里 OPcache 已经并进主包(用 `lsphp85 -i | grep 'Zend OPcache'` 可验证 → enabled),所以那个分支在 LiteSpeed 源里根本没有 `-opcache` 子包。正确做法是直接从仓库自己的清单取包名,而不是从上一个分支抄:
```bash
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——站点的运行时是它自己配置文件里的一行:
```text
/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 的回退。改它对你的站点毫无影响。
所以切换 = 改一行、测配置、平滑重启:
```bash
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 改回面板以为的值:
```sql
UPDATE cyberpanel.websiteFunctions_websites
SET phpSelection = 'PHP 8.5'
WHERE domain IN (...);
```
我是一个站一个站推的:先测试站,再一个低流量客户站,然后其余,最后才是我自己的主站。任何时候回滚都是同一条 `sed` 反向执行加重启,五秒钟。
## 用字节数验证,不要用状态码
「200 OK」是很弱的断言。页面片段里的 PHP fatal、某个插件悄悄不渲染了、短代码返回空字符串——这些都还是 200。所以每次切换前我都抓下渲染好的首页和它的字节数,切换后要求字节数完全一致:
```bash
curl -skL -o /dev/null -w '%{http_code} %{size_download}\n' https://<host>/
```
```text
hoelee.com 200 227390 (切换前后完全一致)
chaijoywood.com 200 266192 (一致)
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)
```
我还会在每个响应里 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 之外,压根没有注册。配置解析器其实每一次运行都在说这件事:
```text
[ERROR] [PlainConf] [virtualHostConfig:] parseline found more '}' in
/usr/local/lsws/conf/vhosts/truetrading.com.my/vhost.conf:80
```
我把它当噪音读了好几周。而它本身就是全部诊断。删掉那两个括号、重载之后,同样三个 URL 变成了 **403**,配置测试从两条错误变成零。
教训不是「检查括号」。而是:**会静默失效的安全控制,比没有控制更糟**——它把信心卖给了你,却没给保护,而本该告诉你真相的那条警告,正躺在你已判定为噪音的日志里。
## 我故意没有升级的部分
其中有几个系统根本没有补丁可打,把这件事说清楚也是工作的一部分。
**群晖 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 的站点根绝不暴露到公网。
**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 只在内网应答,没有走隧道。
**numerology.digikedai.com** 是我主动留在 8.4.27 的。它是 CodeIgniter 4 应用,框架版本 4.7.4,官方文档只声明支持到 PHP 8.4。一个处理订单和交付的应用,是最不该拿去试 8.5 弃用警告的地方——所以它现在拿的是「已打补丁的运行时」,分支迁移放到框架升级 + staging 测试之后。
## 如果重来一次,我会改什么
1. **把版本探测做成定时任务,而不是事故发生时才做。** 上面这套实测花了我一小时,因为是在压力下做的。一个每周在每台主机、每个容器里跑 `php -v` 的小脚本,本来能在通告落地之前就把这张表给我。
2. **把面板数据库当成运行时状态来看。** 我一半的困惑来自「面板说 8.4」对上「进程说 8.5」。两个真相来源本身就是 bug。
3. **当天就删旧分支。** 8.1 和 8.2 是装着的、没用的、不再支持的——194MB 纯靠惯性活下来的攻击面。现在它们没了。
4. **每次都把配置测试的输出读出来。** 那个括号 bug 每次重启都在打印自己的诊断,整整一个月。
## 结果
一条命令,在两分钟内关掉了 8 个公网站上的 9.2 分未授权 RCE。接下来一小时里,其中 7 个迁到 PHP **8.5.12**,第 8 个按计划留在 **8.4.27**,回退分支升到 **8.3.36**,两条过时分支被删除——每个站渲染出的字节数与升级前完全一致,零 PHP 错误,配置测试干净通过。
顺带还拿到一个真实发现:八条从未生效的 bot-block 规则,在一个公网站点上,在一个解析器一直在抱怨的配置文件里。
PHP 的零日很少见。静默失效的安全控制不少见。
---
## 需要有人帮你把这些做一遍?
我平时就在做自托管 PHP 机群的打补丁与维护——CyberPanel/OpenLiteSpeed 上的 WordPress、Traefik 后面的容器化 PHP、以及群晖和 unRaid 主机。如果你的站点不确定跑在哪个运行时上,我会上门把每一个都量出来、老实告诉你哪些厂商还没出包、其余的按你的流量节奏排期。
邮箱 [[email protected]](mailto:[email protected]?subject=PHP%20%E8%BF%90%E8%A1%8C%E6%97%B6%E8%A1%A5%E4%B8%81%E5%AE%A1%E8%AE%A1),WhatsApp
[+60 12-797 2969](https://wa.me/60127972969),或看我在做什么:[hoelee.com](https://hoelee.com)。