NAS 被爆破 1 万次:fail2ban 从日志指纹到真正封禁的排障记录

日期:2026-10-01 场景:家里的群晖 DS418play 挂在公网服务器的 Nginx 反向代理后面(DSM 5000 / 5001、HTTPS 域名) 现象:DSM 登录警告刷屏,而且所有攻击来源在 DSM 眼里都是同一个 IP 结果:fail2ban 上线并真正生效,爆破量 5 分钟内从 36 次掉到 6 次


1. 现象

NAS 的 DSM 登录日志一直在刷这类警告:

User [administrator] from [<代理IP>] failed to sign in to [DSM] via [password] due to authorization failure.
User [compta]        from [<代理IP>] failed to sign in to [DSM] via [password] due to authorization failure.
User [jean-luc]      from [<代理IP>] failed to sign in to [DSM] via [password] due to authorization failure.

用户名是随机的(davvmart、compta、jean-luc…),典型爆破字典。但来源 IP 全都是同一个内网地址 —— 那是我自己那台公网反向代理的内网 IP。

到 Nginx 日志里一统计,规模比想象的大得多:

5000 端口日志(34 小时窗口):
  总请求            10,254 次
  命中登录接口       9,783 次     ← 93%
  独立来源 IP          627 个
  TOP50 占比          79.8%       ← 分布式爆破,不是单个脚本
  单 IP 最高           ~600 次

攻击者是固定 IP 池轮着打,每个 IP 各打 ~600 次:

77.91.71.90 / 91 / 92 / 23 / 24 / 25      185.136.15.80 / 81 / 82 / 110 / 111
138.226.239.90 / 91                        13.215.212.94

2. 坑一:反代后面,所有来源都变成代理 IP

因为 NAS 是从我的 Nginx 反代出去的,DSM 看到的永远是连接它的那台代理,于是:

后果 说明
登录日志没用 全记成代理的内网 IP,看不出谁在打
自动封锁会把自己人锁死 DSM 的「自动封锁」按来源 IP 计数。爆破失败数一超限,它封的是那台代理服务器 → 所有人、所有设备一起被挡在门外

也就是说:服务自己的防护机制,在反代架构下会反过来咬自己一口。

解法是让 NAS 相信反代传来的 X-Forwarded-For。Nginx 侧本来就一直在传(宝塔默认带 X-Real-IP / X-Forwarded-For),缺的只是 NAS 端「愿意相信」这句话。在 NAS 上加:

sudo tee /usr/local/etc/nginx/conf.d/realip.conf > /dev/null <<'EOF'
set_real_ip_from <反向代理的内网IP>;   # 只信这台反代
real_ip_header X-Forwarded-For;
real_ip_recursive on;
EOF
sudo nginx -s reload

DSM 更新/重启会重写它自己的 nginx 配置,所以这段要放进 控制面板 → 计划任务 → 触发的任务(boot-up),每次开机重放。

加完之后 DSM 就能看到真实客户端 IP 了,它自己的自动封锁也才按真实来源计数。


3. 坑二:想当然地按 401/403 匹配

一开始的思路很直接:爆破就是「打登录接口 + 返回 401/403」,那就写个正则去抓密码错误:

failregex = ^<HOST> -.*"(GET|POST) .*(login|auth|signin).*" (401|403)

这条正则一条都抓不到。 真实日志里登录类请求的状态码分布是:

5000 端口登录类请求(9,868 次):
   200 → 9,868 次    ← 全部
   404 →     5 次
   401 →     0 次    ← 想要的一个都没有
   403 →     0 次

原因:DSM 的 API 认证失败返回的是 HTTP 200,加上一个 JSON 错误体,不是 401。 而且这是应用程序返回的业务响应,不是 Nginx 的错误页,所以 error.log 里也不会有任何东西 —— 只能在 access.log 里认。

教训:不要凭常识猜攻击特征,去看真实日志。


4. 怎么从日志里挖出攻击指纹

方法:把日志按 「方法 + 路径 + 状态码 + 响应体字节数 + 关键参数」 五元组聚合,同时对「已知正常用户的日志」做同样的统计,找两者的不重叠特征。

import re
from collections import Counter
pat = Counter()
for line in open(LOG, encoding="utf-8", errors="replace"):
    m = re.search(r'"(\w+) (\S+) HTTP[^"]*" (\d{3}) (\d+|-)([^\n]*)', line)
    if not m: continue
    meth, url, code, size, rest = m.groups()
    pat[(meth, url.split("?")[0], code, size, "passwd=" in url)] += 1
for k, n in pat.most_common(15):
    print(k, n)

对照结果非常干净:

                  方法   URL带passwd=   状态码   响应体字节
