文件完整性监控实战:用 AIDE + inotify + AppArmor 抓住服务器上的每一次文件篡改

为什么你的网站"看起来正常",其实早被改过了

做站长这些年,我踩过最深的一个坑,不是服务器被打崩,而是服务器一直在正常运行。网站能打开,后台能登录,日志也不报错,可首页底部被塞了一段看不见的跳转脚本,只在来自搜索引擎的访客身上触发。我是过了一个多月才发现——流量掉了,我还在怪关键词排名。

这类攻击有一个共同特点:它不破坏服务,只修改文件。挂马、暗链、SEO 劫持、伪装跳转、在 wp-config.php 里插一行后门——全部都是在你不注意的时候,改动磁盘上某个文件的一小段内容。杀毒软件在服务器上基本无效,nginx 的 access log 只能看到正常请求,dftop 也都干净。

所以真正能救你的,不是防火墙,而是文件完整性监控(File Integrity Monitoring,FIM)。它的原理朴素到近乎笨拙:记录下所有文件的哈希值,过一段时间再算一遍,不一致就报警。但就是这种笨办法,能抓到 90% 的篡改行为。

FIM 的三种实现路线,先搞清楚再动手

市面上做文件完整性监控的工具很多,但本质上只有三条技术路线,选错路线会让你在半年后彻底放弃维护它。

路线一:全量哈希入库(AIDE、Tripwire)

工具首次运行会扫描整个目录,把每个文件的路径、权限、属主、inode、mtime、SHA256 全部写进一个数据库。之后每次检查都是"重新算一遍哈希 + 跟数据库对比"。优点是能发现内容级改动,哪怕攻击者只改了一个字节;缺点是首次建库慢、数据库会随时间膨胀,且每次全量校验都吃 CPU 和 IO。

路线二:实时事件监听(inotify + fanotify)

靠内核的文件系统事件,文件一被写入就立刻触发。优点是实时,攻击者刚落地后门你就能收到告警;缺点是只报告"文件被改了",不报告"改成了什么",而且重启期间发生的变化、绕过 inotify 的写入(比如直接写块设备)会漏掉。

路线三:内核级强制访问控制(SELinux、AppArmor)

这一条其实是"预防"而不是"检测":它不告诉你文件被改了,而是直接阻止进程去改它——哪怕那个进程是 root。这是里面最强的一道墙,但配置成本也最高,放到后面单独讲。

我的建议是三线并行,但分阶段落地:先用 AIDE 建立基线并每日校验(兜底,能发现一切内容改动),再用 inotify 对最敏感的几个路径做实时告警(快速止血),最后给对外暴露的进程套上 AppArmor 规则(阻断未来)。下面按这个顺序讲。

第一步:用 AIDE 建立并维护你的文件基线

AIDE(Advanced Intrusion Detection Environment)是 Tripwire 的开源替代品,Debian/Ubuntu 直接装,不需要编译。

apt-get update
apt-get install -y aide aide-common

安装完你会发现 /etc/aide/aide.conf 里其实只是个引用,真正生效的规则在 /etc/aide/aide.conf.d/。Debian 的默认配置已经够用了,但我们真正要改的是扫描范围——默认配置会扫描整个 /,在建站服务器上这意味着几百万个文件、跑一次半小时,你会很快失去耐心。

更实际的做法是自定义一份配置文件,只盯住真正重要的路径。在 /etc/aide/aide.conf 末尾追加:

# ==== zz1984 站点自定义规则 ====

# 规则别名:p 权限, i inode, n 链接数, u 用户, g 组, s 大小, m mtime, ctime, S sha256
WEB = p+i+n+u+g+s+m+c+S

# 网站代码目录:必须严格监控,任何改动都要报
/www/wwwroot    WEB
/etc/nginx      WEB
/etc/php        WEB
/etc/mysql      WEB
/etc/cron.d     WEB
/etc/crontab    WEB
/etc/systemd/system WEB
/etc/passwd     WEB
/etc/shadow     WEB
/etc/sudoers    WEB
/root/.ssh      WEB
/root/.authorized_keys WEB

# 日志目录只监控文件是否消失或被截断,不比对内容(日志本来就在变)
/var/log   p+i+u+g+n

