Featured image of post 收到阿里云 PHP2Shell 告警后,我紧张了十分钟

收到阿里云 PHP2Shell 告警后,我紧张了十分钟

手机弹出阿里云短信:PHP 爆出 9.2 分的远程代码执行漏洞。我第一反应是自己博客中招了,结果一查——全站一个 PHP 文件都没有。顺便把这个漏洞用白话讲清楚。

···

下午两点十二分,手机震了一下。

我正干着别的事,随手瞟了一眼,看到开头四个字是「【阿里云】」,后面跟着「安全风险通告」。当时心里就是咯噔一下——这条短信不是营销,阿里云的安全通知从来不发废话。

阿里云发来的告警短信

短信内容很短:

【阿里云】尊敬的 aliyun*33711: 【安全风险通告】PHP 曝出"php2shell"远程代码执行漏洞(CVE-2026-109618、CVE-2026-109619)。如您的业务使用 PHP,请尽快自查并升级至安全版本,避免服务器遭受攻击。

PHP。远程代码执行。

那一瞬间我脑子里过的画面是:博客被人挂上一句话木马、首页被改成黑底红字的乱码、服务器在替我挖矿、账单开始飙……

虽然我盯着"如您的业务使用 PHP"这几个字看了两遍,但说实话,第一反应还是——我这站,到底是不是 PHP 的?

先别慌,我的站到底是什么

写博客这件事有个坏处:搭完之后就很少再想底层细节了。我确实记得自己是拿 Hugo 弄的,但"Hugo 是什么"和"我的服务器上装了什么"是两件事,中间隔着 Nginx、宝塔、几个自己加的小服务。人一紧张,这些细节就全糊在一起了。

所以我干脆不靠记忆,直接上服务器查。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
# 1)网站目录里有没有 PHP 文件
find /www/wwwroot/我的站点 -iname "*.php" | wc -l
# 输出 0

# 2)Nginx 配置里有没有把 .php 请求转给 PHP-FPM
grep -rn "fastcgi_pass\|location.*\.php" /www/server/panel/vhost/nginx/ | grep -v "^\s*#"
# 输出为空

# 3)这台机器上有没有装 PHP
php -v 2>/dev/null || echo "没有 PHP"

三条命令下去,答案很干净:零个 PHP 文件,零条 fastcgi 配置,机器上没装 PHP。

心跳恢复正常。

再回头审视一遍自己的架构,其实一目了然:

1
2
3
4
5
6
7
8
9
浏览器
  │  HTTPS
  ▼
Nginx ──► 站点目录  ├─ index.html   ← Hugo 编译产物,直接当文件发出去
                    ├─ css/js/img   ← 同上
                    └─ 没有 .php,也没有 fastcgi_pass
  │
  ├─► Go 服务(看板后端)
  └─► Node 服务(点赞接口)

整条链路上,PHP 出现的位置是零。Nginx 压根不认识 .php 这个后缀,请求打过来它要么返回 404,要么当成静态文件去磁盘上找一个不存在的文件。PHP 解释器没有机会被唤起,漏洞也就没有入口。

十分钟紧张,换来三行输出。值了。

不过在关掉这条短信之前,我倒是想把这事彻底搞明白——毕竟这个洞的评分是 9.2,而且我身边用 WordPress 建站的朋友一抓一大把。于是我把官方的技术公告翻了一遍。

这个洞,到底是怎么回事

两个 CVE 编号不同,但病根是同一个:PHP 在处理"出错"的时候,脑子不清醒。

第一个洞:内存耗尽时的"半成品"

PHP 有个配置叫 memory_limit,意思是"单个请求最多能吃掉多少内存,超了直接掐死"。这是道保险丝,防止一个烂请求把整台服务器拖死。

问题就出在"掐死"的那一瞬间。

打个比方:

你在拼一张桌子,四条腿只装了一条。 老板突然喊"时间到,收工!"——这就是 memory_limit 触发。 正常流程应该把半成品零件收回去放好。 但清理代码犯了个错:它把这张歪桌子当成了成品,还照着图纸去拆。 而图纸指的位置,可能正好是上一批零件堆里、被攻击者塞过东西的地方。

技术一点的说法:memory_limit 超限时,PHP 用 longjmp() 直接跳出去中断请求。这一跳可能发生在内部请求数据只初始化了一半的时刻——典型场景是正在解析 multipart 表单上传。接着清理流程把未初始化的无效数据当成有效数据去释放内存,而那块内存里可能还留着攻击者能控制的内容。内存被破坏,然后被利用成执行任意代码。

