网站被 14 分钟搬空 1040 个页面:Nginx 限速 + Fail2ban 实战

封面:网站被采集器搬空 1040 个页面

一篇来自真实事故的复盘。不是理论,是踩完坑之后能直接抄的配置。

一、事故经过

某天早上 8 点,网站日志里出现了一条异常曲线:

指标 数据
攻击 IP 单个 IP(114.66.x.x)
时间窗口 14 分 19 秒
请求总数 1042 次
抓取内容 1040 个不重复的详情页 URL(每个只抓一次,系统性遍历)
请求频率 约 1.2 次/秒,匀速
UA 伪装成 Chrome/131(只有 2 次暴露了真实 UA:OneNavImport/1.0)
静态资源请求 0 个(纯 HTML 抓取,确认是脚本不是浏览器)
结果 1041 次 200、1 次 304,WAF 零拦截

换句话说:整站的导航数据(标题、描述、链接、分类)在 14 分钟内被完整搬走了。

如果你还没配置过基础的 Fail2ban 封禁,可以先看这篇:《1Panel 服务器用 fail2ban 自动封禁恶意扫描 IP 实战教程》。本篇聚焦它没覆盖的场景——匀速采集。

二、为什么现有防护全部失效

1. WAF 的 UA 规则被绕过

WAF 里配了「拦截 OneNavImport 等采集器 UA」的规则。上一次它来的时候,141 次请求里被拦了 112 次。

但这一次,它把 UA 换成了普通的 Chrome/131——UA 规则只认字符串,换一个就失效了。

2. 并发限制拦不住匀速抓取

服务器上已经配了:

limit_conn perip 25;   # 单 IP 最多 25 个并发连接

看起来够用?不。采集器是一次只发一个请求,等回来再发下一个——并发数始终是 1,永远不会触发 25 的上限。

limit_conn 管的是「同时有多少个连接」,不管「一分钟发了多少次」。匀速爬虫正是它的盲区。

3. 没有速率限制

全站配置里有 limit_conn,但没有 limit_req(请求速率限制)。这就是 1040 页被顺利抓完的根本原因。

三、方案一:Nginx limit_req 实时限速

第一步:在 http 块定义限速区和爬虫白名单

limit_req_zone 和 map 只能写在 http 上下文(写在 server 里会报语法错误),所以改的是全局 nginx.conf:

# 爬虫白名单:搜索引擎 UA 用空 key => 不限速
map $http_user_agent $sites_rate_key {
    default            $binary_remote_addr;
    "~*bingbot"        "";
    "~*googlebot"      "";
    "~*baiduspider"    "";
    "~*yisouspider"    "";
    "~*bytespider"     "";
    "~*oai-searchbot"  "";
    "~*claudebot"      "";
    "~*applebot"       "";
}

# 单 IP 30 次/分钟
limit_req_zone $sites_rate_key zone=sites_rate:10m rate=30r/m;

为什么用 map 做白名单:Nginx 的 limit_req 不支持条件开关,但当 key 是空字符串时会自动跳过限速。把搜索引擎 UA 映射成空 key,就实现了「爬虫不限速,普通访客限速」。

注意:这段只是”定义”,不会影响其他站点。只有引用了 sites_rate 的站点才会生效。

第二步:在站点配置的 server 块启用

# /sites/ 详情页限速:单 IP 30 次/分钟,突发 10
location ~ ^/sites/ {
    limit_req zone=sites_rate burst=10 nodelay;
    limit_req_status 429;
    try_files $uri $uri/ /index.php?$args;
}

参数解释:

参数 含义 建议值
rate=30r/m 每分钟 30 次(即每 2 秒 1 次) 按站点真实访问量调
burst=10 允许瞬时突发 10 个请求 给真人快速翻页留余量
nodelay 超出 burst 的请求立即返回 429,不排队 建议开启,避免拖慢响应
limit_req_status 429 被限时返回 429 Too Many Requests 比 444 友好,搜索引擎能理解

burst 是给真人留的缓冲:正常用户点开一个详情页是 1 次 HTML 请求,快速连点也就 3-5 次;10 的突发足够。而采集器 1.2 次/秒连续 14 分钟,第 41 个请求之后就会被限。

第三步:验证

# 连续请求 45 次,观察状态码变化
for i in $(seq 1 45); do
  curl -s -o /dev/null -w "%{http_code} " 
    -A "Mozilla/5.0 Chrome/120" 
    https://你的域名/sites/1.html
done
# 预期:前 30 个左右 200/304,之后出现 429