# 明确排除:缓存、会话、上传、临时文件
!/www/wwwroot/cache
!/www/wwwroot/*/cache
!/tmp
!/var/tmp
!/proc
!/sys
!/dev

这里有两个关键设计。第一,!/ 排除规则必须写在包含规则之后,AIDE 是按顺序匹配的,顺序反了排除就不生效——这是我第一次配 AIDE 时最大的坑。第二,日志目录绝对不要比对哈希。日志每时每刻都在写,你一比对就满屏告警,告警疲劳之后这个工具就废了。对日志我们只关心"文件还在不在、有没有被人清空",所以只查 p+i+u+g+n 这几个属性,不查 S(哈希)。

配好之后初始化数据库:

aideinit -y -f
# 生成 /var/lib/aide/aide.db.new
mv /var/lib/aide/aide.db.new /var/lib/aide/aide.db
# 首次校验,确认基线干净(应该没有任何 diff)
aide --check

初始化那一天必须是"确定干净"的一天。如果你已经怀疑被入侵了再去建库,那等于把木马当成合法文件记进基线,以后永远不会报警。建库前最好先做一次快速自查:

# 最近 7 天被修改过的、位于 web 目录下的可执行文件
find /www/wwwroot -type f -mtime -7 \( -name "*.php" -o -name "*.js" \) -ls | head -50
# 查找常见后门特征
grep -rn --include=*.php -iE "eval\s*\(\s*(base64_decode|gzinflate|str_rot13)" /www/wwwroot | head -20
grep -rn --include=*.php -iE "assert\s*\(\s*\\\$_(GET|POST|REQUEST)" /www/wwwroot | head -20

让 AIDE 每天自动跑,并且把结果推到你能看到的地方

手动跑 AIDE 是没意义的,因为你不会每天跑。写成 cron,并且只在有差异时通知你——否则你每天收到一封"一切正常"的邮件,第三天就把它过滤掉了。

# /etc/cron.d/aide-daily
# 每天凌晨 4:17 校验,输出只保留 diff 部分
17 4 * * * root /usr/bin/aide --check 2>&1 | grep -E "^(\s*(added|removed|changed)|Summary|Total)" | mail -s "[AIDE] $(hostname) file integrity diff" you@example.com

grep 这一步很关键。AIDE 的原始输出包含大段"扫描进度"和"未变化文件"列表,几百 KB,直接发邮件会被邮箱当成垃圾。我们只保留 added / removed / changed 三行关键词和汇总行。

如果你的服务器没配 MTA,用 msmtp 走外部 SMTP 最省事,装完写一个 ~/.msmtprc 指向任意邮箱服务商的发信服务器,再把 mail 命令通过 update-alternatives 指向 msmtp 即可。也可以直接把 diff 结果 curl 推到一个 Server 酱、钉钉或企业微信机器人 webhook 上,比邮件更快看到。

AIDE 的告警怎么读:三种 diff 分别意味着什么

收到告警不要慌,先把 AIDE 的 diff 读明白。它只会输出五种前缀,含义完全不同:

  • added:基线里没有、现在存在的新文件。最危险的一种。攻击者落地 webshell、留后门、上传脚本都是新增文件。但正常的软件升级、日志轮转、缓存生成也会产生新增,所以要结合路径判断。
  • removed:基线里有、现在消失的文件。同样高危。经典的用法是删掉原来的 index.php 再写一个同名的恶意版本,或者删掉 .user.ini 之类的安全限制文件。
  • changed:文件还在,但属性变了。要看具体哪个属性变了——只变 mtime/ctime 通常是正常写入变 SHA256 才是内容真被改了变权限位(p)或属主(u/g)往往意味着提权尝试
  • Summary:总览,看数字量级。如果 changed 是几十条且都在缓存目录,那是误报;如果只有 1 条,正躺在 /www/wwwroot/ 下,那大概率是入侵。

我给自己定了一条规则:只要 diff 里出现 web 目录下的 addedchanged 且涉及 .php 文件,先切到维护模式,再查。因为这类文件在正常运维中就不该自己变——你上线时是什么样,就该一直是什么样。

确认是误报怎么办?不要每次手动处理,把合理的排除规则补进配置,然后重建基线。重建基线前建议先备份旧库:

cp /var/lib/aide/aide.db /var/lib/aide/aide.db.$(date +%F)
aideinit -y -f && mv /var/lib/aide/aide.db.new /var/lib/aide/aide.db

第二步:给最敏感的路径加实时监听

AIDE 是"事后发现",最快也要等到第二天凌晨。而 webshell 从落地到被搜索引擎抓取,可能只要几个小时。所以对几个关键路径,我们需要秒级告警。

inotifywait(来自 inotify-tools)写一个常驻监听:

apt-get install -y inotify-tools

# /root/watch_web.sh
#!/bin/bash
WATCH_PATHS="/www/wwwroot \
             /etc/nginx \
             /etc/passwd \
             /etc/crontab \
             /root/.ssh"
ALERT_URL="https://your-alert-endpoint.example.com/hook"

inotifywait -m -r -e create,modify,moved_to,delete,attrib \
  --exclude '\.(log|tmp|cache|swp|sess)$' \
  --format '%T %e %w%f' --timefmt '%F %T' \
  $WATCH_PATHS 2>/dev/null | while read -r TS EV FILE; do
    LP=$(echo "$FILE" | tr 'A-Z' 'a-z')
    # 过滤缓存、编译产物等高频噪音
    case "$LP" in
      *"/runtime/"*|*"/cache/"*|*"/sessions/"*|*"/tmp/"*|*".git/"*) continue ;;
    esac
    # 高价值事件才告警
    case "$EV" in
      CREATE|MOVED_TO|DELETE|ATTRIB)
        curl -s -m 5 -X POST "$ALERT_URL" \
             -H 'Content-Type: application/json' \
             -d "{\"text\":\"[FIM] $TS $EV $FILE\"}" >/dev/null ;;
    esac
  done

再配一个 systemd unit 让它开机自启、崩了自动拉起来:

# /etc/systemd/system/fim-watch.service
[Unit]
Description=File Integrity Real-time Watcher
After=network.target

[Service]
Type=simple
ExecStart=/root/watch_web.sh
Restart=always
RestartSec=5
StandardOutput=journal

[Install]
WantedBy=multi-user.target
chmod +x /root/watch_web.sh
systemctl daemon-reload
systemctl enable --now fim-watch
systemctl status fim-watch --no-pager

这里有几个必须注意的坑。第一,inotify 有内核对每个用户实例的 watch 数量上限(默认 max_user_watches 通常是 8192 或 65536)。监控大目录树时会报 Failed to watch ... upper limit on inotify watches reached,需要调大:

sysctl -w fs.inotify.max_user_watches=524288
echo 'fs.inotify.max_user_watches=524288' > /etc/sysctl.d/99-inotify.conf
sysctl --system

第二,只监听 modify 会被日志和缓存刷爆。我上面刻意只对 CREATE / MOVED_TO / DELETE / ATTRIB 告警,并且用 --exclude 加白名单过滤掉日志、缓存、session、临时文件。命名过滤用文件名后缀而不是目录,因为很多框架的缓存目录在 web 根下。第三,监听器本身要防自杀:如果它把告警写进一个也被监听的目录,会形成死循环,所以告警走 HTTP 而不是写文件。

第三步:用 AppArmor 把"能改"变成"不能改"

前面两步都是"发现被改了",而 AppArmor 是"根本不让改"。它的模型是给每个程序一份配置,声明它只允许访问哪些路径、以什么权限。进程一旦越界,内核直接拒绝,返回 EACCES,连 root 也不例外。

Debian/Ubuntu 自带 AppArmor,先看状态:

apt-get install -y apparmor apparmor-utils
aa-status

给 nginx 生成一份profile最简单的方式是"学习模式":让 AppArmor 记录 nginx 一段时间内的所有合法访问,然后据此生成规则。

# 1. 生成初始 profile(以 complain 模式加载,违规只记录不阻止)
aa-genprof /usr/sbin/nginx
# 它会提示你操作网站,多访问几个页面、上传一次文件、跑一次重载
# 每个提示都选 A(allow) 或 I(inherit),直到不再有新提示

# 2. 切换到 enforce 模式(真正开始拦截)
aa-enforce /usr/sbin/nginx

# 3. 查看当前模式
aa-status | head -20
# 4. 查看被拒绝的操作(日志在 dmesg 和 /var/log/syslog)
dmesg | grep -i 'apparmor.*DENIED' | tail -20

千万别一上来就 aa-enforce学习模式跑够久(至少覆盖一天的所有定时任务,包括证书续期、日志切割、备份脚本、框架的定时清理),再切强制。我见过太多人直接 enforce,结果 certbot 续期时写不了 /etc/letsencrypt、备份脚本读不了 /var/lib/mysql,然后网站半夜挂掉还找不到原因。

AppArmor 真正救命的场景是这样的:假设你的 Typecho 或者 WordPress 出了一个远程代码执行漏洞,攻击者从 web 请求打进来,拿到了 nginx/php-fpm 的运行权限。在没有 AppArmor 的世界里,这个权限足以让他写 webshell、读 /etc/passwd、改 crontab、装持久化后门。在有 AppArmor enforce 的世界里,php-fpm 的profile只允许它读 /www/wwwroot 和写 /www/wwwroot/runtime,其他一律拒绝——攻击者会直接撞墙,最多只能写进一个被监控目录,然后被 AIDE 和 inotify 抓住。

补一条规则示例,看profile长什么样:

# /etc/apparmor.d/usr.sbin.nginx 片段
/usr/sbin/nginx {
  #include <abstractions/base>
  #include <abstractions/nameservice>

  /etc/nginx/**            r,
  /etc/ssl/certs/**        r,
  /etc/letsencrypt/**      r,
  /www/wwwroot/**          r,
  /www/wwwroot/*/runtime/** rw,
  /var/log/nginx/**        rw,
  /run/nginx.pid           rw,

  # 明确拒绝:即使被攻破也无法写代码目录
  deny /www/wwwroot/**/*.php w,
  deny /etc/passwd w,
  deny /etc/crontab w,
  deny /root/** rw,
}

注意 deny 的优先级高于任何 allow,而且不可被后续规则覆盖,所以在 nginx 的profile里显式 deny /www/wwwroot/**/*.php w,等于给"写 webshell"这个动作上了一把物理锁——nginx 进程永远不可能创建或修改 php 文件。这一条规则的性价比,比装十个安全插件都高。

