起因不是被入侵,是

我的 nginx 接了 ,异常会发 Discord。它长这样,连当下的 Top IP 和 Top Path 都一起贴给你:

诊断资讯给得很足,问题是它每天要响几百上千次,而且绝大多数不是我的服务有事,是扫描器在敲 /wp-login.php/.env、各种 .php 端点造成的 4xx。我的站根本没有 PHP,但它们不知道,也不在乎,就是一直敲。

(上面那张其实是少数「真的有事」的一次,/api/spotify/audio-features 五分钟被打了 9968 次,那是我自己的东西在回圈。IP 我遮掉了,那几条是我自己的出口。)

当时机器上已经有 fail2ban,而且数字很漂亮:

4257
累计登入失败
fail2ban 统计
614
累计封锁 IP
0
当下封锁中
封锁时间短,到期就放

封过 614 个 IP,听起来很有用。但我盯着那串通知想到一件事:他们一直在换 IP。封掉一个,下一个就换一条线再来。这样封下去到底有没有终点?

这篇是后来换上 CrowdSec 的完整过程:为什么它比 fail2ban 适合这个情境、架设踩到的三个坑、这台机器六天内实际挡下什么,以及我把自己封在门外的三次(第三次最蠢,也最值得写)。最后是我做的 Discord 解锁 bot,因为被自己挡在外面时,你连 SSH 都进不来。

先看这台机器长什么样#

后面的坑跟自我封锁都跟架构有关,所以先把环境讲清楚。

几个对后文有影响的重点:

  • 全部跑在同一台实体机上,家用固网加固定 IP,路由器把 80/443 转进来。没有云端负载平衡,也没有第二台可以在出事时顶着。
  • 封锁发生在 nftables,也就是比 nginx 更前面。所以被挡的请求连 access log 都不会留下,而你这端看到的是静默逾时而不是拒绝连线。这个位置关系是后面所有诊断的基础。
  • CrowdSec 引擎跟 bouncer 是分开的:引擎读 nginx 的 log 做判断,bouncer 负责在防火墙执行。图上那两条虚线就是这个回圈。
  • nginx 后面挂着十几个子网域,部落格、NAS、监控、MQTT、各种小工具都在里面。任何一个服务的限流或错误,都会喂进同一份 access log,也就会喂给 CrowdSec。

还有一件跟本文有关的事:我的主网域走 Cloudflare 灰云(只做 DNS,流量直连我家),但另一位站长把他的一个子网域用橘云指到我这里,那条路径的来源 IP 是 Cloudflare 的节点。这件事在交叉监控那篇里咬过我一次,细节写在这里:

为什么不是 fail2ban#

fail2ban 没有坏掉,它做的事一直很正常。问题在于它的模型:读你自己的 log,累积你自己的失败次数,封你自己看过的 IP

对一个会换 IP 的对手,这是打地鼠。每个新 IP 对你来说都是全新的,要重新从零累积到门槛才会被封,而在那之前它已经扫完一轮了。

CrowdSec 换了个前提:别人遇过的坏 IP,你直接拿来用

fail2banCrowdSec
判断依据只有本机 log本机 log 加上全球社群黑名单
遇到没见过的 IP从零累积,先让它扫一轮别人标记过就直接挡
执行封锁自己改 iptables交给 bouncer(可换 nftables / nginx / Cloudflare)
规则来源自己写 regexHub 上的社群规则,含大量 CVE 场景

第三栏那个「交给 bouncer」是架构差异:CrowdSec 把侦测执行拆开。引擎只负责判断谁该挡,真正动防火墙的是另一支 bouncer。这个切分后面会变得很重要。

还有一点对我是决定性的:它是自架的。当时另一个选项是把站挂到 Cloudflare 后面让它挡,但那等于把流量交给别人的 proxy。CrowdSec 在本机读 log、在本机的防火墙执行,不动我现有的架构。

NOTE

社群黑名单预设就开 安装时会自动 Registering to CAPI(Central API),那份共享黑名单立刻就在帮你挡,不用额外设定。cscli console enroll 是选配,给你仪表板和更大的订阅清单。

三个坑,全来自同一个根因#

装 CrowdSec 只要两行:

bash
curl -s https://install.crowdsec.net | sudo sh
sudo apt install -y crowdsec
sudo apt install -y crowdsec-firewall-bouncer-nftables   # Ubuntu 24.04 预设 nftables

然后我就卡了一整晚,而且三个坑是同一颗骨牌推倒的。

