WordPress 是最新的,PHP 却不是:一次紧急漏洞修补和之后的自动化

WordPress 是最新的,PHP 却不是 — 封面

10 月 21 日,PHP 官方发布了一次安全更新,修复两个可以远程执行代码的漏洞:CVE-2026-109618 和 CVE-2026-109619,评分都是 9.2。修复版本是 8.5.12、8.4.27、8.3.36 和 8.2.35(php.net 公告)。漏洞细节在发布前就泄露了,当天网上已经有利用代码在流传。

我第一反应是:我的 WordPress 开着自动更新,后台也显示“您使用的是最新版本”,应该没事吧?查下来发现,恰恰是这种“后台一片绿”的状态最容易让人放松。

一、WordPress 是最新的,PHP 不是

站点用 Docker 部署,WordPress 跑在官方镜像 wordpress:php8.3-apache 里。这里面其实有两层,各自更新:

  • WordPress 程序本身:放在数据卷里,由 WordPress 的自动更新负责。后台“已是最新版本”说的是这一层。
  • PHP、Apache 和系统库:打包在镜像里。镜像不更新,它们就永远停在当初拉取时的版本。WordPress 后台管不到这一层。

站点健康页面里的“信息”标签能看到实际的运行环境:PHP 8.3.35,Apache 2.4.68。也就是说,漏洞版本正在线上跑着。

更糟的是部署方式。官方镜像的 apache 版本用的是 mod_php,PHP 基金会的说明里写得很清楚:这种方式风险最高,攻击者不需要登录,请求在到达 WordPress 代码之前就能触发漏洞(PHP Foundation)。插件装得再少、密码设得再强,在这里都帮不上忙。

二、修补:先备份,再换镜像,然后验证

好在 Docker 官方镜像在 21 号下午就重新构建了,php8.3-apache 和 latest 两个标签都已经带上了 8.3.36。修补就是三步:

cd /opt/stack
/usr/local/sbin/备份脚本 || exit 1          # 1. 先备份,失败就不往下走
docker compose pull wordpress              # 2. 拉新镜像
docker compose up -d wordpress             #    只重建 wordpress,数据卷不受影响

# 3. 验证:按分支比对修复版本
docker compose exec -T wordpress php -r '
$f=["8.2"=>"8.2.35","8.3"=>"8.3.36","8.4"=>"8.4.27","8.5"=>"8.5.12"];
$b=PHP_MAJOR_VERSION.".".PHP_MINOR_VERSION;
echo PHP_VERSION, (isset($f[$b]) && version_compare(PHP_VERSION,$f[$b],">=")) ? " PATCHED" : " NOT PATCHED", PHP_EOL;'

输出是 8.3.36 PATCHED,首页和登录页都返回 200,网站只中断了十几秒。

有一个坑值得提一下:如果 compose 文件里把镜像写死成某个具体的旧版本标签,比如带完整 WordPress 版本号的那种,pull 不会拿到新的 PHP,因为旧标签不会再重新构建。用 php8.3-apache 这类“跟着分支走”的标签,才能吃到安全更新。

三、补完还要看一眼:有没有已经被人进来过

漏洞暴露了将近一天,补上不等于没事。我做了三个最基本的检查:

  1. 上传目录里有没有 PHP 文件。wp-content/uploads 只该放图片和附件,出现任何 .php 都是危险信号。
  2. 漏洞公开之后有哪些 PHP 文件被改过。用 find -newermt 列出指定日期之后修改的文件,排除 WordPress 核心自动更新会动的目录。
  3. 后台有没有多出来的管理员。

三项都干净。顺手把没在用的三个官方主题和一个演示插件删掉了,少一个文件就少一个可能出问题的地方。

四、把这件事变成每周自动做

这次是碰巧看到了新闻。下次如果没看到呢?所以我写了一个每周运行的脚本,放进站点的运维仓库,和其他服务器脚本一样通过 GitOps 自动部署:

  • 时间:每周一凌晨 4 点多,在每天 3:30 的备份之后。
  • 只动有变化的:拉取 WordPress、MariaDB、Caddy 的新镜像,只重建镜像真正变了的服务。
  • 重建后检查:首页和登录页必须在两分钟内返回 200。
  • 不行就退回:检查失败时,把之前的镜像重新打回原标签,再重建一次。
  • 结果要有人知道:成功、失败、回滚都写进状态文件,并推送到手机。

回滚的关键是:更新之前先记下每个容器当前用的镜像 ID,而不是只记标签。标签在 pull 之后已经指向新镜像了,只有 ID 能找回旧的(示意):

# 更新前:记下每个服务正在用的镜像 ID 和标签
for s in $(docker compose config --services); do
  c=$(docker compose ps -q "$s"); [ -n "$c" ] || continue
  OLD[$s]=$(docker inspect -f '{{.Image}}' "$c")
  REF[$s]=$(docker inspect -f '{{.Config.Image}}' "$c")
done

# 检查失败时:把旧 ID 重新打回原标签,再重建
for s in $CHANGED; do docker tag "${OLD[$s]}" "${REF[$s]}"; done
docker compose up -d $CHANGED

脚本上线前,我用假的 docker 和 curl 命令把六种情况各跑了一遍:没有新镜像、正常更新、更新后网站挂掉触发回滚、试运行、拉取失败、不在执行窗口。每种情况的退出码、状态文件和推送都符合预期以后才推上去。

小结

  • WordPress 的“最新版本”只说明了一层。用 Docker 部署时,PHP 和 Apache 跟着镜像走,需要单独更新。
  • 镜像标签选“跟着分支走”的,比如 php8.3-apache,不要写死成某个具体的旧版本标签。
  • 补漏洞要有顺序:备份、换镜像、验证版本号、检查有没有被入侵。
  • 能自动做的就别靠记性:每周自动拉取,失败自动回滚,结果推送到手机。

上一篇
下一篇