把三者串成一条自动化的运维链条

单独用任何一个工具都会让你失望:AIDE 太慢、inotify 太吵、AppArmor 太死。真正的效果来自它们的组合。我现在线上的分工是这样的:

  • AppArmor(预防):阻止 nginx/php-fpm/mysql 越权写代码目录,把攻击面的"可能性"直接掐断。
  • inotify(实时):监控 /www/wwwroot/etc/root/.ssh,秒级推送到告警机器人。主要用于抓"写进来了"这个瞬间。
  • AIDE(兜底):每天 4:17 全量校验,抓到所有绕过实时监听的改动——包括重启期间发生的、inotify 漏掉的、以及攻击者用 touch -r 伪造时间戳试图蒙混过关的(AIDE 比对哈希,伪造时间戳无效)。

再叠加一条自检:把这三个工具自身也纳入监控。攻击者拿到 root 后第一件事往往是 systemctl stop fim-watch 或者把 AIDE 数据库一起改掉。所以要把 /var/lib/aide/aide.db 的哈希写到另一个地方(比如 root 邮箱或对象存储),把 fim-watch 的状态也纳入外部监控(比如 Uptime Kuma 探一个它暴露的 heartbeat 接口),一旦监听器停止心跳就告警。

# 每次 AIDE 校验后,把数据库哈希存到对象存储,防止被本地篡改
HASH=$(sha256sum /var/lib/aide/aide.db | awk '{print $1}')
echo "$(date -Iseconds) $HOSTNAME $HASH" | \
  rclone rcat oss:backup/aide-db-hash.log --append 2>/dev/null || \
  echo "$(date -Iseconds) $HOSTNAME $HASH" >> /root/.aide-db-hash-history