apt 从 ESM 抓到了上古版本

官方脚本确实把 packagecloud 的 repo 加进去了,但 apt install crowdsec 实际下载的来源是 esm.ubuntu.com里有一份被冻结的 1.4.6,而 packagecloud 上是 1.7.x。

装完当下不会有任何错误讯息,你要跑 cscli version 才会发现版本不对。

平心而论这个设计我到现在还是觉得很烦。ESM 的立意是替第三方套件延长安全维护,但它把版本冻在某个时间点、又排在你刚加的官方 repo 前面,结果就是「你以为自己装了最新版」。经过这次我认真想过:如果只是要一台干净的伺服器,Debian 大概比 Ubuntu LTS 少很多这种惊喜。

跨大版本升级,hub 的路径搬家了

发现版本旧,自然是升上去。而 1.4.6 到 1.7.8 之间,hub 资料的位置换了:

console
旧版  /var/lib/crowdsec/hub/...
新版  /etc/crowdsec/hub/...

旧版留下的一堆 symlink 全部指向已经不存在的路径,变成断掉的连结。安装时那串 Ignoring file ... no such file or directory 就是在讲这件事,但它混在几十行输出里,很容易当成杂讯滑过去。

parser 没载入,于是它什么都侦测不到

断掉的 symlink 意味着 nginx-logs 这类 parser 根本没被载入。症状是 cscli metrics 看起来有在读 log,但解析率只有 9%:

console
Source                          Lines read   Lines parsed
file:/var/log/nginx/access.log  41           3

它读得到档案,却看不懂内容。看不懂就不会触发场景,不触发场景就永远不会封任何人。表面上服务是绿的,实际上完全空转。

修法是把断掉的连结清干净再重装规则集:

bash
sudo find /etc/crowdsec -xtype l -delete     # 只删「指向不存在目标」的 symlink
sudo cscli hub update
sudo cscli collections install \
  crowdsecurity/nginx crowdsecurity/sshd crowdsecurity/linux \
  crowdsecurity/whitelist-good-actors --force
sudo systemctl reload crowdsec

TIP

教训:直接 pin packagecloud 装 1.7.x 这三个坑是一条因果链,源头只有一个:第一步装到了 ESM 的旧版。装完先跑 cscli version 对一下版本号,可以省掉后面全部。

两个很容易误判的诊断细节#

cscli metrics 的数字是累计的。我一度以为解析率修好了却没有起色,其实新解析的行被之前那堆失败的行稀释着。reload 不会清空计数,要 restart 才会。想看「修完之后」的真实比例,就得重启后重新累积。

cscli explain 一翻两瞪眼。它会把一行 log 实际走过的 parser 链印出来,哪一关失败一目了然:

bash
sudo cscli explain --file /var/log/nginx/access.log --type nginx | tail -25

修好之后应该看到 crowdsecurity/nginx-logs 绿色通过并往下流进场景,而不是只剩 parser failure。

SSH 的来源只留一个#

还有一个是我自己调出来的。SSH 事件可以从 /var/log/auth.log 读,也可以从 journalctl 读,而两边是同一批事件的两个来源。都收的话每次登入失败会被算两次,场景比你设定的门槛更早触发,alert 数量也灌水。

实测两者都解析出 261 笔,但 auth.log 要读 10.13k 行才拿到,journalctl 只读 330 行。所以我的 acquis.yaml 只留 journalctl:

yaml
source: journalctl
journalctl_filter:
  - "_SYSTEMD_UNIT=ssh.service"
labels:
  type: syslog

六天,1619 次#

这是这台机器最近六天的实际数字(cscli alerts list 全量统计):

1619
侦测到的攻击
6 天,平均每天 270
25.22kIP
防火墙封锁中
61.29k
丢弃封包
共 3.52 MB

拆开来看攻击类型:

场景次数在打什么
ssh-time-based-bf978SSH 慢速暴力破解
http-probing144乱敲端点探路
ssh-time-based-bf_user-enum121猜使用者名称
http-sensitive-files44.env.git 这类档案
http-bad-user-agent39已知的扫描器 UA
http-wordpress-scan36找 WordPress 漏洞
http-admin-interface-probing36找后台登入页
http-cve-2021-4177333Apache 路径穿越

剩下的长尾是一整排 CVE 探测:CVE-2017-9841(PHPUnit RCE)、http-cve-2021-42013CVE-2022-41082(Exchange)、thinkphp-cve-2018-20062netgear_rcefortinet-cve-2018-13379jira_cve-2021-26086。这些我一个都没装,但它们照样每天来敲。

