适用读者:运行PHP站点、正在应用紧急PHP更新,或刚刚完成更新并担心存在遗漏的人员。
2026年10月,两个严重PHP漏洞(CVE-2026-109618和CVE-2026-109619)的修复版本发布。本站运营者管理的一个站点在当晚完成了更新。本页将那里发生的情况、两个最初被证明是错误的假设,以及之后对每台服务器应用的入侵复查,整理为通用步骤。漏洞及修复版本的详情见两个严重PHP漏洞与2026年10月的紧急修复版本。
发生了什么(本站运营者管理的站点)
该站点在多种环境中使用PHP。由于PHP团队已公布修复版本的发布时间,运营者在发布前切换到了维护页面,并在修复版本发布之前保持离线。发布后,他们替换为修复版本并解除了维护页面。
公告之后
切换到维护页面,等待修复版本发布之后
替换为修复版本,并解除维护页面更新后检查
Web端已更新,但cron使用的命令行PHP仍指向旧版本,于是重新指向了新版本当晚
用于解除维护模式的临时脚本在写入任何内容之前就停止了。原因:set -e与diff同时使用。没有造成损害,脚本已重新编写事后
对每台服务器应用了相同的入侵检查清单,并通过比较文件检查了一个在更新前到达的可疑请求
美国CISA随后将这两个CVE加入其已知被利用漏洞目录(KEV)(CISA公告)。这支持了紧急打补丁的决定,但补丁只能关闭入口。确认没有人已经进入,是另一项工作。
假设一:“Web端PHP已更新,所以完成了”
第一个假设是:只要站点运行在新版本上,该服务器上的所有PHP都已更新。实际上,cron调用的命令行PHP仍指向旧版本。
PHP根据运行方式可以是不同的程序(SAPI,服务器API)。php.net指出,CLI SAPI在许多方面与其他SAPI不同(PHP:从命令行使用PHP)。在同一台机器上安装了多个版本时,Web可能运行新版本,而命令行却运行旧版本。
Web请求 → PHP-FPM / Apache模块
运行已更新的版本(可在Site Health等页面中查看)
cron和shell脚本 → 命令行PHP
仍指向旧版本,Web端的检查无法显示这一点
分别检查Web与CLI
检查命令行PHP版本
php -v显示该shell调用的PHP版本。php.net指出,php -v还会告诉你PHP是CGI还是CLI版本。which php显示实际被调用的可执行文件的位置。
在安装了多个版本的Debian系系统上,检查命令指向何处
update-alternatives --display php显示php命令指向哪个版本(update-alternatives手册)。--config可以更改指向,但这属于服务器配置变更,应先确认操作步骤及如何撤销。
在cron任务中写明PHP的完整路径
cron在其自身环境中运行任务,与登录shell相互独立(crontab(5))。如果只写php,实际选用哪个PHP取决于该环境。本站的建议是:写明已打补丁版本的完整路径,使更新后的检查只有一个答案。
从Web侧检查Web端PHP版本
在WordPress上,后台的Site Health(站点健康)页面会显示该版本。在代码中,可以使用常量PHP_VERSION(版本)和PHP_SAPI(PHP的运行方式:CLI为cli,PHP-FPM为fpm-fcgi,Apache模块为apache2handler)(php_sapi_name)。
检查完成后立即删除phpinfo()页面
调用phpinfo()的公开文件会显示版本信息,但也会暴露配置和服务器内部细节。用完后应立即删除。不留存该页面,是使用此方法的前提条件。
假设二:“diff要么成功,要么失败”
用于解除维护模式的临时脚本,在写入任何内容之前先用diff查看当前内容与新内容的差异。脚本中开启了set -e。
diff发现差异时返回退出状态1。在GNU手册中,0表示无差异,1表示发现差异,2表示出错(GNU diffutils:diff)。发现差异正是脚本所预期的。但set -e(errexit)会在非零状态时停止shell,条件判断等少数位置除外。脚本因为存在差异而停止,从未执行到写入步骤。
因此,解除维护模式的步骤在未写入任何内容的情况下停止了,没有造成损害。它是在安全一侧失败的,但反过来的组合,即该停止时却没有停止,则会很危险。脚本已重新编写。
退出状态1并不代表“错误”的命令
| 命令 | 1的含义 | 2的含义 | 来源 |
|---|---|---|---|
diff | 发现差异 | 出错 | GNU diffutils:diff |
cmp | 发现差异 | 出错 | GNU diffutils:cmp |
grep | 未选中任何行(无匹配) | 发生错误 | GNU grep |
安全的写法
GNU Bash手册指出,在set -e下,若命令作为if测试的一部分、在除最后一个命令之外的&&或||列表中,或其状态被!取反,则shell不会因该命令返回非零而退出(GNU Bash:The Set Builtin)。应使用这一特性。
会停止,或掩盖真正的失败
- 在
set -e下直接写diff a b(仅因存在差异就停止) diff a b || true(同时吞掉“有差异”和真正的错误(2))- 在
grep上使用|| true,使“无匹配”与“无法读取文件”看起来相同
分别处理退出码
- 将退出状态保存到变量中,并根据0、1和2分支处理(推荐的写法)
- 如果只需判断是否存在差异,可以使用
if diff -q a b; then …,但要注意2(错误)会与“有差异”落在同一分支 - 如果使用
|| true,应先确定漏掉2(真正的错误)是否可以接受
下面的示例区分了0、1和2。||左侧的失败不受errexit影响,因此脚本不会停止,且rc保存了退出状态。
#!/usr/bin/env bash
set -euo pipefail
rc=0
diff -rq deployed/ live/ || rc=$?
case "$rc" in
0) echo "no differences" ;;
1) echo "differences: review before continuing" ;;
*) echo "diff failed" >&2; exit 2 ;;
esacset -e并不是完整的安全网。它有许多可能掩盖错误的例外情况,因此对于重要的分支,更好的做法是自己读取退出状态再作决定(本站的建议)。
打补丁之后:对每台服务器运行相同的检查
打补丁关闭了入口,但并不能告诉你,在更新之前是否已有人进入。对于KEV漏洞,CISA要求在更新之前检查是否存在入侵(参见PHP文章中的“已确认的利用与加入CISA KEV”)。为避免各服务器的检查结果不一致,运营者对每台服务器应用了相同的检查清单。
| 项目 | 应查找的内容 | 示例命令 |
|---|---|---|
| 登录密钥 | 你不记得注册过的公钥 | 打开authorized_keys |
| 用户 | 未知用户,管理员组的变化 | getent passwd、getent group sudo |
| 被修改的软件包文件 | 软件包安装的、自发布后已发生变化的文件 | debsums -s(Debian系)、rpm -Va(RHEL系) |
| 不属于任何软件包的可执行文件 | /usr/local、/opt或PATH中新出现的、没有软件包归属的可执行文件 | dpkg -S <file>(Debian系)或rpm -qf <file>(RHEL系);查找没有所属软件包的文件 |
| SUID | 以所有者权限运行的新文件 | find / -xdev -perm -4000 -type f |
| cron和systemd | 未知的计划任务和定时器 | crontab -l、/etc/crontab、/etc/cron.d、systemctl list-timers |
| /tmp | 放置在此处的可执行文件 | find /tmp /var/tmp -type f -perm /111 |
| 出站连接 | 通往你不认识的目的地的连接 | ss -tunp |
| 容器内部 | 正在运行的进程,以及启动后写入的文件 | docker top、docker diff |
crontab -l只显示当前用户的任务,因此应分别检查/etc/crontab、/etc/cron.d以及各用户的cron目录。ss -tunp需要root权限才能显示每个连接背后的进程。dpkg -S和rpm -qf可报告哪个软件包拥有某个文件;“没有所属软件包”的文件仍可能是你自己放置的,因此应与你记得安装过的内容进行核对。
find -perm -4000匹配设置了所有指定位的文件(find(1))。docker diff列出容器创建后发生变化的文件(A:新增,C:修改,D:删除),docker top显示正在运行的进程(docker container diff、docker container top)。容器通常是重新构建而非原地修改的,因此应将docker diff显示的可执行文件视为异常并进行调查(本站的建议)。
关于如何解读各项内容,以及共享主机、WordPress和VPS的步骤,参见如何检查您的网站或服务器是否被入侵。
通过比较文件确认可疑的日志条目
在此次检查中,团队发现,在更新之前,有一个来自Tor出口节点(隐藏发送者身份的网络出口点)的POST请求到达了联系表单。日志只显示请求已到达,并不能说明是否因此写入了任何内容。
因此,他们将实时公开文件与部署时保留的副本进行了比较。两者完全一致,也没有多余的PHP文件。此次检查没有发现任何文件被改动的迹象。
在服务器之外保留一份已部署内容的副本
如果用于比较的唯一副本位于服务器上,那么同时发生的变更将不会被发现。每次部署时,都应将已发布的状态保存在服务器之外。
与实时文件进行比较
使用diff -rq查找存在差异的文件,或比较各文件的哈希列表(sha256sum)。如果存在差异,应确认是否为你预期的变更。
同时查找新增的文件
即使内容一致,新放置的PHP文件也不会出现在原始副本中,因此必须单独查找。diff -rq同样会报告仅存在于一侧的文件。
一致并不等于安全
此方法只能说明你所比较的文件未被改动。它无法检查数据库、上传目录或服务器之外的变化。应结合其他检查综合判断(本站的建议)。
本站观点:在紧急之夜之前确定好检查清单
在仓促的夜晚,人们在哪台服务器上查看了什么,往往会有出入。如果检查项目事先确定,每台服务器都会进行相同的检查,遗漏之处也会显现出来。这次之所以发现了旧的命令行PHP,是因为更新后的检查没有止步于Web端。
今天可以做的事
- 如果可以登录服务器,运行
php -v和which php,并在cron任务中写明PHP的完整路径 - 从Web侧检查Web端PHP版本,例如使用Site Health(用完后立即删除任何
phpinfo()页面) - 找出
set -e脚本中调用diff、grep或cmp的位置,并确定如何处理退出状态1 - 在服务器之外保留一份已发布内容的副本,并在更新后进行比较
- 让每台服务器的入侵检查清单保持一致,并逐项检查
来源
- PHP:从命令行使用PHP
- PHP:php_sapi_name
- GNU Bash参考手册:The Set Builtin
- GNU diffutils:diff、cmp、GNU grep
- update-alternatives(1)、crontab(5)、find(1)
- docker container diff、docker container top
- CISA:将两个漏洞加入已知被利用漏洞目录
延伸阅读
- 漏洞与修复版本:两个严重PHP漏洞与2026年10月的紧急修复版本
- 入侵检查:如何检查您的网站或服务器是否被入侵
- 准备工作:备份与恢复基础 / 安全资产清单检查表
- 术语:什么是RCE(远程代码执行)? / 什么是IOC(入侵指标)?