diff --git a/public/banners/php2shell-patching-every-php-runtime-i-manage.jpg b/public/banners/php2shell-patching-every-php-runtime-i-manage.jpg new file mode 100644 index 0000000..7c9ee88 Binary files /dev/null and b/public/banners/php2shell-patching-every-php-runtime-i-manage.jpg differ diff --git a/public/banners/php2shell-patching-every-php-runtime-i-manage.webp b/public/banners/php2shell-patching-every-php-runtime-i-manage.webp new file mode 100644 index 0000000..a38d1ac Binary files /dev/null and b/public/banners/php2shell-patching-every-php-runtime-i-manage.webp differ diff --git a/public/og/php2shell-patching-every-php-runtime-i-manage.jpg b/public/og/php2shell-patching-every-php-runtime-i-manage.jpg new file mode 100644 index 0000000..c6bf203 Binary files /dev/null and b/public/og/php2shell-patching-every-php-runtime-i-manage.jpg differ diff --git a/public/og/thumb/php2shell-patching-every-php-runtime-i-manage-400.webp b/public/og/thumb/php2shell-patching-every-php-runtime-i-manage-400.webp new file mode 100644 index 0000000..2e05469 Binary files /dev/null and b/public/og/thumb/php2shell-patching-every-php-runtime-i-manage-400.webp differ diff --git a/public/og/thumb/php2shell-patching-every-php-runtime-i-manage-800.webp b/public/og/thumb/php2shell-patching-every-php-runtime-i-manage-800.webp new file mode 100644 index 0000000..bb45879 Binary files /dev/null and b/public/og/thumb/php2shell-patching-every-php-runtime-i-manage-800.webp differ diff --git a/public/og/zh-php2shell-patching-every-php-runtime-i-manage.jpg b/public/og/zh-php2shell-patching-every-php-runtime-i-manage.jpg new file mode 100644 index 0000000..140d6b9 Binary files /dev/null and b/public/og/zh-php2shell-patching-every-php-runtime-i-manage.jpg differ diff --git a/scripts/og-gen/art-meta.json b/scripts/og-gen/art-meta.json index b9026c0..922d847 100644 --- a/scripts/og-gen/art-meta.json +++ b/scripts/og-gen/art-meta.json @@ -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" } } \ No newline at end of file diff --git a/src/content/posts/php2shell-patching-every-php-runtime-i-manage.md b/src/content/posts/php2shell-patching-every-php-runtime-i-manage.md new file mode 100644 index 0000000..6a05b87 --- /dev/null +++ b/src/content/posts/php2shell-patching-every-php-runtime-i-manage.md @@ -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//vhost.conf + +extprocessor { + type lsapi + address UDS://tmp/lshttpd/.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//vhost.conf \ + /root/vhost-backup/.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//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:/// +``` + +```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 [me@hoelee.com](mailto:me@hoelee.com?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). diff --git a/src/content/posts/zh/php2shell-patching-every-php-runtime-i-manage.md b/src/content/posts/zh/php2shell-patching-every-php-runtime-i-manage.md new file mode 100644 index 0000000..1f17884 --- /dev/null +++ b/src/content/posts/zh/php2shell-patching-every-php-runtime-i-manage.md @@ -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//vhost.conf + +extprocessor { + type lsapi + address UDS://tmp/lshttpd/.sock + path /usr/local/lsws/lsphp85/bin/lsphp <- 就是这一行 + ... +} +``` + +`httpd_config.conf` 里那个全局的 `extProcessor lsphp`(现在仍指向 `lsphp83`)只是默认 vhost 的回退。改它对你的站点毫无影响。 + +所以切换 = 改一行、测配置、平滑重启: + +```bash +cp -a /usr/local/lsws/conf/vhosts//vhost.conf \ + /root/vhost-backup/.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//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:/// +``` + +```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 主机。如果你的站点不确定跑在哪个运行时上,我会上门把每一个都量出来、老实告诉你哪些厂商还没出包、其余的按你的流量节奏排期。 + +邮箱 [me@hoelee.com](mailto:me@hoelee.com?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)。