攻击不是来自「坏人的电脑」#

来源 AS 的分布才是最有意思的一栏:

来源次数
MICROSOFT-CORP-MSN-AS-BLOCK199
Techoff Srv Limited191
DIGITALOCEAN-ASN171
Hangzhou Alibaba Advertising165
Hetzner Online GmbH161
WEB MASTER COLOMBIA SAS153
Sai gon Postel Corporation137

前七名几乎全是云端机房:Azure、DigitalOcean、阿里云、Hetzner。扫描器跑在按小时计费的 VM 上,封掉一台,再开一台就是几秒钟的事,IP 还不一样。

这正好从数据面回答了我最初那个问题:封 IP 这条路,对手的成本比你低得多。而这也是社群黑名单的价值所在,那台新开的 VM 在打到你之前,通常已经打过别人了。

IMPORTANT

社群黑名单挡掉的量,是我自己抓到的三千倍 cscli metrics 的 bouncer 区块把两个来源分得很清楚:

console
Origin                       active_decisions
CAPI (community blocklist)   25.21k
crowdsec (security engine)        8

我自己的引擎当下抓到 8 个,社群黑名单同时提供了 25210 个。累计封锁决策里,http:exploit 17249、ssh:bruteforce 6632、http:scan 2894,全部来自 CAPI。 换句话说,这套东西的绝大部分价值,在你装好的那一刻就生效了,不需要等你自己累积。

被挡的不全是坏人#

有个细节值得讲清楚。某次封锁清单里出现 crowdsecurity/http-bad-user-agent 挡下的 167.94.146.48,查了一下属于 Censys,做网际网路普查的研究机构,同类的还有 Shodan。它们不是要打你,是在编目全世界的公开服务。

挡不挡是你的选择,但知道自己在挡什么比较好。顺带一提,善意爬虫不用担心:whitelist-good-actors 这个 collection 预设就装了,Google、Bing 和各家 SEO 爬虫都在白名单里,而且是用 而不是只看 User-Agent,冒充不了。

我把自己封掉三次#

这是全篇最该被记住的部分。一套会自动封锁的系统,迟早会封到你自己。

第一次:关掉 SSH 密码登入之后

我把 SSH 改成金钥登入、关掉密码。但区网那台开发机还在用旧的方式重试,一直失败,于是 ssh-bf 场景判定它是暴力破解,bouncer 把那个内网 IP 整个 drop 掉。

症状很怪:内网完全连不进去,但从外面绕进来反而正常,因为外网走的是另一条来源 IP。

第二次:一个 app bug 引发连线风暴

我在测一个档案上传功能,那个 handler 有 bug,一上传就把整台机器卡住。机器一卡,我的电脑就开始疯狂重连 SSH 和 HTTPS。CrowdSec 看到的就是「有个 IP 在短时间内狂敲」,判定为攻击,封。

当下的画面是:手机的 SSH 进得去,电脑完全连不上,网站也全开不了。因为手机走 4G,是不同的 IP。

第三次:我的限流器喂了我的封锁器

同一天稍晚又中一次,而这次的机制值得单独拿出来讲。看 cscli decisions list:

console
Ip:<我的出口IP>  crowdsecurity/nginx-req-limit-exceeded  ban  6 events

nginx-req-limit-exceeded 这个场景做的事是:看到 nginx 回 429,就判定这个 IP 在洗流量。而 429 正是我自己设的 nginx 限流器发出来的。

于是回圈长这样:

我自己的两个防护措施串起来咬了我一口。

三个讯号可以立刻判断#

被 CrowdSec 挡跟服务挂掉,症状其实不一样:

讯号意义
i/o timeout 而不是 connection refused封包被静默丢弃。这是防火墙 DROP 的特征,服务挂掉会是 refused
手机正常、电脑全挂两者是不同的来源 IP,问题出在「你这条线」而不是伺服器
连图片、CSS 都载不出来整个 IP 被挡,不是某个 API 坏掉

三个同时成立,几乎可以直接跳到 cscli decisions list 找自己。

修法有两层#

区网用 whitelist parser。在 s02-enrich 放一个 parser,让整个 RFC1918 永远不会被封:

yaml
# /etc/crowdsec/parsers/s02-enrich/lan-whitelist.yaml
name: koimsurai/lan-whitelist
description: "Never ban LAN / RFC1918"
whitelist:
  reason: "private LAN"
  cidr:
    - "192.168.0.0/16"
    - "10.0.0.0/8"
    - "172.16.0.0/12"