实测结果(45 次连续请求):

200 200 200 ... 200(共 29 次)→ 429 429 ...

第 30 个请求开始返回 429,和 rate=30r/m 完全吻合。
紧接着再测 10 次,只拿到 2×200 + 8×429——因为令牌桶按每分钟 30 个(即每 2 秒 1 个)匀速回填,不会瞬间恢复。

别忘了验证白名单(这是最容易翻车的地方):

# Bingbot UA 连发 20 次,应该全部 200
for i in $(seq 1 20); do
  curl -s -o /dev/null -w "%{http_code} " 
    -A "Mozilla/5.0 (compatible; bingbot/2.0; +http://www.bing.com/bingbot.htm)" 
    https://你的域名/sites/1.html
done

实测:Bingbot 20 次全 200、Googlebot 10 次全 200,白名单生效。

四、方案二:Fail2ban 兜底封禁

limit_req 是「限速」,采集器还能慢慢磨;Fail2ban 是「直接封 IP」,更彻底。两者叠加,一个限、一个封。

1. 思路

监控站点 access.log,统计单个 IP 对 /sites/ 的成功请求:

  • 60 秒内超过 30 次 → 封禁 1 小时
  • 排除搜索引擎 UA(避免误伤)

2. jail 配置

在 /etc/fail2ban/jail.local 末尾追加:

[nginx-sites]
enabled = true
filter = nginx-limit-req
logpath = /path/to/your/site/log/access.log
port = http,https
maxretry = 30
findtime = 60
bantime = 3600
banaction = iptables-multiport
action = iptables-multiport[name=nginx-sites, port="http,https"]
failregex = ^<HOST> - - [[^]]+] "GET /sites/[^"]+" (200|206|304|429) 
ignoreregex = (?i)(bingbot|googlebot|baiduspider|yisouspider|bytespider|oai-searchbot|claudebot|applebot|yandex|exabot)

同时在 [DEFAULT] 段确认自己的 IP 不会被误封:

ignoreip = 127.0.0.1/8 ::1 你的家用宽带网段/16 服务器公网IP

几个关键点:

  • failregex 直接内联在 jail 段里:fail2ban 支持 jail 级覆盖 filter 的正则,这样不用额外创建 filter 文件(对面板类环境很友好)
  • 必须把 429 也算进去:这是实测踩到的坑。Nginx 在第 30 个请求就开始返回 429,如果 fail2ban 只统计 200/206/304,采集器永远拿不到第 30 个成功响应,maxretry=30 永远触发不了——两个方案会互相抵消。加入 429 后,”被限速了还继续请求”这种更可疑的行为也会被计数
  • ignoreregex 排除搜索引擎:不排除的话,Bingbot 一天抓 90 次也会被封
  • action 必须显式指定:很多系统的 DEFAULT action 里带 sendmail,没装 sendmail 会导致 action 启动失败,封禁不生效

3. 验证正则是否有效

不装 fail2ban-regex 也能测——用 Python 跑一遍真实日志:

import re
log = open('access.log', errors='replace').read().splitlines()
host = r'(?P<host>d{1,3}(?:.d{1,3}){3})'
pat = re.compile(r'^' + host + r' - - [[^]]+] "GET /sites/[^"]+" (200|206|304) ')
ign = re.compile(r'(?i)(bingbot|googlebot|baiduspider|yisouspider|bytespider|oai-searchbot|claudebot|applebot)')

matched = [l for l in log if pat.match(l)]
human = [l for l in matched if not ign.search(l)]
print(f'匹配 {len(matched)} 行,排除搜索引擎后 {len(human)} 行')

我方实测结果:匹配 1079 行,其中 1040 行来自那个采集器——正则精准命中,没有漏。

4. 重载

fail2ban-client restart
fail2ban-client status nginx-sites

看到 Jail 'nginx-sites' started 即成功。

5. 验证封禁链路(别只看 jail 启动就以为完成了)

jail 启动 ≠ 封禁能用。用保留测试地址验证一次完整链路:

fail2ban-client set nginx-sites banip 192.0.2.1   # 192.0.2.1 是 TEST-NET 保留地址,不会影响任何人
fail2ban-client status nginx-sites                 # 确认出现在 Banned IP list
fail2ban-client set nginx-sites unbanip 192.0.2.1 # 立即解封

实测:封禁 → 列表出现 192.0.2.1 → 解封成功,链路正常。

这一步顺带能发现 action 配置错误——如果 banip 后列表里没有,或者 iptables 链没生成,说明 action 有问题(比如前面提到的 sendmail)。

