第七星尘的独立博客

记一次PHP安全漏洞的马拉松式修复

前言

昨天PHP官方爆了个大洞,代号php2shell,两个CVE编号(CVE-2026-109618和109619),CVSS评分9.2,简单说就是未经认证的远程代码执行。trigger路径跟PHP的session upload progress机制有关,攻击者不需要登录,构造特定的multipart请求就能在你服务器上执行任意代码。

我早些年是写PHP的,最近几年转了全栈,新项目基本都是nodejs/ts。但老项目里PHP的存货不少:ShowDoc、一个WordPress博客、一个老的ThinkPHP项目,还有一台跑着PHP 7.2的老服务器和一个Nextcloud网盘。这些大多是好几年前开发或者安装的,有些保持着运行但早就不更新了,还在活跃开发的只有ShowDoc。也就是说,这次洞基本把我家底里的PHP存货全点名了。

于是从发现漏洞那天晚上开始,前前后后折腾了很久,把所有PHP资产都过了一遍。补一句时间线:其实漏洞细节公布前,下午我就在技术群里看到了应急招——关掉session.upload_progress。当时没多想,直接把能改的机器都改了。等晚上官方通告出来才发现,事情没这么简单:这不是一个洞,是两个CVE;而关闭upload_progress也只是封住了其中一条触发路径,不是根治。所以最终我还是把所有能升PHP版本的地方都升了一遍。先说一句,这次修复我基本是驱动AI干的——用OpenClaw一路沟通下来,编译镜像、搭测试环境、跑升级流程,都是AI在执行,我负责拍板和验收。要是搁以前自己手打命令,这种全资产排查的活,光是登录各台机器敲命令就够呛。不过也别把AI想得太神奇,效率是真的高,但过程一样踩了不少坑,需要人反复纠正,这个后面再说。记录一下过程,一来以后自己翻,二来兴许对同样中枪的人有参考。

先说一下资产盘点

漏洞出来第一件事不是急着升级,是把所有跑PHP的地方列个清单。列完发现有五种情况:

1,能直接升到修复版的(大多数)

2,能升但需要连带升级应用的(WordPress 5.9、Nextcloud 28这种老古董)

3,不能升版本、只能打配置缓解的(EOL的PHP 7.2)

4,停掉比升级划算的(一台没人用的老机器)

5,看起来升不动、重新评估后发现能曲线救国的(后面细说)

分类完心里就有数了,不同情况用不同打法。这一步看着不起眼,其实比后面所有修复操作都重要,不知道自己有什么,就不知道该修什么。

主力项目:自编译救急,官方镜像出来后回归官方

ShowDoc用的基础镜像是webdevops/php-nginx,8.3版本线。漏洞发布当天PHP官方就出了8.3.36。但webdevops什么时候跟进,一开始不好说,我担心它更新慢,就让AI尝试自己下载源码编译了个8.3.36。编译本身折腾,国内网络拉依赖很费劲,编译出来又发现少扩展,部分功能跑不起来,要反复测试补扩展。这么一算账,成本比想象中高得多。回头算时间,大部分就耗在这条线上:下载、编译、构建、测试,一轮发现问题再重来。后来发现webdevops其实更新挺快,第二天镜像就出来了——索性放弃自编译,直接用回官方镜像。结论:自编译是最后手段,上游还活跃的话,先等等。

不过官方镜像出来后还有个坑:CI构建机上的镜像缓存。构建机不主动pull的话,感知不到基础镜像tag已经更新,会继续用缓存里的旧版本构建。我就中了这个招,构建完以为升级了,一查版本还是旧的。加--pull参数能根治。

另外前面提到的那个缓解配置,这里展开说一下:

session.upload_progress.enabled = Off
session.use_strict_mode = 1

第一行就是切断本次漏洞的触发路径。但要说清楚的是,它是“切断已知路径”,不是修复洞本身——同一块代码,今天封住这条路,明天说不定又有人找到另一条。这也是为什么我最终还是坚持把所有能升的地方都升到修复版,而不是停留在缓解配置上奏效就收手。

WordPress:连带升级的连锁反应