这条规则到目前为止已经放行了 16037 次,可以想像没有它会有多烦。

公网出口 IP 用 allowlist。区网白名单救不了「你人在外面」的情况,那时你的来源是公网 IP:

bash
sudo cscli allowlists create home-dev -d "我的开发机 IP"
sudo cscli allowlists add home-dev <你的出口IP>
sudo cscli allowlists inspect home-dev

WARNING

allowlist 比「逐条调松场景」好 nginx-req-limit-exceeded任何 nginx 429 都会触发,不管是哪个站台、哪条限流规则。逐条去调限流是打地鼠,allowlist 一次让你所有 IP 免疫,回圈直接断掉。

代价要心里有数:白名单里的 IP 完全不会被建立封锁决定。所以只放你真的信任的来源,而且住宅动态 IP 重拨就会变,加了也是白加。

用 Discord 把自己放出来#

前面三次的共同麻烦是:要解封,得先连得进去。而我被挡的时候,SSH 正是连不进去的那个东西。

当时的替代方案是手机。我用 连,平常我很喜欢它,功能该有的都有。但在路上、单手、用触控键盘敲 sudo cscli decisions delete --ip ... 这种指令,而且还得先从一整张表里认出哪个 IP 是自己,体验实在称不上好。

还有一件事让情况更麻烦:CrowdSec 预设不会通知你它封了谁profiles.yaml 里那几行 notifications 全是注解掉的,除非你自己去接 slack / http / email 的 plugin。所以「被自己挡」这件事从来不会主动告诉你,你只会发现连不上,然后开始猜。

yaml
# /etc/crowdsec/profiles.yaml —— 预设长这样,通知全是注解
decisions:
 - type: ban
   duration: 4h
# notifications:
#   - slack_default
#   - http_default

所以我在既有的 Discord bot 上加了两个指令,顺便把「查」跟「解」一起解决。但这里有个架构问题:bot 跑在容器里,而 CrowdSec 是主机服务,容器碰不到主机的 cscli

把 docker socket 或主机 root 权限挂进容器可以解决,但那等于让一个对外连网的 bot 拿到整台机器的控制权。所以我改成档案伫列:

容器能做的只有一件事:丢一个请求档进伫列。真正执行 cscli 的是主机上以 root 常驻的 watcher,它读请求、验证、执行、把结果写回。就算 bot 被打穿,攻击者也只能丢请求档,而且动作限定在白名单里,拿不到 CrowdSec 的控制权。

watcher 的关键几行:

python
ALLOWLIST = "parole"          # 解锁后顺手加进这个 allowlist
ALLOWLIST_TTL = "4h"          # 只给 4 小时,不永久放行

def valid_ip(s):
    try:
        ipaddress.ip_address(s)   # 挡 command injection
        return True
    except ValueError:
        return False

if action == "unblock":
    ip = str(req.get("ip", ""))
    if not valid_ip(ip):
        return {"ok": False, "output": f"invalid ip: {ip}"}
    rc, out = run(["decisions", "delete", "--ip", ip])
    ensure_allowlist()
    run(["allowlists", "add", ALLOWLIST, ip, "-e", ALLOWLIST_TTL])

三个设计上的细节:

IP 一定要验过才进指令

这个字串会被丢进 subprocess 执行。虽然用的是 list 形式(不经过 shell),但一个没验证的栏位迟早会出事。ipaddress.ip_address() 一行就挡掉了。

解锁之后给 4 小时 parole

如果只是删掉封锁决定,那个触发封锁的行为往往还在继续(例如你还在狂重整),几秒后就会被重新封回去。所以解锁时顺手把 IP 加进一个叫 parole 的 allowlist,但只给 4 小时,不永久放行。

只有站长本人能用

cog 检查 interaction.user.id == OWNER_ID,而且 OWNER_ID 没设的时候是拒绝所有人,不是放行所有人。指令回复一律 ephemeral,封锁清单不会留在频道里。

做完之后,/crowdsec_list 直接把目前封锁清单摊在手机上,每笔都有 IP、触发的场景、还剩多久:

认出自己那笔之后,/crowdsec_unblock 填 IP 就解开,回复会告诉你 parole 给了多久:

清单上那些 IP 顺带说明了一件事:http-cve-2021-41773http-wordpress-scanhttp-sensitive-files,全是前面统计里的那几种,而且 AS 查下去又是 Google Cloud 和 Azure。连被我拿来当示范的这一批,都还是跑在云端机房上的扫描器。

