📌 隶属于架构蓝图: 生产级自托管基础设施全景 → L4: 安全基线、CI/CD 与异地容灾

PHP2Shell:我管的每台机器,PHP 运行时全都中了

· 16 分钟 · 运维架构 phpsecuritycvecyberpanellitespeedwordpresspatchingself-hosting

这次和过去十个「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 + 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-alpine8.3.33公网(6 个站)有——8.3.36
VPSmailcow phpfpm8.2.29公网(它自己的 web UI)有,藏在 mailcow 2026-10 里
CyberPanel VMlsphp84(8 个站)8.4.25公网(8 个站)有——8.4.27
CyberPanel VMlsphp81 / lsphp82 / lsphp838.1.34 / 8.2.33 / 8.3.33未使用 / 默认回退8.1 永远没有,其余有
DSMWeb Station PHP 套件8.2.30 / 8.3.30 / 8.4.19内网 + 隧道当时还没有——22 日 19:45 才出
DSMphpmyadmin:5.2.38.3.33SSO 后面有——同一个 tag 被重建了
DSM / unRaidpassbolt 5.15 / 5.148.4.24SSO / 内网有——5.17.0 带 8.4.27
unRaid 7.1.4webGUI 的 PHP8.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。那是一次带停邮窗口的应用升级,不是拉个镜像,所以它要单独排一个窗口。在那之前这个容器是修复前状态,把这句话写下来,比做一张看起来全都完事的表更有用。

如果重来一次,我会改什么

  1. 把版本探测做成定时任务,而不是事故发生时才做。 上面这套实测花了我一小时,因为是在压力下做的。一个每周在每台主机、每个容器里跑 php -v 的小脚本,本来能在通告落地之前就把这张表给我。
  2. 把面板数据库当成运行时状态来看。 我一半的困惑来自「面板说 8.4」对上「进程说 8.5」。两个真相来源本身就是 bug。
  3. 当天就删旧分支。 8.1 和 8.2 是装着的、没用的、不再支持的——194MB 纯靠惯性活下来的攻击面。现在它们没了。
  4. 每次都把配置测试的输出读出来。 那个括号 bug 每次重启都在打印自己的诊断,整整一个月。
  5. 假设厂商补丁会在事故中途到达。 我早上把 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 Station8.2.35 / 8.3.36 / 8.4.27SA-26:14 套件
DSM phpMyAdmin8.3.36同一个 tag,重建镜像
passbolt 主库 + 备库8.4.275.17.0
mailcow php-fpm8.2.29被 mailcow 版本钉死——2026-10 已排期
unRaid webGUI8.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 个容器的高可用自托管生产网络。

Lee Teong Hoe

Lee Teong Hoe · Mr Hoelee

Full-stack developer & DevOps engineer based in Malaysia. I run my own production infrastructure and build web systems for small businesses — this blog is the living record.

Hiring? Profile & stack · Malaysian SME? Website design & development ↗ · All posts