站点被入侵后我做了什么:清马、入口与加固

一次攻击留下三道口子:我的站点被入侵复盘

去年冬天,我值班时收到一条监控告警:博客首页响应体里出现了陌生字符串。打开一看,页面底部多了一个看不见的 iframe,指向一个博彩站。列表页和详情页都正常,只有首页被注入了。这条告警来自一个很土的办法——每天拉一次首页,检查正文里有没有关键词之外的可疑标签。

接下来的两天我做的事,就是这篇要讲的:怎么确认被入侵、怎么找到入口、怎么清干净、怎么加固。事后复盘,这台机器上其实一共有三道口子同时敞着,攻击者只用了一个很低级的漏洞,但另外两道让我的应急响应慢了整整半天。

先声明:我不是安全专家,这是一次普通站长的实战记录。文中的命令都在 Debian 12 + Nginx + PHP-FPM 8.2 + MySQL 8 的环境上验证过。

第一小时:先止损,别先好奇

发现异常后的第一反应不该是打开浏览器研究那串代码,而应该按这个顺序止损:

1. 保留证据再动手。把可疑文件、dmesg、Nginx 日志、MySQL 日志整体打包一份到站点目录之外:tar czf /root/evidence-$(date +%F).tar.gz /var/www/html /var/log/nginx /etc/nginx。做完再改任何东西。如果只是自己玩的小站,这条可以放宽,但如果哪天因为别的原因需要找服务商或警察,没证据就只能吃哑巴亏。

2. 快照。云服务器先打一个磁盘快照。这是最便宜的保险,最坏情况下能整机回滚到入侵前的状态。

3. 断开业务出口但不关机。不要急着 systemctl stop nginx 然后重启,重启会清掉 /tmp、清掉内存里的进程信息,反而销毁线索。正确做法是先把数据库账号密码改掉、把可疑的定时任务停掉,让攻击者拿不到新数据,再慢慢查。

4. 检查定时任务和 SSH 公钥。这是入侵者最爱留后门的两处:crontab -l、ls -la /etc/cron.d/ /etc/cron.hourly/、cat ~/.ssh/authorized_keys、cat /etc/passwd | grep -E 'sh$' 看有没有多出来的可登录用户。很多挖矿木马就是靠 cron 每分钟拉起一次,杀掉进程也没用。

第二小时:确认入口是哪里

我的入口很典型:一个两年前装的第三方「统计插件」,代码里有一处 eval($_POST['data']),作者大概是为了做「在线升级」。攻击者扫到 /plugins/stats/callback.php 这个路径,POST 一个 payload,就拿到了 webshell。这个 shell 只花了 30KB,放在 /var/www/html/uploads/2024/ 里伪装成图片,文件头还带了 GIF89a。

找到它靠的是三件事:

按修改时间找。find /var/www/html -type f -newermt '2026-01-01' -not -path '*/cache/*' -ls | sort -k11 -rn | head -50。正常站点的代码文件修改时间是部署时间,不可能在凌晨三点被改。我一眼看到了三个陌生的 .php,都在 uploads 目录里。

按 webshell 特征串找。grep -rIl -E 'eval\(|assert\(|base64_decode\(|gzinflate\(|str_rot13\(|preg_replace\(.*/e|\\\$_(POST|GET|REQUEST)\[' /var/www/html --include='*.php'。这个命令会有误报(正常框架里也有 $_POST),要人工过滤。eval 加 base64_decode 同时出现,基本可以直接判死。

按上传目录找 PHP 文件。这条规则应该是永久有效的:/uploads/、/images/、/attachments/ 下面不该有任何 .php 文件。我用 find /var/www/html/uploads -name '*.php*' -type f 找出四个,其中三个是历史遗留的「图片处理脚本」,一个就是马。

确认之后,先把文件改名隔离(不要立刻 rm,改名到 /root/quarantine/ 保留样本),再回来查它被访问了多少次——在 Nginx 日志里 grep 文件名,能看到攻击者的 IP、时间和 POST 的 payload 长度。这些信息决定了后面加固的重点。

第三小时:审计所有「静默执行」的地方

清掉那个文件之后,我以为结束了。事实上大部分入侵的清理工作都是在这里失败——攻击者留有第二入口。

数据库里的注入。检查 wp_options(或者 Typecho 的 typecho_options、自建站的 settings 表)里有没有异常的 HTML。我这次的 iframe 就是这样注进去的:攻击者通过 webshell 直接在数据库里 UPDATE 了模板配置。修复方法是查全库的 <iframe、<script 关键字:

mysqldump --single-transaction blog | grep -n -i -E '<iframe|onerror=|eval\(' | head -20

dump 出来 grep 是个笨办法,但够用,也最安全(不改库)。如果数据量大,可以逐个表查 SELECT COUNT(*) FROM t WHERE content LIKE '%<iframe%'。

模板文件。主题目录下的 footer.php、header.php 是最常被插广告的位置,因为这些文件被所有页面包含。用 grep -rIl -E '<iframe|document\.write|atob\(' /var/www/html/usr/themes/ 扫一遍。

系统层。这里是我踩的第二个坑——/etc/ld.so.preload 被写进了一个 rootkit 的 so 文件路径。这个文件默认不存在,一旦出现内容就基本可以确定是 rootkit。cat /etc/ld.so.preload 应该是「文件不存在」。另外查 ls -la /proc/<pid>/exe 有没有 (deleted),那是进程还在跑但文件已删的典型特征。