最后那个让整件事成立的关键,其实很简单:bot 是主动连出去 Discord 的。你的 IP 被防火墙 DROP,挡的是「连进来」的方向,完全不影响 bot 那条既有的对外连线。所以你被锁在门外时,它照样收得到你的指令。

一个架在自己机器上的 web 管理面板做不到这件事,因为你连得到它才能用它,而你正好连不到。用手机 SSH 则是绕过了封锁(行动网路是另一个 IP),但你还是得在手机上打完整的指令。

回到最初:Discord 安静了吗#

安静了,但原因跟我当初打算做的事不一样。

当时除了装 CrowdSec,我还准备了一份 netdata 的告警静音档,想把 4xx 那组 route 到 silent,只留 5xx 和「变慢」。写好了、指令也备妥了。

结果刚刚翻了一下:

console
$ ls /etc/netdata/health.d/
(空的)

$ ls ~/Server/netdata-web-noise-silence.conf
-rw-rw-r-- 1 timo9378 timo9378 2922  netdata-web-noise-silence.conf

那个静音档从来没部署过,还躺在家目录里。而我确实很久没被 4xx 告警吵过了。

原因是 4xx 自己消失了。现在同一份 access log 抽 6038 行来算:

46.1%
2xx
4.0%
4xx
远低于告警门槛
0.0%
5xx

扫描器在打到 nginx 之前就被 bouncer 在防火墙层 drop 掉了,log 里根本不会留下那笔 404,4xx 比例自然压在门槛底下,告警就不会触发。

这个结果比我原本要做的好得多。静音是把仪表遮起来,而这是真的没事发生。如果当初我先部署了静音档,现在 netdata 就算真的遇到扫描潮也不会叫,我会失去一个有效的讯号;而现在它一叫,通常就真的有事,像文章开头那张,那次是我自己的 Spotify API 在回圈。

TIP

先治源头,再考虑静音 告警很吵的时候,第一直觉常常是调门槛或静音。但如果吵的原因是「真的有很多坏请求进来」,那消除来源会连告警一起解决,而且保留了告警的判别力。静音应该留给「这个告警本身就不该存在」的情况。

fail2ban 后来怎么了#

老实说,我没有正式退役它。CrowdSec 上线之后就没再管,直到后来才发现它其实停在一个尴尬的状态:服务没在跑,设定档还留着。

console
$ systemctl is-active fail2ban
inactive
$ systemctl is-enabled fail2ban
disabled

它是怎么停掉的也有点好笑:当初为了加区网白名单,我在 jail.d/ 丢了一个 drop-in,那个档让它开机解析失败,从此就没起来过。而我没发现,因为那段期间 CrowdSec 一直在挡,没有任何症状。

这里的教训不是「fail2ban 不好」,而是:两套功能重叠的防护并存,最危险的不是冲突,是你以为两边都在守。如果那时停掉的是 CrowdSec,我大概也要过很久才会知道。要换就换干净,并且确认新的那套真的在动。

一页式清单#

CrowdSec 自架检查清单
  1. 装完先 cscli version。从 ESM 拿到旧版不会有错误讯息,但后面所有问题都从这里开始。
  2. 升级过大版本就检查断掉的 symlink:sudo find /etc/crowdsec -xtype l -delete,再 cscli hub update + collections install --force
  3. cscli explain 验解析,别只看 cscli metrics 的比例,那是累计值,reload 不会清,要 restart
  4. SSH 来源只留 journalctl,同时收 auth.log 会让每次失败被算两次。
  5. 第一天就设区网 whitelist parser,不然关掉 SSH 密码那类操作很容易把自己锁在内网外。
  6. 公网出口 IP 进 allowlist,区网白名单救不了你人在外面的情况。
  7. 注意 nginx-req-limit-exceeded:你自己的 429 会喂给封锁器,狂重整就会封到自己。
  8. 判断是不是被自己挡:i/o timeout 而非 refused、手机正常电脑不通、连静态档都载不到。
  9. 准备一条不依赖 SSH 的解封路径,而且它要是「主动连出去」的架构才有用。
  10. CrowdSec 预设不通知,profiles.yaml 的 notifications 是注解掉的,想要就自己接。
  11. 告警很吵先治源头再谈静音,挡掉扫描器会让 4xx 自己消失,静音只是遮住仪表。
  12. 旧的那套要停就停干净,别留一个半死不活的服务让你误以为有双重保险。
參考連結