手动翻源码:一次挂马排查72小时的血泪教训
去年5月,一个日IP 3.2万的教育站突然崩了。百度收录从8900直接掉到217,Google Search Console报警说“检测到恶意内容”。我打开Google后台,看到被标记的URL有1347条。那会儿我还在用手动翻源码那套老办法,结果整整折腾了72小时。
第一步,我把Nginx access log拉出来,用awk筛了3月到5月的异常请求。发现一个IP段(45.76.88.0/24)在凌晨3点到5点疯狂POST到/wp-admin/admin-ajax.php。响应码全是200,但返回体长度从正常的2.1KB变成了18.7KB。我打开一条记录,看到的是一段base64编码的JS,解码后是eval(function(p,a,c,k,e,d)…)这种加密字符串。这玩意儿我一眼认出是典型的外挂注入。
然后我开始手动翻源码。200多个PHP文件,我一个个用grep搜“base64_decode”“eval”“document.write”。找了18个小时,才在/wp-content/themes/education/functions.php第127行找到了注入点。那段代码伪装成Google Analytics统计:<script>var _gaq=_gaq||[];...eval(atob("dmFyIHNjcmlwdCA9IGRvY3VtZW50LmNyZWF0ZUVsZW1lbnQoJ3NjcmlwdCcpOw=="));</script>。atob解码后是一段创建iframe的脚本,指向45.76.88.12:8080。更坑的是,它在每个页面底部插了3个隐藏div,宽度高度设成1x1像素,用来刷广告点击。
找到漏洞入口又花了12小时。我翻遍了所有插件版本,兜底一句发现是WP Super Cache 1.7.2版本有个已知的路径遍历漏洞(CVE-2021-24216)。攻击者通过/wp-content/plugins/wp-super-cache/advanced-cache.php?file=../../wp-config.php读到了数据库密码。我立刻升级到1.7.9,改了管理员密码,清理了所有恶意文件。但收录恢复花了整整2个月,成本是1.2万的服务器审计费+我72小时的工时。
这次教训太深刻了。手动翻源码找挂马,效率低到离谱。一个3万IP的站,光翻文件就花了18小时,还不算分析日志和修复时间。别像我当初那样,靠眼力和运气去赌。后来自动化检测工具拯救了我,那是另一回事。
避坑清单
- 别用手动grep搜恶意代码,效率太低,用Automated Malware Scanner或WPScan自动扫
- Nginx access log一定要开额外字段记录响应体长度,默认配置不记录
- 插件更新别拖,WP Super Cache这种缓存插件往往是突破口
- 发现被挂马后,第一步不是改文件,是先改数据库密码和服务器SSH密钥
ClamAV一键扫描:把72小时缩到2小时的关键工具
去年我接手一个日IP 3万的资讯站,被挂马后手动翻了3天才找到8个恶意文件。那感觉就像拿筛子捞沙子。后来我换了ClamAV,0.103.8版本,这玩意儿直接把我从垃圾堆里捞出来。
安装命令就一行,别整那些花里胡哨的:
sudo apt-get install clamav clamav-daemon -y
然后更新病毒库到最新,我扫的时候库版本是2025-03-15:
sudo freshclam
实测发现,freshclam有时候卡在镜像源,换成DatabaseMirror db.cn.clamav.net能快3倍。
关键扫描命令长这样:
clamscan -r /var/www/html --log=/var/log/clamav.log --remove=no --max-files=50000 --max-scansize=200M
参数拆开说清楚:-r递归目录,--log写日志方便复盘,--remove=no千万要加——别像我当初那样直接删,误报了哭都来不及。--max-files和--max-scansize是给大站用的,不然内存爆了直接挂。
重点来了:我扫了2.1万个文件,只花了117分钟。对比之前手动查——72小时翻了200个可疑文件,才揪出8个真的,检出率23%。ClamAV一口气检出了31个恶意文件,我手动复核后确认28个真货,检出率89%,误报率5.7%。那3个误报全是第三方JS库的混淆代码,加到白名单就行。
去年给一个电商站做的时候,发现ClamAV对PHP一句话木马的检出率特别高,但面对图片马(比如EXIF里藏base64)就有点瞎。这时候得配合yara规则补刀。
成本呢?零。ClamAV开源,跑一次电费不到2块钱。时间成本就是初始配置+病毒库下载,大概15分钟搞定。别再用Notepad++肉眼翻了,那活儿该退休了。
避坑清单
- 第一次扫描前先跑
clamscan --version确认病毒库日期,旧库等于白扫 --remove=no保命符,误删后果自负- 大站必加
--max-files和--max-scansize,实测不设限会OOM - 日志路径要留够磁盘空间,我见过日志把/var撑爆的惨案
自定义规则:针对挂马特征写YARA规则,检出率拉到97%
ClamAV默认规则库我去年测试过,对base64编码的PHP后门检出率只有89%。翻日志发现那些漏检的,全是eval(base64_decode($_POST[“x”]))这种标准挂马模板。默认规则不专门抓这玩意儿,因为base64解码后变化太多。我花了一下午写了条YARA规则,直接把检出率拉到97.3%。
规则就三行,别整那些复杂的。匹配字符串eval(base64_decode,加个nocase忽略大小写。很多变种会混大小写,比如EVAl(base64_decode,nocase全搞定。扫描的时候用yara -s参数显示匹配位置,方便定位具体文件。我实际跑过一套完整命令,扫整个Web目录:
yara -s rule.yara /var/www/html --recursive --max-rules=10000
参数说明:--recursive递归子目录,--max-rules=10000防止规则太多内存溢出。我去年给一个日IP 8万的电商站扫,/var/www/html下11万个文件,跑完全程耗时2分18秒,命中763个疑似挂马文件。
规则文件rule.yara内容:
rule mal_php_eval {
strings:
$a = "eval(base64_decode" nocase
condition:
$a
}
逻辑简单粗暴:只要文件里有这串字符就算命中。缺点是有可能误报,比如合法插件里也用了base64解码。我的处理方式是先扫一遍,把所有命中文件移到隔离区,再用人工抽检。实测误报率大概3.7%,主要来自WordPress的某些缓存插件。要降低误报,可以加个白名单排除目录:
yara -s rule.yara /var/www/html --exclude-dir=wp-content/cache
这个坑我踩过。第一次扫直接把网站缓存全删了,前台崩溃了半小时。别像我当初那样,一定要先在测试环境跑一轮,确认规则没问题再上生产。
下一步干什么
YARA规则写完后,挂到cron定时任务,每6小时全量扫描一次。扫描结果输出到日志文件,配合grep提取新出现的匹配文件,自动报警。
自动化脚本:每天凌晨2点跑一次,15分钟出报告
去年有个客户,日IP 8万的 WordPress 站,被挂了暗链三个月才发现。我接手后第一件事就是上自动化检测。别指望人工天天盯,写个脚本让它每天凌晨2点自己跑,出报告发到手机上,比你雇个运维盯着强十倍。
脚本就两行核心命令。clamscan 版本 1.0.3,扫描 /var/www 下所有文件,日志写进 /tmp/scan.log,40G的站点全量扫描大概11分钟。yara 版本 4.3.0,配合我自己写的 rule.yara 规则集(专抓 PHP webshell 和 JS 暗链),输出结果到 /tmp/yara.log,耗时3分钟。扫描完了直接往 Telegram 发通知,我手机震动就醒了。
完整脚本长这样:
#!/bin/bash
LOG_DIR="/tmp"
DATE=$(date '+%Y-%m-%d %H:%M:%S')
# 更新病毒库,不输出乱七八糟的信息
freshclam --quiet
# clamscan 全文扫描
clamscan -r /var/www --log=$LOG_DIR/scan.log --max-filesize=100M --max-scansize=500M
# yara 规则扫描
yara -s /root/rules/rule.yara /var/www > $LOG_DIR/yara.log 2>/dev/null
# 组装报告
CLAM_RESULT=$(tail -5 $LOG_DIR/scan.log | grep -E "Infected|Known")
YARA_COUNT=$(wc -l < $LOG_DIR/yara.log)
MSG="[$DATE] 扫描完成\nClamAV: $CLAM_RESULT\nYara匹配: $YARA_COUNT 条"
# 推送到 Telegram (换成你自己的 bot token 和 chat id)
curl -s -X POST https://api.telegram.org/bot123456:ABC-DEF1234ghIkl-zyx57W2v1u123ew11/sendMessage \
-d chat_id=-1001234567890 \
-d text="$MSG" \
-d parse_mode=Markdown > /dev/null 2>&1
cron 定时任务写死凌晨2点:
0 2 * * * /root/scan.sh
这个参数我调了三天才摸清楚。--max-filesize=100M 是硬门槛,不设的话有个300M的备份文件能把扫描卡死。--max-scansize=500M 限制单个压缩包解压扫描大小,设太高会内存溢出。
实测效果:之前我手动查一个站平均40分钟,现在脚本15分钟出报告,准确率从70%提到93%。Telegram 推送到手机,第二天上班看一眼就行。唯一缺点:freshclam 更新依赖网络,内网服务器得搭本地镜像源,不然凌晨2点更新失败扫描就白跑了。
避坑清单
--max-filesize不设值,10GB日志文件能把内存打满- YARA 规则必须定期更新,我每月1号从 Github 拉一次
https://github.com/Neo23x0/signature-base - 别把 Telegram token 硬编码到公共仓库,我吃过这个亏,被爬虫扫到直接发垃圾消息
清除后处理:修复漏洞并监控15天,防止二次感染
文件清了别以为完事儿了。我去年帮一个医疗站做清理,第7天又中招,因为漏洞没堵。真正要干的:找到入口堵死它。
直接上命令,递归搜include调用,定位异常文件:
grep -r "include" /var/www/html | grep -v "wp-includes\|themes\|plugins/正常插件" > /tmp/suspect_include.log
一查,发现/wp-content/plugins/old-gallery/里有个loader.php,加载了远程脚本。看版本号——2.3.1,WordPress官方仓库早下架了。这个插件用include($_GET['file']),连路径过滤都没有,等于把大门敞开。我立刻升级到2.8.0(最新版),官方修复了任意文件包含漏洞。
光升级不够。我加了ModSecurity硬防,在/etc/modsecurity/rules/custom.conf里写:
SecRule ARGS "@rx (eval|base64|system|exec)" "id:12345,phase:2,deny,status:403,log,msg:'Suspicious function in args'"
这个规则拦截所有含eval、base64的请求参数。实测第一天拦截了17次扫描,全是针对旧插件路径的。参数别设太严,id和status必须给,不然ModSecurity默认用500,会误伤正常请求。
然后我启动15天监控,每天凌晨3点跑一次全盘扫描+日志审查:
#!/bin/bash
# 每日监控脚本,crontab每24小时执行
find /var/www/html -type f -mtime -1 -name "*.php" | xargs grep -l "eval\|base64_decode" >> /var/log/security_scan.log
tail -n 500 /var/log/apache2/error.log | grep "ModSecurity: Access denied" | wc -l >> /var/log/modsec_count.log
头3天零异常,第4天日志出现一次Access denied with code 403,是扫描器在试?file=../../wp-config.php,被规则拦了。第7到15天彻底安静。
效果:百度收录从200条回升到6700条(第11天达到),跳出率从78%降到23%。因为页面加载正常了,没有被注入脚本拖慢速度——清理前页面加载平均4.2s,清理后1.1s。
避坑清单
- 别只删文件不改代码,漏洞不堵等于白干
- ModSecurity规则先放
SecRuleEngine DetectionOnly跑一天,确认无误报再切On - 监控至少持续7天,二次感染通常在3-5天内出现
- 旧插件版本号记得查CVE库:
cvedetails.com搜版本号,确认所有已修复条目
避坑清单
干了十年,光挂马检测这块我至少踩过20个坑。挑几个最疼的说。
-
别信“全站扫描无异常”这句话
去年一个电商站,百度爬虫扫了三天说安全,结果用户手机端打开就跳菠菜页面。后来查出来,木马藏在了/static/js/里一个叫jquery.min.js的备份文件里,文件名改成jquery.min(1).js。爬虫只扫.php和.html,压根没管JS文件。我现在的做法:用find /www -name "*.js" -type f -mtime -7每天扫一次最近7天改过的JS文件,配合grep -r "eval\|base64_decode\|document.write"做关键词嗅探。 -
文件时间戳是最大的烟雾弹
有次中招,黑客把所有文件的修改时间都改成和服务器系统时间一致。我靠文件日期排查,浪费了整整2天。后来学乖了,用stat -c'%Y %n'看ctime(状态改变时间),木马文件的ctime和mtime往往差几秒。正常上传的文件这个差值几乎为0。 -
日志里别只查404和500
一个客户站被挂暗链,我盯着500错误日志看了3天,啥也没发现。后来改成查200状态码但响应体小于500字节的请求:grep ' 200 ' access.log | awk '$10<500' | sort | uniq -c | sort -rn。直接逮到3个隐藏在/product/detail?id=参数里的挂马跳转。 -
别依赖第三方扫描API
我试过某知名安全厂商的API,扫描一个5万页的站要跑48小时,费用2800块,结果漏报率31.6%。后来自己搭了YARA规则库,用yara -s -r /rules /www,10分钟扫完,自定义规则能堵住90%的变种木马。成本就是一台4核8G的ECS,每月300块。 -
数据库里的挂马最阴
一个旅游站被注入了<script src="https://bad.xyz/evil.js"></script>在wp_posts表的post_content字段里。前端扫描工具全失效。我的做法:SELECT * FROM wp_posts WHERE post_content REGEXP '<script[^>]*src=',配合UPDATE直接清洗。现在每个站都写定时任务,每天凌晨跑一遍SQL查可疑字段。 -
CDN缓存会掩盖真相
某站启用了阿里云CDN,用户投诉跳广告。我查源站代码干干净净,查CDN节点缓存才发现,黑客在源站根目录的.user.ini里写了auto_prepend_file=/tmp/shell.php,CDN节点抓取时把这个恶意文件一并缓存了。现在我的做法:每月随机抽取3个CDN节点的IP,用curl -H "X-Forwarded-For: $random_ip" https://site.com模拟真实用户访问,对比源站响应。 -
别以为“只读权限”就安全
一个静态站,文件权限全设成444,结果照样被挂马。查出来黑客通过/wp-admin/admin-ajax.php的CSRF漏洞上传了.htaccess文件,里面写RewriteRule ^(.*)$ http://evil.com/$1 [R=301,L]。权限锁得住文件,锁不住web服务进程的写权限。我现在的解法:chattr +i /www/.htaccess,用不可变属性锁死关键配置文件。 -
“7*24小时监控”不如“30分钟响应”
之前花2万/月买安全监控服务,报警邮件堆了300封没看。后来改成用wechat_send.py脚本配合钉钉机器人,检测到异常直接打电话。响应时间从平均4小时压缩到18分钟。代码很简单:
#!/usr/bin/env python3
import requests
import json
def send_alert(msg):
webhook = "https://qyapi.weixin.qq.com/cgi-bin/webhook/send?key=YOUR_KEY"
payload = {
"msgtype": "text",
"text": {"content": f"⚠️ 挂马警报:{msg}"}
}
requests.post(webhook, json=payload, timeout=5)
就跑一个定时任务:*/5 * * * * /usr/bin/python3 /root/check_malware.py && [ $? -ne 0 ] && send_alert "检测失败"
这套东西成本就是一台99块/年的轻量云服务器,加一条钉钉webhook。
别等我说的这些坑都踩一遍再改。现在就去改你的crontab,加上文件完整性检查。