计划任务和 systemd。systemctl list-timers --all、ls -la /etc/systemd/system/*.service,看有没有名字诡异、ExecStart 指向 /tmp 或 /dev/shm 的单元。挖矿木马很喜欢 /dev/shm,因为它是 tmpfs,重启就没了,不留痕。

第四小时:把三道口子堵上

复盘时我发现这台机器上同时存在三个问题,它们互相「配合」,才让一次低级漏洞变成整站沦陷:

口子一:文件上传目录可执行 PHP。这是最常见也最致命的一条。uploads 目录下的文件被当作 PHP 解析,等于让攻击者把 webshell 当头像传上来执行。修法是在 Nginx 里单独给这些路径一个 location:

location ~* ^/(uploads|images|attachments)/.*\.(php|php5|php7|phtml|phar)$ {
    return 403;
    access_log /var/log/nginx/blocked_upload.log;
}
location ~* ^/(uploads|images|attachments)/ {
    add_header X-Content-Type-Options nosniff;
    add_header Content-Security-Policy "default-src 'none'; img-src 'self'; style-src 'self'";
}

注意 location ~* 是正则匹配,优先级高于普通前缀匹配,所以这条规则会先命中。不要在 uploads 目录里用 location ^~(前缀匹配的具体路径),那个的优先级更高,容易把上面的规则屏蔽掉。另外 .phar 也要拦,PHP 的 phar 反序列化漏洞不需要文件被解析,但拦掉更省心。

口子二:旧插件长期不更新。我那三个插件都是两年前装的,作者早已不维护。判断插件是否还值得留的办法很朴素:去官网看最近一年有没有更新,没有就直接删掉,功能要么自己写二十行,要么找替代品。不需要的功能直接删代码,比禁用好——禁用只是让入口不加载,文件还在,别人可以构造请求直接访问 plugins/xxx/callback.php。

口子三:PHP 的 disable_functions 是空的。默认配置下 PHP 可以执行 system()、exec()、shell_exec()、passthru()、proc_open()、popen(),这一串就是 webshell 变控制服务器的通道。把它们禁掉,webshell 就只能读写文件,危害小一个数量级:

; /etc/php/8.2/fpm/php.ini
disable_functions = exec,passthru,shell_exec,system,proc_open,popen,curl_multi_exec,show_source,symlink,pcntl_exec,putenv,dl
expose_php = Off
allow_url_include = Off
open_basedir = /var/www/html:/tmp
session.use_strict_mode = 1
session.cookie_httponly = 1

关掉这些函数前一定要确认你的站点和插件不需要它们。图片处理(ImageMagick 的 convert 调用)、PDF 生成、备份插件、有些缓存清理可能依赖 exec。稳妥做法是先在测试环境开一周,观察 error_log 里有没有 has been disabled for security reasons。另外 disable_functions 只对 FPM 生效,命令行(CLI)下的 php.ini 是另一个文件,这一点经常被忘掉——如果你有 cron 跑 PHP 脚本,它不受这条限制的影响。

一周后:让「再被入侵」变慢

加固做完不代表安全了,下一次被入侵只是时间问题,能做的是把发现时间和影响范围压小。

1. 文件完整性监控。最简单的方式是每天生成一次站点文件的哈希清单,和昨天比对:

#!/bin/bash
SITE=/var/www/html
FILE=/var/lib/sitecheck/hashes.txt
mkdir -p "$(dirname "$FILE")"
find "$SITE" -type f -name '*.php' -print0 | xargs -0 sha256sum | sort -k2 > "$FILE.new"
if [ -f "$FILE" ]; then
  diff "$FILE" "$FILE.new" | grep -E '^(<|>)' | sed 's/^/变更: /' | mail -s "站点文件变更" you@example.com
fi
mv "$FILE.new" "$FILE"

文件多了会有点慢,但每天一次跑在凌晨毫无压力。它能在文件被改的当天就发现,而不是等首页角落多出个 iframe 才发现。注意排除 cache 和 uploads 目录,否则每天都有几百条变更。

2. 首页内容探针。就是这次救了我的那个脚本:每小时拉一次首页,检查 HTTP 状态码、字节数是否在正常区间、正文里是否出现预期关键词,以及是否出现 iframe/script 的意外增多。三条任一异常就发消息。这个探针放在站外(另一家服务商的小机器或者免费的在线监控),因为站本身挂了或者被改了,站内监控也会跟着不可信。

3. 权限最小化。web 目录属主 www-data,文件 644,目录 755,不该有 777。find /var/www/html -type f -perm -o+w -o -type d -perm -o+w 应该返回空。上传目录需要写,但只给 www-data 写权限,且不允许执行。

4. 做一个「出事时能查」的准备。Nginx 的 access_log 保留 30 天以上;history 别关;MariaDB/MySQL 的 general log 平时不开(太占空间),但需要时能临时开。最重要的是把「所有服务商控制台账号开了两步验证」这件事做掉——攻击者如果拿到云控制台的密码,前面所有加固都不重要了。

写给不想重蹈覆辙的站长

这次事故真正让我难受的不是被入侵,而是「入侵发生在半年内,我半年没看过一眼日志」。站点能打开、文章能发布,就以为一切正常。事实上攻击者在我站上待了四个多月,中间还用它发了两万封垃圾邮件——是我被服务商停邮件端口才发现的。

所以现在我的检查清单是这样的:每天自动比对文件哈希和首页内容;每周看一眼 /var/log/nginx/ 里的 4xx/5xx 分布;每月 apt list --upgradable 并更新一次;每季度把备份完整恢复到一个临时机器上验证一次。这套流程花不了多少时间,但它把「发现问题」从「偶然瞄见」变成了「一定会知道」。

还有一点:网上流传的「一键清理 webshell」脚本一个都不要用。它们会把正常文件一起删掉、会绕过你保留证据的步骤、还可能本身就是马的下载器。清马这件事只有一条路——知道它从哪来,然后在那个入口上把门关上,否则删多少次都会回来。

Last modification:October 22nd, 2026 at 07:29 pm

Leave a Comment