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

Binary file not shown.

After

Width:  |  Height:  |  Size: 77 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 45 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 70 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 17 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 64 KiB

+3
View File
@@ -255,5 +255,8 @@
"evidence": "URL percent-encoding trap killed · 2-layer defensive fix",
"ogTitle_en": "Why Your TTS Model Keeps Saying \"Percent\"",
"ogTitle_zh": "为什么本地语音合成一直在念 Percent?"
},
"php2shell-patching-every-php-runtime-i-manage": {
"evidence": "8 public sites in 2 min · 7 on 8.5.12 · 8 dead rules"
}
}
@@ -0,0 +1,316 @@
---
title: "PHP2Shell: I Measured Every PHP Runtime I Manage. All of Them Were Vulnerable."
description: "CVE-2026-109618 and CVE-2026-109619 are unauthenticated RCE in PHP itself, exploited before disclosure. What I measured across the infrastructure I manage — and the exact fix that closed it."
pubDate: 2026-10-23
category: devops
tags: ["php", "security", "cve", "cyberpanel", "litespeed", "wordpress", "patching", "self-hosting"]
ogImage: /og/php2shell-patching-every-php-runtime-i-manage.jpg
banner: /banners/php2shell-patching-every-php-runtime-i-manage.jpg
draft: false
---
## Why this vulnerability is not like the last ten
Most PHP "critical" advisories are application bugs wearing a framework's name:
a plugin, a CMS route, a library with a bad unserialize. They are bad, but they
are patchable by updating one thing, and the blast radius is whatever that one
thing serves.
**PHP2Shell is in PHP itself.** Two bugs — CVE-2026-109618 (an out-of-memory
error that leaves request state half-initialised, which PHP's own cleanup then
treats as valid) and CVE-2026-109619 (stale global state that survives the
request that created it) — both CVSS v4.0 **9.2**, both allowing
unauthenticated remote code execution on a stock configuration. The Wiz research
team demonstrated pre-auth RCE against `wordpress:latest`, `drupal:latest` and
`joomla:latest` and separately showed a memory-disclosure path that leaks other
requests' passwords and API keys without any code execution at all.
Three details decided how I spent my day:
1. **It affects everything from PHP 5.4 to 8.5.** Not "some versions". A
"Hello World" `index.php` is enough for the RCE — your application's code is
irrelevant, so "my site is custom, nobody targets it" is not a mitigation.
2. **It was exploited before it was disclosed.** AWS's threat intelligence
observed PHP2Shell exploitation attempts in a honeypot on **17 October**,
four days before the advisory. Cloudflare reported attack traffic, including
attempts to evade its protections, the same day the patches landed.
3. **Only patching fixes it.** The circulating `session.upload_progress.enabled
= 0` tweak cuts off one exploitation path, not the class of bug. PHP's own
advisory says so; so does Datadog's analysis. Treat it as a seatbelt, not a
fix.
Fixed versions, released 21 October 2026: **8.2.35, 8.3.36, 8.4.27, 8.5.12**.
PHP 8.1 and older are end-of-life and will never get one.
## I stopped guessing and measured
The infrastructure I manage spans a Synology DSM NAS, an unRaid box, an Ubuntu
VM running CyberPanel/OpenLiteSpeed with eight production sites, and a VPS with
Traefik, mailcow and five containerised PHP sites — roughly 170 containers in
total.
My first instinct was to check the image tags and call it done. That is how you
get this wrong. `php:8.3-fpm-alpine` is a floating tag — it can hold any 8.3.x,
and a container that has not been recreated keeps whatever it started with.
Docker's tag is not a version. So I probed inside every candidate runtime
instead — `php -v` and `php -i` executed in the container or in the host
runtime's own binary.
What came back:
| Host | Runtime | Measured version | Reachable from | Patched build existed? |
|---|---|---|---|---|
| VPS 41.216.187.102 | 5 × `php:8.3-fpm-alpine` | **8.3.33** | public (5 sites) | yes — 8.3.36 |
| VPS | mailcow `phpfpm` | **8.2.29** | internal only | yes — 8.2.35 |
| CyberPanel VM | `lsphp84` (8 sites) | **8.4.25** | public (8 sites) | yes — 8.4.27 |
| CyberPanel VM | `lsphp81` / `lsphp82` / `lsphp83` | 8.1.34 / 8.2.33 / 8.3.33 | unused / fallback | 8.1 never, others yes |
| DSM | Web Station PHP packages | **8.2.30 / 8.3.30 / 8.4.19** | LAN + tunnels | **no** |
| DSM | `phpmyadmin:5.2.3` | **8.3.33** | behind SSO | yes — re-pull |
| DSM / unRaid | `passbolt` 5.15 / 5.14 | **8.4.24** | behind SSO / LAN | re-pull and verify |
| unRaid 7.1.4 | webGUI PHP | **8.3.19** | LAN only | **no** |
Every single row was vulnerable. That is not a coincidence — it is what a
ten-year-old bug class looks like on a fleet that patches applications and not
runtimes.
The table also produced the ranking. Two things decide the order of work:
**exposure** (who can reach it) and **whether a fix even exists**. A public
WordPress fleet on a pre-fix runtime comes first. An admin UI that only answers
on the LAN comes last. And "no vendor fix exists" is a real state you have to
name out loud rather than paper over with an update that doesn't exist.
## Step 1: one command closed it on eight public sites
The CyberPanel VM was the urgent one: eight public sites — five of them
WordPress — all sharing `lsphp84` 8.4.25.
The LiteSpeed repository already carried the fixed build, so the first move cost
nothing in risk: a point upgrade inside the same 8.4 branch.
```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
```
Two minutes later every one of those sites was on a patched runtime. I want to
be explicit that this — not the 8.5 migration that follows — is what actually
closed the hole. Everything after this was about ending up where I wanted to be,
not about being safe.
Verifying it is the part people skip, and it is the part that matters. **The
process name proves nothing**: OpenLiteSpeed spawns each vhost's PHP as a child
whose `argv[0]` is literally `lsphp`, so `ps aux | grep lsphp` shows you eight
identical processes and tells you nothing about which binary each is running.
Read the binary behind the PID instead, per site user:
```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
...
```
## Step 2: moving to 8.5.12, and the package that does not exist
With the CVE closed I could work at a civilised pace. The target was PHP
**8.5.12** for everything that supports it, one branch of 8.3 kept as a
fallback, and the two obsolete branches (8.1, 8.2) removed.
The install failed on the first attempt, in a way worth recording. I mirrored
the existing 8.4 package set by find-and-replace, which produced
`lsphp85-opcache` — and the whole apt run aborted:
```text
E: Unable to locate package lsphp85-opcache
```
In PHP 8.5, OPcache is part of the base package (verify with
`lsphp85 -i | grep 'Zend OPcache'` → enabled), so the `-opcache` sub-package
simply does not exist for that branch in the LiteSpeed repo. Build the list from
the repository's own inventory instead of from the previous branch:
```bash
apt-cache search '^lsphp85' | awk '{print $1}' | tr '\n' ' '
```
One more trap: `lsphp -m` prints the usage banner and exits, because the LSAPI
SAPI has no script to run. Use `lsphp -i | grep …`, or the sibling CLI binary
`/usr/local/lsws/lsphp85/bin/php -m`.
## Where a site's PHP version actually lives
This is the part that surprised me, and it is why I did not simply click the
version dropdown. On this box, each vhost carries its **own** PHP handler — the
site's runtime is a line in the site's own config file:
```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 <- this line
...
}
```
The global `extProcessor lsphp` in `httpd_config.conf` — which still points at
`lsphp83` — is only the fallback for the default vhost. Changing it does nothing
for your sites.
So the switch is a one-line edit, a config test, and a graceful restart:
```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 # errors? stop here
/usr/local/lsws/bin/lswsctrl restart # graceful, ~2 s, all vhosts
```
The panel's database is part of the runtime state too. CyberPanel keeps
`phpSelection` in `cyberpanel.websiteFunctions_websites`; leave it saying
`PHP 8.4` while the vhost says 8.5 and the next panel-side save (or a staging
clone) will happily rewrite the vhost back to what the panel believes:
```sql
UPDATE cyberpanel.websiteFunctions_websites
SET phpSelection = 'PHP 8.5'
WHERE domain IN (...);
```
I rolled it out one site at a time — a test WordPress install first, then a
low-traffic client site, then the rest, then my own site last. Rollback at any
point is the same `sed` in reverse plus a restart, five seconds.
## Verify with bytes, not status codes
"200 OK" is a weak assertion. A PHP fatal in a page fragment, a plugin that
silently stops rendering a block, a shortcode that returns an empty string —
all of those still return 200. So before each switch I captured the rendered
homepage and its byte count, and after each switch I required the same count:
```bash
curl -skL -o /dev/null -w '%{http_code} %{size_download}\n' https://<host>/
```
```text
hoelee.com 200 227390 (before and after: identical)
chaijoywood.com 200 266192 (identical)
nestz.com.my 200 222780 (identical)
truetrading.com.my 200 134535 (identical)
test.hoelee.com 200 80869 (identical)
sifumail.com 200 44861 (identical)
numerology.digikedai.com 200 142762 (stayed on 8.4.27)
```
I also grep every response for `Fatal error` / `Uncaught` / `Deprecated:`, and
hit `/wp-json/` — a 200 there means WordPress actually bootstrapped, queried the
database and serialised a response, which is a much stronger signal than a
cached homepage render. Zero errors across all of them. (WordPress 6.9 and 7.x
are documented as fully supporting PHP 8.5; the residual risk is plugins, not
core. My sites run 19–44 plugins each, which is exactly why the byte-count check
earned its keep.)
## The bug the vulnerability found for me
While testing, I noticed `/xmlrpc.php`, `/.env` and `/readme.html` were still
returning 200 on one client site — in October 2026, on a vhost I had
deliberately hardened in September with eight deny contexts.
The cause was not the contexts. It was two stray `}` left in that vhost file by
my own earlier edit. A `vhost.conf` is the **body** of a `virtualHost` block —
the outer braces live in `httpd_config.conf` — so those two braces closed
nothing, and every `context` appended after them sat outside the vhost and
registered nowhere. The config parser had been saying so on every single run:
```text
[ERROR] [PlainConf] [virtualHostConfig:] parseline found more '}' in
/usr/local/lsws/conf/vhosts/truetrading.com.my/vhost.conf:80
```
I had read that line as noise for weeks. It was the whole diagnosis. After
removing the two braces and reloading, the same three URLs answered **403**, and
the config test went from two errors to zero.
The lesson is not "check your braces". It is that **a security control that
fails silently is worse than no control**, because it buys you the confidence
without the protection — and the warning that would have told you is sitting in
a log you have decided is noisy.
## What I deliberately did not upgrade
Two of those systems have no fix to install, and saying that plainly is part of
the job.
**Synology DSM** ships PHP as Package Center packages. The newest `PHP8.4`
build in Synology's public archive is still **8.4.19-0050** — pre-fix, and
Synology's PHP packages historically lag by days to months. So Web Station gets
a mitigation, not a patch: `session.upload_progress.enabled = 0`,
`session.use_strict_mode = 1`, `session.use_only_cookies = 1` in the PHP
profile, and a hard rule that Web Station's docroot is not exposed to the
internet while the runtime is unpatched.
**unRaid** bundles PHP in the OS image. My box runs 7.1.4 with webGUI PHP
**8.3.19**; the current stable (7.3.3) and even 7.4.0-rc.1 ship **8.4.26** —
still below 8.4.27. There is no patch to apply yet, so the control is
reachability: the webGUI answers on the LAN only and is not tunnelled.
**numerology.digikedai.com** stays on 8.4.27 on purpose. It is a CodeIgniter 4
application on framework 4.7.4, whose documented support stops at PHP 8.4. A
revenue-handling app is the wrong place to discover 8.5 deprecations, so it gets
the patched runtime now and the branch migration after a framework update and a
staging test.
## What I'd do differently
1. **Probe versions on a schedule, not during an incident.** The measurement
work above took an hour because I was doing it under pressure. A tiny script
that runs `php -v` in every container and every host runtime weekly would
have answered the whole question in one table before the advisory landed.
2. **Treat the control panel's database as runtime state.** Half my confusion
was "the panel says 8.4" versus "the process says 8.5". Two sources of truth
is a bug.
3. **Delete the old branches on the same day.** 8.1 and 8.2 were installed,
unused, and unsupported — 194 MB of attack surface kept alive by inertia.
They are gone now.
4. **Read the config test output every time, out loud.** The stray-brace bug had
been printing its own diagnosis on every restart for a month.
## The result
One command closed a 9.2-severity unauthenticated RCE across eight public sites
in about two minutes. Over the following hour, seven of them moved to PHP
**8.5.12**, the eighth stayed on **8.4.27** by choice, the fallback branch went
to **8.3.36**, and two obsolete branches were removed — with every site's
rendered output byte-identical before and after, zero PHP errors, and a config
test that now passes clean.
The vulnerability also handed me a real finding: eight bot-block rules that had
never been live, on a public site, in a config file whose parser had been
complaining about them the entire time.
Zero-day PHP bugs are rare. Silent security controls are not.
---
## Want this done on your servers?
I patch and maintain self-hosted PHP fleets for a living — WordPress on
CyberPanel/OpenLiteSpeed, Dockerised PHP behind Traefik, Synology and unRaid
hosts included. If you have sites you are not sure are on a patched runtime, I
will measure every one of them, tell you honestly which ones have no vendor fix
yet, and schedule the rest around your traffic.
Reach me at [[email protected]](mailto:[email protected]?subject=PHP%20runtime%20patch%20audit) or WhatsApp
[+60 12-797 2969](https://wa.me/60127972969), or see what I do at
[hoelee.com](https://hoelee.com).
@@ -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)。