aide.db 的哈希外置,等于给基线本身加了一道封印。下次收到 diff 时先核对数据库哈希有没有变——如果数据库自己变了,那说明有人已经拿到 root 并且在试图掩盖痕迹,这本身就是最高级别的告警。

一套可以直接照抄的落地清单

  1. 挑选一个"确定干净"的时间点,先做一次人工排查(find -mtime + grep eval(base64_decode)),再建 AIDE 基线。
  2. 自定义 aide.conf,只监控 web 代码、/etc 关键配置、/root/.ssh;日志目录排除哈希比对;排除缓存和上传目录。
  3. cron 每日 4:17 校验,只把 added/removed/changed/Summary 推送到告警渠道。
  4. inotify-tools,先 sysctl 调大 max_user_watches,写过滤脚本,只对 CREATE/MOVED_TO/DELETE/ATTRIB 告警,用 systemd 托管防退出。
  5. aa-genprof 给 nginx 和 php-fpm 生成profile,complain 模式跑满一周(覆盖所有定时任务),再切 enforce。
  6. 在 enforce 后的profile里显式 deny*.phpdeny/etc/shadowdeny/etc/crontab
  7. aide.db 哈希外置保存,把监听器的存活状态纳入外部监控。
  8. 每月做一次演练:主动在 web 目录建个 test.php,确认 AIDE 第二天报警、inotify 秒级报警。规则有没有生效,只有演练才知道。

最后说一句心里话。文件完整性监控不解决任何"让网站更快"或者"让收录更多"的问题,它唯一的作用是在最坏的情况下,让你比攻击者早一步知道发生了什么。做站长做久了都会有侥幸心理——我的站小,没人盯。我当初也是这么想的,直到那个藏了一个月的跳转脚本教会我:攻击是自动化的、批量的,它不挑你,它扫全网。花一个下午把这三层装上,是成本最低的一份保险。

Last modification:September 21st, 2026 at 07:24 pm

Leave a Comment