6. 一个容易忽略的细节:日志路径

Nginx 配置里写的是容器内路径(如 /www/sites/xxx/log/access.log),而 fail2ban 运行在宿主机,logpath 要写宿主机上的真实路径(如 /opt/1panel/www/sites/xxx/log/access.log)。

验证方法:直接读那个文件,确认能看到最新日志:

tail -3 /opt/1panel/www/sites/xxx/log/access.log

如果路径写错,jail 状态正常但永远统计不到任何请求,属于静默失效。

五、踩过的坑(都是真实教训)

  1. 面板的网站配置 API 会重建配置
    通过 1Panel 的 websites/config/update 接口写入自定义 location,接口返回 200,但读回发现内容被模板重建,新加的 location 被丢弃。最后只能在面板「网站 → 配置文件」里手动粘贴。改之前一定要备份。

  2. fail2ban 的 DEFAULT action 带 sendmail
    服务器没装 sendmail,日志里一直报 ERROR Failed to start jail 'nginx-sites' action 'sendmail-whois-lines',而 action 启动失败可能导致封禁也不执行。解决办法:jail 里显式写 action = iptables-multiport[...],去掉邮件通知。

  3. 重复的 jail 段会让 reload 失败
    改配置时如果同一个 [nginx-sites] 出现两次,fail2ban 会报错。改完记得检查段名是否唯一。

  4. CDN 环境下 set_real_ip_from 0.0.0.0/0 有风险
    为了拿到真实用户 IP,很多配置会写 set_real_ip_from 0.0.0.0/0 + real_ip_header X-Forwarded-For。这意味着任何直连源站的人都能伪造 XFF 来绕过按 IP 的限速。更安全的做法是把 set_real_ip_from 收窄为 CDN 官方回源网段。

  5. limit_conn 不等于 limit_req
    并发限制挡不住匀速抓取,这是本次事故最核心的认知。

  6. 两个方案会互相抵消
    Nginx 在第 30 个请求就返回 429,而 Fail2ban 最初只统计成功响应(200/206/304)——采集器最多拿到 29 个 200,maxretry=30 永远触发不了。必须把 429 也加入 failregex,让”被限速后仍继续请求”的行为被计数。

  7. ignoreip 漏了自家宽带,差点把自己封了
    部署完做压力测试时发现:请求在日志里记录的来源是家用宽带 IP(不是服务器 IP)。如果没把这个网段加进 ignoreip,一旦测试触发封禁,第一个上不了站的就是自己。上线前务必把「自家宽带网段 + 服务器公网 IP + 127.0.0.1」都写进 ignoreip。

六、实测效果汇总

部署完成后逐项验证的结果:

验证项 实测结果
普通 UA 连续请求 45 次 前 29 次 200,第 30 次起返回 429
紧接着复测 10 次 2×200 + 8×429(令牌桶未回填,符合预期)
Bingbot UA 连发 20 次 全 200(白名单生效)
Googlebot UA 连发 10 次 全 200
Fail2ban 封禁链路(ban/unban 192.0.2.1) 封禁 → 列表出现 → 解封,通过
Fail2ban 正则对历史日志的匹配 1079 行,其中 1040 行来自那个采集器(精准命中)

采集器下次再来的完整链路:

  1. 第 30 个请求 → 收到 429
  2. 如果它继续请求 → 60 秒内累计 30 次(含 429)→ Fail2ban 封 IP 1 小时
  3. 换 IP 再来 → 同样处理
  4. 搜索引擎爬虫走白名单,全程不受影响

最后提醒一句:限速规则不是一劳永逸的。攻击者会换 UA、换 IP、降速。真正长期有效的,是「速率限制 + 行为封禁 + 日志监控」三层配合,以及定期回看日志里那些”看起来很正常的异常流量”。


附:本次事故的关键数字备忘

  • 攻击窗口:14 分 19 秒
  • 请求:1042 次(1040 个不重复详情页)
  • 频率:约 1.2 次/秒(匀速)
  • 原防护:limit_conn perip 25 + WAF UA 规则 → 全部失效
  • 最终方案:Nginx limit_req 30r/m + Fail2ban 30 次/60 秒封 1 小时
×
喜欢这篇文章吗?
如果这篇文章对你有帮助,可以扫码打赏支持我一下。
微信
微信赞赏码
支付宝
支付宝赞赏码
×
长按保存图片,分享给朋友
文章海报
© 版权声明

相关文章