服务器日志里满屏 404,来源高度集中,请求路径全是 /admin.php、/backup.zip、/.env 这种猜出来的名字——这不是搜索引擎在抓页面,而是目录扫描器在踩点。本文用 9 个问答讲清扫描请求的特征、怎么和正常抓取区分、404 洪峰怎么先降速再封禁,以及一套尽量不误伤真实用户的处置配置。
Q:目录扫描器和正常爬虫有什么区别?
核心差别在请求路径的来源:正常爬虫顺着站内链接走,目录扫描器是拿一份字典在拼路径。
- 路径站内不可达:/admin、/manage、/wp-config.php.bak、/.git/config、/backup.zip、/phpinfo.php 这些名字,站内没有任何链接指向。
- 404 占比极高:正常抓取 404 通常低于 5%,扫描器经常超过 50%。
- 节奏均匀:单 IP 每秒固定几个请求,且不加载 css、js、图片。
- UA 有特征:dirsearch、Nikto、Go-http-client、python-requests 等默认 UA,或伪装成 Chrome 却缺少 Sec-Fetch 系列头。
一句话:正常爬虫要内容,扫描器要入口。前者关心页面里的正文,后者关心服务器上存不存在某个文件。
Q:怎么确认是扫描而不是搜索引擎抓取?
三个信号命中两个就能定性,全部用访问日志算:
# 404 的来源 IP Top 20
grep ' 404 ' access.log | awk '{print $1}' | sort | uniq -c | sort -rn | head -20
# 单 IP 的请求量与状态码分布
awk '{print $1, $9}' access.log | sort | uniq -c | sort -rn | head -20
- 是搜索引擎:请求路径都能在站内找到,会拉取静态资源,会读 robots.txt,段内 IP 分散。
- 是扫描器:路径大多数不存在,只请求 HTML 不拉资源,单 IP 404 超过 100 次/小时。
有个前提容易踩坑:先确认日志里的 IP 是真实客户端 IP。挂了 CDN 或 WAF 时,日志默认记录的是回源节点地址,不还原真实 IP 时封禁会直接封掉整个 CDN 节点:
set_real_ip_from 10.0.0.0/8;
set_real_ip_from 172.16.0.0/12;
real_ip_header X-Forwarded-For;
real_ip_recursive on;
Q:404 洪峰有什么实际危害?
很多人觉得 404 只是日志脏,其实代价比想象中高:
- 真实资源消耗:WordPress 这类动态站点每个 404 也要启动 PHP、查数据库,十万次请求就是十万次无用查询。
- 磁盘与带宽:日志按天膨胀,回源流量被白白吃掉。
- 缓存命中率被拉低:大量不存在的路径都是 cache miss,还会污染缓存。
- 漏洞前置侦察:一旦猜到真实存在的备份文件或后台入口,下一步就是拿工具打。
所以 404 洪峰不是”噪音问题”,而是成本问题加攻击前置,必须按安全事件处理。
Q:怎么先降速止血?
第一步不是封 IP,而是限速,把洪峰摊平,让真实用户先不受影响:
# 单 IP 请求速率 + 并发连接双限
limit_req_zone $binary_remote_addr zone=req_per_ip:10m rate=10r/s;
limit_conn_zone $binary_remote_addr zone=conn_per_ip:10m;
limit_req_status 429;
limit_conn_status 429;
server {
limit_req zone=req_per_ip burst=20 nodelay;
limit_conn conn_per_ip 20;
}
两个关键点:rate 要按单 IP 正常峰值定,不要用”全站峰值除以 IP 数”,否则正常用户一起被限;静态资源要单独放行,否则用户首屏的图片会被扫描器连坐拖慢。
Q:怎么针对 404 单独做处置?
实用思路是把 404 抽出来单独统计,再用封禁工具闭环。Nginx 默认所有请求混在一个日志里,先让 404 单独落盘:
map $status $is_404 {
~^404$ 1;
default 0;
}
server {
access_log /var/log/nginx/notfound.log main if=$is_404;
}
剩下的交给 fail2ban,因为它的封禁自带超时,天然避免永久误封:
# /etc/fail2ban/filter.d/nginx-404.conf
[Definition]
failregex = ^<HOST> - .* "GET [^"]*" 404
ignoreregex =
# /etc/fail2ban/jail.d/nginx-404.local
[nginx-404]
enabled = true
filter = nginx-404
logpath = /var/log/nginx/notfound.log
maxretry = 60
findtime = 300
bantime = 3600
参数含义:5 分钟内 404 超过 60 次就封 1 小时。阈值不能设太松,也不要设太紧,正常用户刷新几次页面不会触发。
Q:UA 黑名单能挡住扫描器吗?
能挡一部分,挡不住改变 UA 的那部分。低端扫描工具大多懒得改 UA,先做一层粗筛成本最低:
map $http_user_agent $bad_ua {
default 0;
~*(dirsearch|nikto|nmap|masscan|sqlmap|wpscan|acunetix|nuclei) 1;
~*(python-requests|Go-http-client|curl/|Wget/|Java/) 1;
}
server {
if ($bad_ua) {
return 403;
}
}
注意两个坑:一是 UA 判断必须用 map 出变量再 return,不要写复杂的 if 嵌套,Nginx 的 if 是 rewrite 模块的一部分,嵌套容易出不可预期行为;二是这条规则会误伤正常的接口调用和监控探针,上线前把自家脚本、CDN 回源、可用性监测的 UA 加进白名单。
结论很清楚:UA 只能做第一层粗筛,扫描器把 UA 改成 Chrome 就绕过了,真正管用的是行为层加封禁闭环。
Q:主动封禁怎么做得又准又可回退?
用 ipset 加超时,封禁自动过期,不用手工维护名单:
# 建集合,默认 1 小时自动解封
ipset create dirscan hash:ip timeout 3600
# 挂到 INPUT 链(按需改成 FORWARD 或只封 80/443)
iptables -I INPUT -p tcp -m multiport --dports 80,443 -m set --match-set dirscan src -j DROP
# 手动加封(扫描器常整段换 IP,优先封 /24)
ipset add dirscan 203.0.113.0/24 timeout 7200
# 查看与解封
ipset list dirscan | head
ipset del dirscan 203.0.113.0/24
三条经验:封禁一定带 timeout;优先按 /24 或 ASN 聚合封,不要一个 IP 一个 IP 追;CDN 场景先在 Nginx 还原真实 IP,再把真实 IP 送进 ipset,否则封的是回源节点。
Q:怎么防止误封正常用户?
用三级阶梯,不要一上来就 DROP:
- 记录观察:先在日志和看板上标出可疑 IP,不动流量。
- 降速:返回 429 而不是 403,用户还能用,只是慢一点。
- 带超时封禁:只有”404 集中 + 不拉静态资源 + 无 Referer”三条同时成立的 IP 才封。
另外把三类来源加进永久白名单:搜索引擎官方 IP 段、CDN 回源段、内部监控与健康检查探针。封禁时长控制在 1 小时以内并自动解封,是防误伤最有效的保险。
Q:还有什么补充建议?
- 先消灭可猜的目标:备份文件不要留在 Web 目录,.git、.env、phpinfo、数据库导出文件一律禁止访问,”扫不到”比”扫到再拦”更彻底。
- 404 页面统一:别让 404 页面暴露框架版本、报错堆栈、后台路径。
- 定期自己扫自己:拿扫描器跑一遍自己的站,先于攻击者发现问题。
- 拦在最外层:CDN 或 WAF 层能拦掉的扫描,就不要让它到源站消耗 PHP 和数据库。
- 看趋势而不是看单点:把每日 404 总量、Top 来源段画成曲线,扫描开始和结束一眼就能看出来。
一句话总结:目录扫描器要的是入口不是内容;用”限速止血、404 单独统计、带超时封禁、白名单兜底”这套闭环,既能压掉洪峰,又不会把真实用户和搜索引擎一起误伤。