博客是WordPress 5.9.3,官方确认5.9系列跑不了PHP 8.3。想升级PHP就得先升WordPress。Nextcloud同理,28的镜像也停在EOL版本,得一路升到32。

这个项目当初部署得很偷懒,整个目录连程序带内容COPY进镜像。所以升级路径是:让AI在本地搭一套测试环境(7.4旧版本原样跑通),文件替换升级到6.8.3,跑完数据库迁移,再切换PHP 8.3镜像做兼容性验证。测出来主题里有两个PHP8已经移除的旧语法,改掉,然后才敢动生产。这种"整套环境搭起来跑一遍流程"的活,AI几分钟就把测试环境拉起来了。

测试环境本身就踩了个大坑:容器起好了,本机curl正常,浏览器就是访问不了。排查半天,tcpdump都上了,最后发现是这台机器上Docker默认bridge网络的-p端口映射,外部根本访问不到,得建自定义网络才行。这种坑跟漏洞八竿子打不着,排查起来还是花了不少时间。

数据库升级是不可逆的,动手前dump一份。虽然测试环境已经把整个流程跑通过一遍,生产操作的时候还是保命要紧。要不是本地完整跑过一遍,生产上估计要翻车。

最麻烦的:PHP 5.5的老项目

一个归档状态的ThinkPHP 3.2项目,跑在PHP 5.5.9上。这个版本PHP官方2016年就停止维护了,补丁是别想了。

一开始想找社区有没有人发布了打了补丁的镜像。让AI搜了一圈。它用了个笨办法:不去搜CVE编号,而是把Docker Hub上所有nginx+php5的镜像按更新时间排序,漏洞发布之后重新构建过的才看。结果发现确实有一批镜像在漏洞后一两天内悄悄重建了——但全是PHP 7.2到8.4的,5.x一个都没有。PHP官方倒是开了个7.4的源码backport分支,5.x连分支都没有。

后来重新盘点代码,发现转机:看着吓人的老代码大部分根本没部署进镜像,真正拦住升级路的只有一处旧函数调用,而且这个项目对PHP版本的真实要求很低。最后方案是把基础镜像换成基于PHP官方7.4源码补丁分支构建的镜像,加上nginx和自己常用的扩展,封装成一个通用的底座镜像推进自己的私有仓库。

这样做的额外好处是:底座镜像跟上游解耦了,以后就算原作者删库也不影响。别的PHP 7项目以后也能直接复用这个底座。

升级过程中就踩了一个坑:ThinkPHP的Runtime目录必须给web进程可写权限,否则缓存写不进去直接白屏。全目录只读加固的时候要记得把这个目录单独放开。

升不了的:缓解配置兜底

还有一台老服务器,PHP 7.2是发行版仓库装的,升不动,业务又不能停。这种情况就只能配置缓解了,把前面说的upload_progress关掉,顺手加了一堆加固项:

disable_functions = exec,passthru,shell_exec,system,...
open_basedir = /var/www/html:/tmp
post_max_size = 8M

缓解不等于根治,官方原话是“只有更新才是完整的解决方案”。关掉upload_progress封住的是已知路径,同一块代码未来还可能被找到新的触发姿势,所以能升级的环境,别停在缓解上。但对实在升不了的环境来说,把攻击路径先堵上,再控制住爆炸半径,是能做的最优解了。

还有一台跑PHP 7.2的机器,上面站点已经废弃了,评估完直接stop加disable,比升级便宜多了。

最后

前面前言说AI干活一样踩坑,这里补上具体的:派AI去验证镜像里的PHP版本,它自作聪明跑去验证了不相干的东西,来回纠了两三次才拉回正轨;处理服务器临时密钥时,收尾清得太积极,密钥刚清掉就发现还有活没干完,又得重新生成重新授权;还有测试数据库实例中途被误删,只能重新导一遍数据。搭WordPress测试环境倒是很快,反而很顺。

折腾完这一次,手上的PHP资产基本都是带修复的版本了,应该又能安分用上几年。等以后再出问题再说吧。

以上。

 如无特殊说明,本站皆为原创。转载请注明来自第七星尘的独立博客的《记一次PHP安全漏洞的马拉松式修复》

评论