这条最要命的地方在于:在 Apache + mod_php 的部署方式下,一个请求就能触发,而且是在你的业务代码执行之前。也就是说,哪怕你的 index.php 里只写了一行 echo "hello",也可能被打穿。你代码写得再规范都不管用,因为这个洞在解释器里。

第二个洞:上个客人的东西没打扫干净

PHP-FPM 是进程池模式。一个 worker 处理完请求不会销毁,而是接着处理下一个——省去反复启动的开销。这是性能上的好事,但对"清理干净"的要求极高。

再打个比方:

客人退房了,保洁以为收拾完了,其实他一只拖鞋还留在床底下(内存已释放,但指针还指着那儿)。 新客人进来一屁股坐下,脚正好踩上那只拖鞋——踩空了,摔了。

这在程序世界里叫"释放后使用"(Use-After-Free),内存类漏洞里最经典的一种。属于某个请求的内部状态,在内存被释放后指针还残留着;如果中途发生一次致命错误,清理路径可能被跳过。等同一个 worker 接下一个请求时,某些逻辑只要在正常初始化之前抢先跑,读到的就是"上一位客人的残留"。

利用通常是两个请求配合:第一个制造残留,第二个去踩。前提是落到同一个 worker 上——多打几次总能碰上。

官方确认的一条现成路径是 session.upload_progress,也就是那个让 PHP 在上传大文件时显示"已上传 37%“的功能。这玩意儿绝大多数人根本不用,但它默认开着。

一句话对比

CVE-2026-109618CVE-2026-109619
病根内存耗尽时的清理逻辑请求之间的状态没重置干净
比喻半成品当成品拆,拆错位置上个客人的残留被下个客人踩到
漏洞类型内存破坏(OOM corruption)释放后使用(Use-After-Free)
门槛单请求即可(mod_php 下)通常要两个请求命中同一 worker
已知入口multipart 表单解析session.upload_progress、致命错误回溯

影响范围:比想象中广

修复版本是这几个:

分支安全版本
PHP 8.5≥ 8.5.12
PHP 8.4≥ 8.4.27
PHP 8.3≥ 8.3.36
PHP 8.2≥ 8.2.35
PHP 8.1 及更早已 EOL,无官方更新(官方给了 7.4 / 8.0 / 8.1 的源码回溯补丁)

运行方式上,官方原话是”所有处理 Web 请求的 PHP 安装",点名了 PHP-FPM、Apache + mod_php(风险最高)、php-cgi、FrankenPHP、LiteSpeed。

但真正让我意外的是连带范围。很多产品是把 PHP 打包在里面的:WordPress、Drupal、Joomla 这些建站程序,Laravel 这类框架,宝塔面板的网站功能,各种 NAS 套件、路由器管理页,还有一堆基于 php:8.x-fpm 的容器镜像。

荷兰 NCSC 甚至专门建了个仓库登记"哪些软件内嵌了受影响的 PHP"。也就是说——就算你的主站不用 PHP,服务器上、内网里、某个设备的后台,也可能藏着一个 PHP。

顺便记一下时间线,感受一下这次的节奏:

时间事件
10-20PHP 官方罕见地提前打招呼:明天发紧急补丁
10-21 13:30 UTC补丁发布
10-21 当天欧盟 CERT-EU、英国 NCSC、新加坡 CSA、瑞士 BACS 同日发高危通告
10-22技术细节和 PoC 已在网上流传

官方会"提前预告",说明严重到必须让运维提前排队;多个国家级机构在同一天发告警,这种规格一般只给"人人都用的东西出了要命的洞"。而 PoC 一旦公开,就意味着拿现成脚本的扫描会按小时来——这种事从不按天算。

为什么这次让人有点火

翻公告的时候还看到一篇 PHP 基金会自己写的复盘,读起来挺不是滋味。

大意是:2024 年 PHP 做了多年来第一次外部安全审计,审计收尾时就摸到了 OOM 这个问题的边。同年已经有人推演出"理论上能打成 RCE",但当时没找到具体路径,其他有明确影响的漏洞更紧急,就压下去了。

后来要修,发现有几种改法,每种都有代价:破坏向后兼容、引入新的拒绝服务风险、或者要改一堆第三方扩展(基本没法协调)。安全团队是共识制,没人敢拍板,怕选错。于是"修—停—再修"反复折腾了几个月。

