1. 背景
公司官网跑在 ThinkPHP 5.0 上,被黑帽 SEO 注入过一次。攻击者在 public/index.php 里塞了一段 base64 编码的远控地址,逻辑是:
判断 User-Agent,遇到 Baiduspider、Sogou、360、Yisou 这类爬虫,就返回一个完全不同的假页面;
用户从搜索结果点进来,被跳到垃圾站。
这种注入在文件层面很难发现。浏览器里打开一切正常,非爬虫请求不受影响,只有搜索引擎来的流量才会中招。等自然搜索流量掉下来,往往已经过了很久。
代码后来清理掉了,但留下一个问题:下次文件被改了,怎么第一时间知道?
2. 需求
梳理了几条硬性要求:
不侵入项目代码。不往 PHP 里加检查逻辑,免得引入新的风险面。
零依赖。Tripwire、AIDE、OSSEC 这类要装要配的工具不用,生产环境能不动就不动。
不接监控服务。不买 SaaS,也不单独搭一套监控平台。
每天查一次就够。黑帽注入不是高频行为,频率再高没意义。
有变化时登录能看见。不发邮件不发短信,SSH 进去直接看到警告。
合起来就是:每天跑一遍,文件变了就警告,登录就能看见。
3. 方案选型
满足这些要求,最省事的组合是 Shell 脚本 + cron + /etc/profile.d/。核心就是一条 find 加一个算哈希的工具,比对逻辑用纯 Shell 写。早期版本用 diff,后来换掉了,原因在第 5 节。
其他方案为什么不用:
| 方案 | 问题 |
|---|---|
| Tripwire / AIDE | 需要安装、维护策略文件,学习成本高 |
| ThinkPHP CLI 命令 | 要加载框架、依赖 PHP 环境,比纯 Shell 慢 |
| Git diff | 生产环境不一定有 Git 历史,.gitignore 之外的文件可能漏检 |
| inotify 实时监控 | 资源开销大,黑帽 SEO 不需要秒级发现 |
| Shell + cron + /etc/profile.d/ | 零依赖,不上服务,脚本三百多行,改动都在服务器本地 |
选 Shell 还有一点:部署新代码后更新基线就一条命令,不用重建索引,也不用等同步。
4. 检测原理
一次扫描,拿到元数据
扫描时 find 一次遍历就输出每个文件的 size、mtime、ctime、inode,时间精确到纳秒,字段之间用 NUL 分隔:
find "$PROJECT" -type f ... -printf '%s|%T@|%C@|%i\0%p\0'
上一次的结果缓存在 /var/secure/thinkphp-checksum.cache。元数据没变的文件直接复用旧哈希,只有变化的才重算。十万个文件的目录,日常检查只碰其中一小部分。
snapshot / check
snapshot 建立或更新基线,check 重新扫描后和基线比对。两个命令走同一套扫描逻辑,区别只是结果写到哪。
输出分级
只有当前有的文件标 [新增];基线和当前都有、哈希不同标 [变更];只有基线有的标 [缺失]。可执行扩展(php、phtml、php5、phar、inc、htaccess、user.ini、env 等)再套一层 [高危],方便先看这些。
内容特征扫描
光看扩展名抓不到图片马。攻击者可以把 写进 jpg,再配合 .htaccess 或 LFI 去执行。所以对变化的文件多做一步内容检查:非可执行扩展的文件里出现 或 = 就标 [可疑];PHP 文件里出现 eval、base64_decode、gzinflate、system、passthru、shell_exec、create_function 这类函数也标 [可疑]。
只扫变化的文件,开销很小。这一步是启发式,会有误报(正常代码里也可能用到 call_user_func),标成 [可疑] 交给人判断,不自动删。
登录告警联动
/etc/profile.d/ 下的脚本每次 SSH 登录自动执行。thinkphp-warning.sh 读 /var/secure/thinkphp-integrity-result.txt,有内容就打印警告,[高危] 和 [可疑] 的排在前面。
cron 凌晨 3 点 → check 无差异 → 清空结果文件 → 登录无提示 有差异 → 写入结果文件 → 登录显示警告
5. 踩过的坑
第一版能跑,但测试时崩了:
/usr/local/bin/thinkphp-integrity.sh: line 181: ${#b}-34: substring expression < 0问题不在报错那一行。脚本用 xargs -P 并行调用 md5sum,多个进程往同一个 stdout 写。glibc 下 md5sum 的缓冲是 8192 字节,比管道的 PIPE_BUF(4096)大,一次 flush 不是原子写。两个进程的缓冲块交错之后,NUL 分隔的记录被撕碎,解析时读到长度不足的碎片,取子串就得到负数。
改法是不让多个进程共享 stdout:每个 xargs 批次把结果写进自己的临时文件,跑完再 cat 合并。并行度保住了,记录也不会串。顺手把哈希从 md5 换成 sha256。
查这个问题时还发现两个小的:
元数据原来只精确到秒。同一秒内、同样大小的改写(比如改个数字)mtime、ctime 都不变,会被当成没改。换成纳秒精度的
%T@、%C@才抓得住。快照原来排序后从 /tmp 直接 mv 到 /var/secure,这两个目录往往不在一个文件系统上,mv 会退化成复制加删除,不是原子操作。改成先排到目标目录里,再 mv 替换。
6. 涉及的脚本
整个监控体系就两个文件:
| 文件 | 用途 |
|---|---|
thinkphp-integrity.sh | 主脚本,snapshot 建立/更新基线,check 比对检查 |
thinkphp-warning.sh | 部署到 /etc/profile.d/,SSH 登录时自动展示告警 |
核心逻辑
#!/bin/bash
SNAPSHOT="/var/secure/thinkphp-checksum.txt"
CACHE="/var/secure/thinkphp-checksum.cache"
FLAGFILE="/var/secure/thinkphp-integrity-result.txt"
PROJECT="/alidata1/www/web" # 每个站点改这里
JOBS="${JOBS:-4}"
# 一次遍历拿到 元数据\0路径\0,时间精确到纳秒
find_meta() {
find "$PROJECT" -type f \
! -path "*/runtime/*" ! -path "*/.git/*" ! -path "*/node_modules/*" \
-printf "%s|%T@|%C@|%i\0%p\0"
}
# 安全并行:每个批次写自己的临时文件,最后合并,避免共享管道串行
hash_zero_batch() {
d=$(mktemp -d) || return 1
xargs -0 -r -P"$JOBS" -n1000 bash -c '
o=$(mktemp "$0/XXXXXX") || exit 1
sha256sum --zero -- "$@" | while IFS= read -r -d "" r; do
printf "%s %s\0" "${r:66}" "${r:0:64}"
done > "$o"
' "$d"
cat "$d"/* 2>/dev/null
rm -rf "$d"
}
# 分级:可执行扩展套一层 [高危]
tag_line() {
case "$2" in
*.php|*.phtml|*.phar|*.inc|*.htaccess|*.user.ini|*.env)
printf "[高危]%s %s\n" "$1" "$2" >> "$FLAGFILE" ;;
*) printf "%s %s\n" "$1" "$2" >> "$FLAGFILE" ;;
esac
}7. 部署步骤
第一步:部署脚本
sudo install -m 700 thinkphp-integrity.sh /usr/local/bin/thinkphp-integrity.sh sudo install -m 644 thinkphp-warning.sh /etc/profile.d/thinkphp-warning.sh
主脚本里的 PROJECT 要改成自己站点的目录,不同站点不一样,每个站点改一行。
第二步:建立基线
sudo /usr/local/bin/thinkphp-integrity.sh snapshot --full
基线存在 /var/secure,root 权限,Web 用户写不进去。因为哈希从 md5 换成了 sha256,旧基线不能用,第一次必须带 --full 重建。
第三步:添加计划任务
sudo crontab -e 0 3 * * * nice -n 19 ionice -c3 /usr/local/bin/thinkphp-integrity.sh check
加 nice 和 ionice 是怕半夜扫描抢 IO,影响白天做站的机器。
验证
sudo /usr/local/bin/thinkphp-integrity.sh check # 无变化时应输出:所有文件正常(扫描 N 个,复用 X,重算 0)
脚本必须是 LF 行尾。从 Windows 传过去先跑一遍 sed -i 's/\r$//',否则 bash 解析会出错。
8. 输出示例
正常
所有文件正常(扫描 3421 个,复用 3419,重算 2)
重算 2 个通常是当天改动过的文件,正常。
有文件变化
警告:4 个文件发生变化! [高危][新增] /alidata1/www/web/public/upload/1.php [变更] /alidata1/www/web/public/index.php [缺失] /alidata1/www/web/public/old.html [可疑] /alidata1/www/web/public/upload/logo.png
这段对应:上传目录多了个 php(高危新增)、首页被改、有个页面被删、一张图片内容里带 PHP 标签。登录时的警告横幅会把 [高危] 和 [可疑] 排在上面。
部署新代码后更新基线
sudo /usr/local/bin/thinkphp-integrity.sh snapshot 基线已更新:3421 个文件(复用 3420,重算 1)
9. 细节与维护
[缺失] 是怎么来的
运行时生成的缓存、日志如果落在监控范围内,删掉就报 [缺失]。默认排除了 runtime/、.git/、node_modules/ 三个目录。上传目录没排除,因为木马经常就落在那里,宁可多报也不漏。噪声太大时再往脚本顶部的 EXCLUDES 里加路径。
内存和并发
缓存用 bash 关联数组加载,十万个文件大概几十 MB 内存。并发数默认 4,用 JOBS 变量调,比如 JOBS=8 thinkphp-integrity.sh check。内容扫描可以用 SCAN=0 关掉。
维护成本
平时不用管。唯一要做的是部署新代码后跑一次 snapshot 更新基线。忘了也不打紧,下次 check 会报一堆 [变更],跑一次 snapshot 就恢复。
本文涉及的脚本
etc-profile-d-thinkphp-warning.txt
以上脚本部署的时候注意修改后缀





