起因:一个 CVSS 9.2 的核弹级漏洞
2026 年 10 月 21 日,PHP 安全团队发布紧急修复,一次堵上两个可被未认证远程利用的 RCE 漏洞:
- CVE-2026-109618(GHSA-w8hw-w9fx-j79w):OOM 处理路径的内存破坏。PHP 用 longjmp() 中止超出内存上限的请求,可能打断内部数据的初始化;随后的请求清理会把无效数据当作有效数据释放,而那块内存里可能还留着攻击者可控的内容。
- CVE-2026-109619(GHSA-6wmf-4rv5-p3rp):请求内存释放后残留的全局状态。某个请求拥有的内部状态在其内存被释放后仍然存活,同一个 worker 处理的后续请求会在正常初始化重置它之前,读到这份”陈旧状态”。
两者组合起来就是:一个未经身份验证的 HTTP 请求,在应用代码执行之前就可能拿下服务器。技术细节与 PoC 在发布后迅速流传,HKCERT 已确认存在在野利用。
影响范围:PHP 5.4 至 8.5.11。修复版本:8.5.12 / 8.4.27 / 8.3.36 / 8.2.35。8.1 及更早的分支不再有修复。
本文记录这次应急处置的完整过程,重点是我踩过的坑——其中第一个坑,让我后背发凉。
环境约束:为什么不能一把梭升级
手上的机器是一台 1.7GB 内存的小鸡,PHP 8.3.8 由源码编译安装在 /usr/local,系统包管理器和 PHP 版本已经脱钩;生产上跑着 WordPress 主站、一个静态站和一个反向代理站点。
看起来”升个级就行”的事情,实际有三个硬约束:
- 无包可升:Alpine 仓库里没有对应 PHP 包,apk 路线走不通;
- 上游镜像已弃更:常用的 tangramor/nginx-php8-fpm 镜像,8.3 线最新 tag 还停留在 php8.3.13,低于修复所需的 8.3.36,该镜像已转向 8.4/8.5 分支;
- 内存不够:1.7GB 的机器上源码编译 PHP(峰值 2GB+)会直接 OOM 失败。
结论:唯一彻底且可行的路径,是基于官方 php:8.5.12-fpm-alpine 镜像自建,编译 WordPress 所需扩展。
方案:官方基础镜像 + 自编译扩展
选择官方 docker-library 镜像而不是第三方”全家桶”镜像,理由很直接:信任链更短。官方镜像由 docker-library 专业维护,而我只需要在上面补 5 个 WordPress 必需的扩展:mysqli、pdo_mysql、gd、zip、exif、intl。
镜像供应链上做了一层校验:服务器无法直连 Docker Hub(原因见坑 3),只能走第三方镜像源。为确认拿到的就是官方原版,我从两个独立运营的镜像源分别拉取同一 tag,比对 manifest 摘要,完全一致才采用:
daocloud: sha256:4943e6c8a08008605b470f6171d611a89384bac97188e4b0a7aefd3f793cd040
1ms.run : sha256:4943e6c8a08008605b470f6171d611a89384bac97188e4b0a7aefd3f793cd040
Dockerfile 的核心部分:
FROM php:8.5.12-fpm-alpine
# 运行时库必须常规安装(写入 world),不能只作为 -dev 包的依赖存在
RUN sed -i 's|https://dl-cdn.alpinelinux.org/alpine|https://mirrors.aliyun.com/alpine|g' /etc/apk/repositories
&& apk add --no-cache nginx tzdata
libpng libjpeg-turbo libwebp freetype libzip icu-libs
&& apk add --no-cache --virtual .build-deps $PHPIZE_DEPS
libpng-dev libjpeg-turbo-dev libwebp-dev freetype-dev
libzip-dev icu-dev
RUN docker-php-ext-install -j$(nproc) mysqli pdo_mysql gd zip exif intl
# 清理时只删 virtual 包,运行时库不受影响
RUN apk del .build-deps
踩坑记录
坑 1:修复版版本号陷阱(最危险的一个)
我最初部署的是 PHP 8.5.4——理由听起来很充分:”8.5 是当前最新大版本线,拉个大版本线里最新的就行”。
直到收尾对账时,我注意到镜像的构建日期:2026-03-12。而补丁的发布日期是 2026-10-21。一个 3 月构建的二进制,怎么可能包含 10 月才发布的修复?
查证之后确认:修复版是 8.5.12,8.5.11 及之前的 8.5.x 全部受影响。我部署的 8.5.4,正在受影响名单里。
教训:修复版本号必须以官方公告为准,”最新大版本线”不等于”已修复”。后来我加了一道对账工序——拿镜像构建日期和补丁发布日期做交叉验证,这个笨办法这次救了命。
坑 2:apk del .build-deps 的隐形连带伤害
第一个镜像构建”成功”了,但启动时 PHP 报警:gd、intl、zip 三个扩展无法加载,提示缺 libpng16.so.16、libicuio.so.76、libzip.so.5。
原因很典型:编译这些扩展需要 libpng-dev、icu-dev、libzip-dev 等 -dev 包,而这些 -dev 包会把对应的运行时库作为依赖拉进来;最后一步 apk del .build-deps 清理时,运行时库作为”不再被显式需要的依赖”被一并删掉了。
解法:运行时库用独立的 apk add --no-cache 安装(写入 world 数据库),-dev 包走 --virtual,清理时只删 virtual 包。Dockerfile 里这四行顺序,值一次 OOM 的学费。
坑 3:Docker Hub 的三重封锁
服务器完全连不上 Docker Hub,层层排查后发现是三重封锁:
- DNS 污染:registry-1.docker.io 被解析到 Facebook 的 IPv6 地址;
- 即使把真实 IP 写进 /etc/hosts 修正解析,连接依然被 reset——基于 SNI 的域名阻断;
- 所有官方 IP 段测试下来无一幸免。
结论:只能走第三方镜像源。但第三方源意味着供应链风险——镜像提供方完全可以植入后门。解法是双源交叉验证(见上文),两个独立运营的源返回完全一致的 manifest 摘要,才认定内容可信。这比信任任何单一第三方源都可靠。
坑 4:Alpine 官方 CDN 的龟速
搞定了镜像源,下一个瓶颈是 Alpine 包源:dl-cdn.alpinelinux.org 实测只有 15KB/s,装一轮构建依赖要 20 分钟以上。
解法:在 Dockerfile 里把 /etc/apk/repositories 替换为阿里云源(4MB/s)。可能有同学会问:换源安全吗?安全——APK 包由 Alpine 官方签名,apk 安装时会验签,传输源只影响速度,不影响完整性。这与坑 3 的镜像摘要校验是两个独立的信任机制,正好互相印证。
坑 5:小内存机器的 FPM 进程数炸弹
排查现网配置时发现一个隐藏风险:FPM 池 pm.max_children = 64,而 memory_limit = 128M——理论峰值内存需求 8GB,物理内存只有 1.7GB。一波并发慢请求就能把整机 OOM 杀掉。
调整为 pm.max_children = 12(动态池,start 3 / spare 2-6),实测新容器稳定运行在 55MB 左右。调参的依据是物理内存,不是抄来的”最佳实践”。
坑 6:pkill -f 会杀死你自己
想杀掉一个跑偏的后台构建进程,pkill -f 'docker build' 敲下去,SSH 连接瞬间断开——因为这个模式匹配到了我自己的 SSH 远程命令行(整条命令里就包含 “docker build” 这几个字)。
解法:模式里加一个字符类空格,pkill -f 'docker[ ]build',匹配不到自身命令行。
坑 7:nohup 后台任务忘了 cd
另一个同样笨的小坑:后台启动构建时省略了 cd 工作目录,构建静默失败于 open Dockerfile: no such file or directory。nohup 的每条命令都值得显式写全工作目录。
可复用的应急处置清单
按时间顺序,这次处置的完整动线:
- 核实”修复版”的准确版本号——以官方公告/CNVD 为准,拒绝任何二手转述;
- 备份先行:数据库 dump、nginx 配置、compose 文件、容器 inspect 全量存档;
- 影子容器验证:备用端口(8080/8443)+ 只读挂载,现网无感,验证通过再切换;
- 供应链校验:镜像拉取走双源摘要交叉验证;
- 正式切换:改 compose 镜像 tag 重建容器,中断以秒计;
- 冒烟测试:三个站点经真实域名访问,确认 200 与内容完整;
- 对账复盘:构建日期 vs 补丁发布日期,确认部署的真的是修复版;
- 保留回滚资产:旧镜像 + 旧 compose + 数据库 dump,一条命令切回。
另外,补丁到位前的窗口期,按 CERT-EU 建议对生产施加了临时缓解(memory_limit=-1 封堵 OOM 触发路径、session.upload_progress=0、post_max_size=32M),补丁切换后即恢复正常内存策略。
结语
这次处置最大的收获,是坑 1 那种”版本号差几个月”的陷阱——技术上都对,组合起来却等于没打补丁。基础设施的对账值得做两遍:一遍在部署前,一遍在部署后。
所有踩过的坑都已归档进构建目录的 Dockerfile 注释里,下次重建不会重蹈覆辙。