「好几个客户反馈,用手机流量访问网站时,会突然跳到一个卖彩票的页面,刷新一下又好了。查了代码没发现木马。」
这个描述信息量其实很大,大到几乎可以排除掉一半的可能:
- 偶发。刷新就好,说明不是站内代码每天在执行。
- 手机流量。不是 WiFi,不是所有网络环境。
- 代码里没木马。文件层面可能是干净的。
- 跳到博彩。劫持类流量的典型变现方向。
四条合在一起,指向的往往不是「网站被黑了」,而是页面在从你服务器出来之后、到用户手机屏幕之前,某一段路被人动了手脚。这段路可能有五六个环节,每个环节的排查方法都不一样。
这篇按「可能性从高到低」拆一遍,每一步都给可直接执行的命令。

一、先分清:是谁的页面在跳?
排查第一步不是查代码,是看跳转到底怎么发生的。三种形态,指向完全不同的方向:
| 现象 | 技术本质 | 大概率的成因 |
|---|---|---|
| HTTP 302/307 跳转,地址栏 URL 变了 | 服务端或链路中间返回重定向 | 运营商劫持、CDN 配置被改、伪基站 |
| 页面先正常显示,几秒后被顶走 | 页面里执行了 location.href= |
被注入 JS、第三方脚本被投毒 |
| 出现一个浮层/新窗口广告,原页面还在 | iframe 或新窗口打开 | DNS 被劫持到代理页、运营商插广告 |
怎么判断?用电脑 Chrome 打开手机模拟模式,开网络面板 + 保留日志,勾上 Preserve log,然后疯狂刷新直到复现。看第一条「跳出去」的请求:
- 如果
Document请求的响应码是 302,看Location头指向哪; - 如果响应码是 200 但页面被替换了,看
Response里有没有陌生的<script>; - 如果根本没发新请求、只是 URL 自己变了,多半是 JS 干的或浏览器层被插了东西。
记住一个判断原则:能复现就查链路,不能复现就查环境。偶发的劫持之所以难查,是因为它根本不发生在你的服务器上。
如果你已经确认是站内注入这一路(第三、四节会细讲),我们之前写过一篇更完整的实战复盘,可以先看那篇:《网站被入侵跳转到非法链接?5 步自查 + 百度云防护方案》。本文重点补的是「站内干净、但还是跳」的那一半。
二、六个环节,按命中率排序
1. 运营商 HTTP 劫持(命中率最高)
这是「手机流量」四个字最直接的指向。部分地区的移动网络会对明文 HTTP 流量做旁路分析,一旦识别出页面特征,就插入广告脚本,或者直接 302 到推广页。
为什么刷新就好?因为劫持是概率性投放的——按规则抽样,不是每个请求都拦。这也是它最难查的原因。
自查方法很土但有效:用手机流量连续访问 20 次你的首页,每次记录是否跳转;同时用 https 再试 20 次。如果 HTTP 跳、HTTPS 不跳,基本可以定性。
# 对比两次响应体是否被篡改:抓首页源码,搜可疑注入
curl -s http://www.example.com/ | grep -nE '<script[^>]*src="https?://(?!www\.example\.com)'
# 看响应头有没有被中间设备加上奇怪的标记(有些劫持会带 X-Cache 之类的伪造头)
curl -sI http://www.example.com/ | grep -iE 'via|x-cache|x-cnection|server'
# 用不同网络出口各测一次,对比首字节和内容长度是否一致
for i in 1 2 3; do curl -s -o /dev/null -w "%{size_download} %{time_total}\n" http://www.example.com/; done
注意最后那条命令:同一份页面,多次请求返回的大小不一样,就是一个很强的信号。
2. 网站真的被黑了,只是木马藏得深
「查了代码没发现木马」不等于没有。现在的主流手法早就不往 index.php 里塞 eval 了:
- 加密混淆:一长串
base64_decode嵌套,肉眼看着像正常数据; - 隐藏在正常文件尾部:在图片、JS、CSS 末尾追加一段;
- 只对移动端 UA 输出:PC 上访问完全正常,手机 UA 才触发——这正好解释了「为什么只在手机上跳」;
- 数据库注入:直接改
wp_options或页面表的字段,文件层面永远是干净的。
所以排查要换思路:不看代码长得像不像木马,看它「不应该出现在哪」。
# ① 找近 30 天被改过的 PHP / JS 文件(排除缓存与上传目录)
find /www/wwwroot/example.com -type f \( -name "*.php" -o -name "*.js" \) \
-mtime -30 ! -path "*/cache/*" ! -path "*/uploads/*" -printf "%TY-%Tm-%Td %p\n" | sort -r | head -40
# ② 找文件末尾被追加内容的(正常 PHP 不会以 base64 结尾)
grep -rlE "eval\(|assert\(|base64_decode\(|gzinflate\(|str_rot13\(" \
/www/wwwroot/example.com --include="*.php" | head -30
# ③ 找隐藏文件(点开头的 PHP 在 Web 目录里极不正常)
find /www/wwwroot/example.com -name ".*.php" -o -name "*.php.*" | head
# ④ 看有没有最近新增的可疑文件(webshell 通常体积很小)
find /www/wwwroot/example.com -name "*.php" -size -4k -mtime -30 -printf "%s\t%p\n"
数据库侧也要查一遍。WordPress 站直接跑:
# 查选项表里有没有被塞进脚本(劫持代码最爱藏在 wp_options 和文章正文里)
mysql -uroot -p 库名 -e "SELECT option_name FROM wp_options WHERE option_value LIKE '%<script%';"
mysql -uroot -p 库名 -e "SELECT ID FROM wp_posts WHERE post_content LIKE '%location.href%' OR post_content LIKE '%atob(%';"
# 查有没有多出来的管理员账号
mysql -uroot -p 库名 -e "SELECT ID,user_login,user_email,user_registered FROM wp_users ORDER BY ID DESC LIMIT 10;"
这里有个关键点:很多劫持只对移动 UA 生效,所以 PC 上打开一切正常。验证方法很简单——用 UA 伪装请求一次:
# 用 iPhone UA 取一次首页,和正常 UA 的响应体做 diff
curl -s -A "Mozilla/5.0 (iPhone; CPU iPhone OS 17_0 like Mac OS X) AppleWebKit/605.1.15" \
https://www.example.com/ > /tmp/mobile.html
curl -s https://www.example.com/ > /tmp/pc.html
diff <(wc -c < /tmp/pc.html) <(wc -c < /tmp/mobile.html)
grep -oE '<script[^>]+' /tmp/mobile.html | grep -viE 'example\.com|google|baidu'两份响应的长度差太多、或者 mobile 版多出外链脚本,问题就在服务端。
3. 第三方脚本 / 广告位被投毒
如果你接了统计、客服、广告联盟、字体 CDN,任何一个供应商被入侵,注入的 JS 都会在你域名下执行。注意看手机的响应体里,外链脚本的域名是不是你认识的。
更隐蔽的是「组件供应链」:某个统计代码某天开始加载一个二次下发脚本,只对移动端下发、只对特定地区下发。这类问题在 PC 上完全看不出来。
处置:把外链脚本全部列出来,逐个确认来源;不用的立刻删掉,必须用的加 defer 并考虑 SRI 校验。
4. DNS 被劫持
DNS 劫持的特征是「连你的服务器都没碰到」——用户解析到的根本不是你。判断方法:
# 用多个公共 DNS 分别解析,结果应该完全一致
for dns in 223.5.5.5 119.29.29.29 8.8.8.8 114.114.114.114; do
echo -n "$dns -> "; dig +short @$dns www.example.com A | tr '\n' ' '; echo
done
# 和权威 DNS(你的域名服务商)的记录对比
dig +short @ns1.your-dns-provider.com www.example.com A
如果公共 DNS 的结果和权威记录不一致,或者不同 DNS 给出不同结果,就是 DNS 层的劫持。这种情况换 DNS 服务商、上 DNSSEC 是最直接的解法。
5. 伪基站 / 免费 WiFi 网关
只影响特定环境:某个商场 WiFi、某个地区的 2G/3G 网络。特征是「同一个人,换个地方就好了」。
这类劫持你没法从服务器端解决,能做的只有一件事:让页面必须走 HTTPS,并对 HTTP 请求做 301 强制跳转。明文传输是这类劫持唯一的入口。
6. CDN / 反向代理缓存被污染
如果你用了 CDN,某个边缘节点缓存了一份被注入的 HTML,那么命中这个节点的用户就会看到广告,直到缓存过期。「刷新就好」有时候是因为刷新打到了另一个节点。
处置:CDN 控制台里对该 URL 做「刷新缓存」并开启「忽略参数缓存」检查;如果反复出现,说明注入源在回源链路上,要继续往下查。
三、一张排查流程图
| 步骤 | 动作 | 结论方向 |
|---|---|---|
| 1 | HTTPS 与 HTTP 各测 20 次,记录跳转率 | 只有 HTTP 跳 → 运营商/热点劫持 |
| 2 | 多个公共 DNS 解析对比 | 结果不一致 → DNS 劫持 |
| 3 | 用移动 UA 抓源码,与 PC 版 diff | 响应体不同 → 服务端注入或被黑 |
| 4 | find -mtime 查 30 天内改动文件 |
有陌生文件 → 已被入侵 |
| 5 | 查数据库 option 与文章正文 |
有外链脚本 → 数据库注入 |
| 6 | CDN 控制台刷新缓存并观察 | 刷新后恢复 → 缓存污染 |
| 7 | 更换网络环境(WiFi / 4G / 异地)复测 | 换环境不复现 → 本地网络劫持 |
按这个顺序走,一般三十分钟内能定位到具体环节。
四、修复:分「治标」和「治本」两条腿
治本一:全站 HTTPS,不留明文入口
# /www/server/panel/vhost/nginx/example.com.conf
server {
listen 80;
server_name www.example.com example.com;
return 301 https://$host$request_uri; # 不留任何明文页面
}
server {
listen 443 ssl http2;
server_name www.example.com example.com;
ssl_certificate /www/server/panel/vhost/cert/example.com/fullchain.pem;
ssl_certificate_key /www/server/panel/vhost/cert/example.com/privkey.pem;
add_header Strict-Transport-Security "max-age=31536000; includeSubDomains" always;
...
}
宝塔路径:网站 → 设置 → SSL → 强制 HTTPS,再在「配置文件」里确认没有残留的明文跳转白名单。
治本二:CSP 限制外部脚本
add_header Content-Security-Policy "default-src 'self'; script-src 'self' 'unsafe-inline' https://hm.baidu.com; img-src 'self' data: https:; frame-ancestors 'self'" always; # 部署
nginx -t && nginx -s reload
CSP 不能防服务端输出被篡改,但能挡住「页面被注入了陌生 JS」这一大类——外链域名不在白名单里,浏览器直接拒绝执行。建议先用 Content-Security-Policy-Report-Only 跑一周看报告,别一上来就硬拦。
治标:临时加固顺序
- 清理可疑文件与数据库注入,改所有管理员密码;
- 关闭
xmlrpc.php、限制/wp-admin访问来源; - 开启网站文件防篡改(宝塔有内置,或
chmod 444保护核心文件); - 配置页面完整性监控,文件被改立刻告警。
五、为什么「偶发 + 查不到木马」最难治
把这几种情况放一起对比,会发现一个规律:
| 类型 | 你能否从服务器端看到 | 能否靠改代码解决 | 能否靠设备防护解决 |
|---|---|---|---|
| 代码被注入木马 | 能 | 能 | 不需要 |
| 数据库注入 | 能(查库) | 能 | 不需要 |
| DNS 劫持 | 不能 | 不能 | 不能 |
| 运营商 HTTP 劫持 | 不能 | 不能 | 不能 |
| WiFi / 伪基站 | 不能 | 不能 | 不能 |
| CDN 缓存污染 | 部分 | 不能 | 不需要 |
后三类占了「偶发跳转」的大多数,而它们的共同点是:问题不发生在你的服务器上,你在服务器上怎么找都找不到。
更麻烦的是,只有 HTTPS 是不够的。HTTPS 能挡住明文劫持,但挡不住「源站已经被拿了」这种情况——比如某个老插件有 RCE、后台弱口令被爆破、或者数据库配置暴露在公网。这类入侵往往做完就删日志、留一个只在手机 UA 下触发的后门,你查一个月也查不出来。
所以实际防护要分成两层:一层管「流量在路上别被改」,一层管「请求进站前先被筛一遍」。
六、加一层「入口级」的过滤,比事后排查省事
把这几年的经验总结成一句:劫持类问题,事后排查的成本远高于事前过滤。因为偶发问题的复现成本极高,而流量一旦进了源站,日志里你看到的往往只是「一个正常请求」。
真正有效的做法是在流量进源站之前就做一次判断和清洗,也就是常说的云 WAF 接入。这里不做硬推,只说几个和你这个问题直接相关的功能点:
- 隐藏源站。域名用 CNAME 接进云端后,公网只看到防护节点的 IP,源站不再暴露。扫描器找不到源站,爆破和漏洞利用就少了一大半入口。
- 恶意代码注入检测。WAF 规则库里有一类专门检测响应内容与请求特征的规则,「页面被插入陌生
<script>」「页面嵌入可疑 iframe」这类问题会在响应侧被识别并阻断。 - 网页防篡改。把核心页面在云端做缓存副本,源站哪怕被改,返回给用户的也是干净页面——这正好对应「源站被黑,但你自己还没发现」那个场景。
- 全站 HTTPS 与证书托管。WAF 节点上可以直接签发并托管证书,不用在源站折腾,HTTP 强制跳转也在节点层完成。
- Bot 与异常访问识别。爆破后台、批量探测、异常 UA 这些行为在 WAF 侧有现成的画像能力,比在服务器上一条条写规则轻松得多。
百度云防护在这几条上是比较齐全的:SaaS 接入只改 DNS、不动服务器架构,对一个已经在跑的老站来说改动最小;0day 类漏洞有虚拟补丁,能在你升级程序之前先挡住;源站隐藏 + 回源 IP 白名单这套组合,能把「源站裸奔」这个最大的风险直接掐掉。
但也要说清楚边界:WAF 治不了运营商在中间插广告。那是链路问题,只能靠 HTTPS 和 DNS 安全来解决。WAF 治的是「源站被人拿到」这一类——而这类恰恰是危害最大的,因为它不只跳广告,还会拖库、挂马、被挂到黑产分销页。
七、给站长的 10 分钟自查清单
| 项 | 怎么做 | 判断标准 |
|---|---|---|
| HTTP 是否还有明文入口 | curl -I http://你的域名 |
必须是 301 到 https |
| HSTS 是否开启 | curl -I https://你的域名 |
有 Strict-Transport-Security 头 |
| 移动 UA 响应是否异常 | 用 iPhone UA 抓源码 diff | 两份响应体的脚本列表一致 |
| 近期文件改动 | find -mtime -30 |
无陌生文件 |
| 数据库是否有注入 | 查 options / posts 表 |
无未知外链脚本 |
| 多 DNS 解析一致 | 4 个公共 DNS 对比 | 结果完全相同 |
| CDN 缓存策略 | 控制台看 HTML 的 TTL | HTML 不缓存或短缓存 |
| 源站是否暴露 | 用第三方工具查域名历史解析 | 查不到真实 IP 最优 |
| 后台入口 | /wp-admin 是否对公网开放 |
加 IP 白名单或二次验证 |
| 日志留多久 | 看 Nginx access log 保留策略 | ≥ 180 天 |
八、写在最后
「偶尔跳转、刷新就好、查不到木马」——这三个特征凑在一起,八成不是你的代码出了问题。
但也别因为「不是我的问题」就放着不管。用户记不住是谁劫持的,只记得「上这个站会跳广告」,最后掉的是你的品牌和信任。尤其是做企业站的,客户第一次访问就撞上博彩页,这一单基本没了。
能做的其实很清楚:把明文入口全部关掉、把源站藏起来、把入口流量筛一遍、把文件与页面盯起来。前两件今天就该做完,后两件是长期的事。
本文的排查命令适用于 Linux + Nginx + MySQL 环境(宝塔面板通用),Windows IIS 站点可按同样思路替换为对应的 PowerShell 命令。