我的正常登录       POST      否          200     42 ~ 673        ← 密码在请求体
攻击爆破           GET       是          200     0 ~ 199         ← 凭据塞在 URL 里

真实日志行:

攻击者: GET  /webapi/entry.cgi?account=helena&api=SYNO.API.Auth&format=sid&method=login&passwd=123&...  200 49
我自己: POST /webapi/entry.cgi?api=SYNO.API.Auth                                                        200 156

判别点 = URL 查询串里带 passwd=。 实测:我自己的正常流量 2,183 行里 0 行含 passwd=;攻击流量 10,390 行里 9,900+ 行含。

一开始我还加了「响应体很小 = 登录失败」作为第二重判据 —— 这是后来出问题的伏笔(见第 6 节)。


5. 写 filter,然后必须双向验证

/etc/fail2ban/filter.d/dsm-auth.conf:

[Definition]
failregex = ^<HOST> - - \[[^\]]*\] "(?:GET|POST) [^"]*passwd=[^"]*"
ignoreregex =

关键步骤:不能只测攻击日志,还必须拿正常流量验证 0 误报。 宝塔的 fail2ban-regex 不在 /usr/bin/,在这里:

F2B=/www/server/panel/pyenv/bin/fail2ban-regex

# 攻击日志 → 应大量命中
$F2B /www/wwwlogs/<被攻击的>.log /etc/fail2ban/filter.d/dsm-auth.conf

# 正常用户日志 → 必须 0 命中(这一步最关键)
$F2B /www/wwwlogs/<正常流量>.log /etc/fail2ban/filter.d/dsm-auth.conf

# 看具体哪几行被误命中
$F2B --print-all-matched /www/wwwlogs/<正常流量>.log /etc/fail2ban/filter.d/dsm-auth.conf

中间踩了一个坑:我原本写了条「兜底正则」(api=SYNO.API.Auth + 小响应体),一测发现误伤了我自己 5 次正常操作:

POST /webapi/entry.cgi?api=SYNO.API.Auth   200 156   ← 这是我自己在开 File Station
POST /webapi/entry.cgi?api=SYNO.API.Auth   200  64

正常流量里也有小响应体的合法调用,所以小响应体单独不能做判据。删掉兜底分支后:

5000(被攻击)     → 9,767 / 10,332 行命中  ✅
nas(我的正常流量) →      0 / 2,183 行命中   ✅ 零误报
5001(我的)       →      0 /   187 行命中   ✅
主站               →      0 /    13 行命中   ✅

jail 放 /etc/fail2ban/jail.d/dsm-auth.conf:

[dsm-auth]
enabled  = true
filter   = dsm-auth
port     = 5000,5001,443
logpath  = /www/wwwlogs/xxx_5000.log
           /www/wwwlogs/xxx_5001.log
           /www/wwwlogs/<NAS站点>.log
maxretry = 5
findtime = 1800        # 见下方说明
bantime  = 86400
action   = bt-ipset
ignoreip = 127.0.0.1/8 <内网/VPN 网段> <自己的出口网段>

两个容易忽略的点:

  1. findtime 要按攻击节奏调。 这个攻击是「慢速分布式」:10 分钟窗口内单 IP 最高只有 4 次,maxretry=5 永远触发不了;放宽到 30 分钟才有 13 个 IP 达到阈值。
  2. jail 别写在 jail.local 里。 宝塔插件的 jail 都在 jail.local(带 #名字-START/END 标记),面板会重写这个文件。自己的 jail 放 jail.d/,永远不会被覆盖。

6. 坑三(最深的):宝塔/firewalld 会把封禁规则冲掉

上线后 fail2ban-client status 显示已经封了 11 个 IP —— 看起来一切正常。

但是攻击一点没减少。查下去:

$ iptables -S | grep f2b
(空)                                    ← 一条规则都没有!

$ ipset list f2b-dsm-auth | grep "Number of entries"
Number of entries: 11                     ← 条目还在

IP 被封在 ipset 里,但没有任何 iptables 规则引用这个 ipset → 封禁完全没生效。

原因:这台机器装了宝塔面板自带防火墙 + firewalld,它们会重写 INPUT 链,把第三方插进去的规则全部清掉。

更糟的是连锁反应 —— 我顺手检查了原有的 jail:

jail            fail2ban 记录封禁    iptables 规则
sshd                     19          ❌ 0 条(失效)
ftpd                      0          ❌ 0 条(失效)
xxx-cc                  271          ❌ 0 条(失效)

所有 fail2ban 封禁全都是死数据(f2b-sshd 的 ipset 里有 16 条,但没有规则引用它)。SSH 爆破保护在这个服务器上已经静默失效很久了。

解法:写进宝塔自己的 ipset

宝塔防火墙的链结构是长期存在的:

INPUT → IN_BT → IN_BT_user_ip → match-set in_bt_user_drop_ipset src → DROP
                                 ↑ 往这里写,既生效、又不会被冲掉

所以自己写一个 action,把封禁写进宝塔的这个 ipset —— /etc/fail2ban/action.d/bt-ipset.conf:

[Definition]

# 集合由宝塔创建(hash:net);-exist 保证已存在时不报错
actionstart = ipset create in_bt_user_drop_ipset hash:net -exist
actionstop  =
actioncheck =

actionban   = ipset add in_bt_user_drop_ipset <ip> -exist
actionunban = ipset del in_bt_user_drop_ipset <ip> -exist

然后 jail 里改成 action = bt-ipset。

⚠️ 改完 action 必须 systemctl restart fail2ban,reload 换不掉! 实测 reload 之后日志里仍然打印 Flush ticket(s) with firewallcmd-ipset —— 也就是说它写的还是旧 action,封禁依旧落到那个会被冲掉的 ipset 里。


7. 验证:不看状态,看攻击量

判断封禁到底有没有生效,不能看 fail2ban-client status(它永远说封了 N 个),要看攻击量有没有下降:

爆破请求量(每 5 分钟,5000 端口):
  15-20 分钟前 |  38 次 ██████████████████████████████████████
  10-15 分钟前 |  30 次 ██████████████████████████████
   5-10 分钟前 |  36 次 ████████████████████████████████████
   0- 5 分钟前 |   6 次 ██████                              ← -85%

并且被封锁的 13 个 IP 确实不再出现在 Nginx 日志里:

185.136.15.80/81/82/110/111
 77.91.71.23/24/25/90/91/92        全部停止请求
138.226.239.90/91
(最后出现时间 = 封禁生效那一刻,之后再无记录)

宝塔 ipset 实际条目: 13
我自己的访问: nas 200 / 5000 200 / 主站 200   ← 完全不受影响

8. 复盘:三个坑的层次

# 坑 表象 根因
1 反代掩盖真实 IP DSM 日志全是代理的内网 IP,自动封锁会封掉代理 后端没信任 X-Forwarded-For
2 按 401/403 写正则 正则一条都匹配不到 DSM 失败返回的是 200 + JSON,不是 401
3 封禁规则被冲掉 状态显示已封,攻击照旧 宝塔防火墙/firewalld 重写 INPUT 链,清掉第三方规则

三个坑的共同点:「看起来正常」和「真的生效」是两回事。

  • 状态码看起来该是 401 → 实际是 200
  • 状态显示「已封 11 个」 → 实际 iptables 里一条规则都没有
  • fail2ban-client 说封禁成功 → 实际 action 根本没换

所以每一步都得用真实数据验证: 正则要用 fail2ban-regex 双向测(攻击日志 + 正常日志); 封禁要 iptables -S 确认规则存在; 效果要看攻击量统计,而不是看面板上的数字。


9. 排查清单(封禁不生效时照这个顺序查)

# 1. 规则真的在吗?(最关键的一步)
iptables -S | grep f2b              # 空 → 被宝塔/firewalld 冲掉了

# 2. 宝塔链引用的 ipset 长什么样
ipset list in_bt_user_drop_ipset
iptables -S | grep in_bt_user_drop_ipset

# 3. jail 实际在用哪个 action
grep "Flush ticket" /var/log/fail2ban.log | tail -3

# 4. 改完 action 要 restart(reload 不生效)
systemctl restart fail2ban

# 5. 最终标准:攻击量是否下降
tail -500 /www/wwwlogs/xxx.log | grep -c "关键词"

常用命令:

fail2ban-client status dsm-auth                  # 看 jail 状态
fail2ban-client get dsm-auth banip               # 看封了哪些 IP
fail2ban-client set dsm-auth unbanip <IP>        # 解封
ipset list in_bt_user_drop_ipset                 # 看实际生效的封禁
tail -f /var/log/fail2ban.log | grep Found       # 看实时识别

10. 还没解决的部分

fail2ban 只能封「打得凶」的 IP(够阈值的),对这个僵尸网络里 600 多个每 30 分钟只打 1~4 次的慢速 IP 无能为力 —— 它们合计仍有 ~30 次/5 分钟。

要彻底挡掉,还是得用白名单:只放行自己的出口网段 + 内网 VPN,其余一律 403。这样陌生 IP 连 DSM 的登录页都看不到,也就无所谓爆破了。代价是失去「随便在哪都能访问」的便利。

两种做法可以并存:白名单管静态的「谁能进」,fail2ban 管动态的「谁在乱试」。

← 返回文章列表