基金会原话是:

我们不打算掩饰,这次延迟是一次结构性的失败,既有决策流程的问题,也有人力不足的问题。

这段话我看了两遍。它说明的事其实很朴素:开源基础设施的维护者常年人手不够。 你依赖的东西越是"默默无闻地支撑一切",出问题时越是没人兜底。PHP 撑着大半个互联网,但它的安全团队要靠基金会筹钱养。

如果你的站真是 PHP

我把这段整理出来了,万一以后要帮朋友处理,不用再翻公告。

唯一真正的解法是升级。 官方说得很直白:没有完整的缓解措施。

1
2
3
# 先看清现状
php -v
php-fpm -v 2>/dev/null

宝塔用户:软件商店 → PHP → 更新或切到 8.3.36 / 8.4.27 / 8.5.12,然后必须重启 PHP(老进程还在跑旧代码,光换版本没用)。

Debian / Ubuntu:

1
2
sudo apt update && sudo apt install --only-upgrade php8.3-fpm
sudo systemctl restart php8.3-fpm

Docker 的话,docker restart 没用,镜像得重建:

1
2
docker pull php:8.3-fpm
docker compose up -d --build

升完还得确认三件事,这几个坑我都见过:

1
2
3
4
5
6
# 1)实际运行的版本真的变了吗
php -v
# 2)worker 是不是全重启了
ps aux | grep php-fpm | grep -v grep
# 3)角落里有没有第二套老 PHP(多版本共存时常见)
ls /usr/bin/php* /usr/local/bin/php* 2>/dev/null

实在升不了级,只能做临时缓解,但要有心理准备:这些措施只降低特定路径的暴露面,不会让你的安装变安全。

针对 OOM 那个洞:

1
2
3
4
5
; 移除可控的内存上限失败(要写在 FPM pool / Apache 配置里,用 php_admin_value,防止应用改回来)
php_admin_value[memory_limit] = -1
; 收紧请求体
post_max_size = 8M          ; 不上传就 8M,需要上传就 32M
upload_max_filesize = 2M    ; 必须小于 post_max_size

注意别用 RLIMIT_AS / RLIMIT_DATA 去兜内存——它们造成的分配失败正好会走进漏洞路径。要用容器或 cgroup 限制,并且配成"只杀出问题的那个 worker"。

针对残留状态那个洞:

1
2
session.upload_progress.enabled = 0     ; 官方确认的一条利用路径,直接关掉
fatal_error_backtraces = 0             ; 仅 PHP 8.5 有效

改完必须重启所有 worker,而且要写在 php.ini、FPM pool 或服务器配置里——不能靠 ini_set() 或 .user.ini,那时候请求已经在解析了,晚了。

还有个容易忽略的点:用 error_reporting、display_errors 或者 @ 把警告藏起来是没用的。问题不在"显示",在于它被触发了。那些无效的、已废弃的 per-request 设置,得真的清掉。

最后,补丁只修漏洞,不修复已经发生的入侵。如果站已经被人摸进去过,升级之后还得查:

1
2
3
4
5
6
# 有没有可疑进程
ps aux | grep -E "php|sh -c|curl|wget" | grep -v grep
# 网站目录有没有被塞进奇怪的文件(一句话木马通常文件名短、体积小)
find /www/wwwroot -name "*.php" -newermt "2026-10-20" -ls
# 定时任务有没有被加东西(挖矿、回连的常见手法)
crontab -l; ls -la /etc/cron.* /var/spool/cron/ 2>/dev/null

真发现痕迹就别自己清了,断网、留证据、找专业应急。

虚惊一场之后

关掉那条短信的时候,我有点庆幸当初选了静态站。

但冷静下来想,这种"庆幸"其实是有代价的——静态站把 PHP 这一整类风险换掉了,代价是评论、点赞、搜索这些动态功能全得另外搭:Twikoo 要单独跑一个 Node 服务连 MongoDB,看板要单独跑一个 Go 服务,每一个都是新的攻击面。风险没有消失,只是挪了位置。

所以这条短信对我来说不算白紧张。至少现在我清楚地知道:自己的站靠什么在跑、哪些端口开着、万一真中招该从哪查起。这种"知道"比"应该没事吧"踏实得多。

至于我那些用 WordPress 的朋友,我已经把消息转过去了——他们的 PHP 是真得升级。


参考资料