🔑 关键洞察
✦ AI·GEN这篇记录了作者把伺服器防护从 fail2ban 换成 CrowdSec 的完整过程,并整理成一份可照做的教学。起因不是被入侵而是被吵:扫描器整天敲 /wp-login.php、/.env,把监控通知洗版。当时 fail2ban 已封过 614 个 IP,但作者发现对手一直在换 IP,封 IP 这条路赢不了——后来的数据也证实了这点,攻击来源前七名几乎全是 Azure、DigitalOcean、阿里云这类云端机房,开一台新 VM 比封一个 IP 便宜得多。文中给出这台机器六天内的真实数字:1619 次攻击侦测、防火墙同时挡着 25.22k 个 IP,其中社群黑名单提供的数量是自己抓到的三千倍。架设踩到的三个坑是同一条因果链(apt 从 ESM 拿到上古版本 → 跨版本升级把 hub 搬家 → symlink 全断导致解析率只剩 9%),并附上诊断用的 cscli explain 与「metrics 是累计值」这类容易误判的细节。后半是最 relatable 的部分:作者三次把自己封在门外,其中最精彩的一次是自己设的 nginx 限流回了 429,而 CrowdSec 看到 429 就把他封了,两个防护措施串起来咬了自己一口。最后是用 Discord bot 解封的架构——容器只能丢请求档、主机 root watcher 才执行 cscli,而它之所以有用,是因为 bot 是主动连出去的,你的 IP 被挡不影响它收指令。
起因不是被入侵,是吵。
我的 nginx 接了 ,异常会发 Discord。它长这样,连当下的 Top IP 和 Top Path 都一起贴给你:
诊断资讯给得很足,问题是它每天要响几百上千次,而且绝大多数不是我的服务有事,是扫描器在敲 /wp-login.php、/.env、各种 .php 端点造成的 4xx。我的站根本没有 PHP,但它们不知道,也不在乎,就是一直敲。
(上面那张其实是少数「真的有事」的一次,/api/spotify/audio-features 五分钟被打了 9968 次,那是我自己的东西在回圈。IP 我遮掉了,那几条是我自己的出口。)
当时机器上已经有 fail2ban,而且数字很漂亮:
封过 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,你直接拿来用。
| fail2ban | CrowdSec | |
|---|---|---|
| 判断依据 | 只有本机 log | 本机 log 加上全球社群黑名单 |
| 遇到没见过的 IP | 从零累积,先让它扫一轮 | 别人标记过就直接挡 |
| 执行封锁 | 自己改 iptables | 交给 bouncer(可换 nftables / nginx / Cloudflare) |
| 规则来源 | 自己写 regex | Hub 上的社群规则,含大量 CVE 场景 |
第三栏那个「交给 bouncer」是架构差异:CrowdSec 把侦测和执行拆开。引擎只负责判断谁该挡,真正动防火墙的是另一支 bouncer。这个切分后面会变得很重要。
还有一点对我是决定性的:它是自架的。当时另一个选项是把站挂到 Cloudflare 后面让它挡,但那等于把流量交给别人的 proxy。CrowdSec 在本机读 log、在本机的防火墙执行,不动我现有的架构。
NOTE
社群黑名单预设就开
安装时会自动 Registering to CAPI(Central API),那份共享黑名单立刻就在帮你挡,不用额外设定。cscli console enroll 是选配,给你仪表板和更大的订阅清单。
三个坑,全来自同一个根因#
装 CrowdSec 只要两行:
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然后我就卡了一整晚,而且三个坑是同一颗骨牌推倒的。
官方脚本确实把 packagecloud 的 repo 加进去了,但 apt install crowdsec 实际下载的来源是 esm.ubuntu.com。里有一份被冻结的 1.4.6,而 packagecloud 上是 1.7.x。
装完当下不会有任何错误讯息,你要跑 cscli version 才会发现版本不对。
平心而论这个设计我到现在还是觉得很烦。ESM 的立意是替第三方套件延长安全维护,但它把版本冻在某个时间点、又排在你刚加的官方 repo 前面,结果就是「你以为自己装了最新版」。经过这次我认真想过:如果只是要一台干净的伺服器,Debian 大概比 Ubuntu LTS 少很多这种惊喜。
发现版本旧,自然是升上去。而 1.4.6 到 1.7.8 之间,hub 资料的位置换了:
旧版 /var/lib/crowdsec/hub/...
新版 /etc/crowdsec/hub/...旧版留下的一堆 symlink 全部指向已经不存在的路径,变成断掉的连结。安装时那串 Ignoring file ... no such file or directory 就是在讲这件事,但它混在几十行输出里,很容易当成杂讯滑过去。
断掉的 symlink 意味着 nginx-logs 这类 parser 根本没被载入。症状是 cscli metrics 看起来有在读 log,但解析率只有 9%:
Source Lines read Lines parsed
file:/var/log/nginx/access.log 41 3它读得到档案,却看不懂内容。看不懂就不会触发场景,不触发场景就永远不会封任何人。表面上服务是绿的,实际上完全空转。
修法是把断掉的连结清干净再重装规则集:
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 crowdsecTIP
教训:直接 pin packagecloud 装 1.7.x
这三个坑是一条因果链,源头只有一个:第一步装到了 ESM 的旧版。装完先跑 cscli version 对一下版本号,可以省掉后面全部。
两个很容易误判的诊断细节#
cscli metrics 的数字是累计的。我一度以为解析率修好了却没有起色,其实新解析的行被之前那堆失败的行稀释着。reload 不会清空计数,要 restart 才会。想看「修完之后」的真实比例,就得重启后重新累积。
用 cscli explain 一翻两瞪眼。它会把一行 log 实际走过的 parser 链印出来,哪一关失败一目了然:
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:
source: journalctl
journalctl_filter:
- "_SYSTEMD_UNIT=ssh.service"
labels:
type: syslog六天,1619 次#
这是这台机器最近六天的实际数字(cscli alerts list 全量统计):
拆开来看攻击类型:
| 场景 | 次数 | 在打什么 |
|---|---|---|
ssh-time-based-bf | 978 | SSH 慢速暴力破解 |
http-probing | 144 | 乱敲端点探路 |
ssh-time-based-bf_user-enum | 121 | 猜使用者名称 |
http-sensitive-files | 44 | 找 .env、.git 这类档案 |
http-bad-user-agent | 39 | 已知的扫描器 UA |
http-wordpress-scan | 36 | 找 WordPress 漏洞 |
http-admin-interface-probing | 36 | 找后台登入页 |
http-cve-2021-41773 | 33 | Apache 路径穿越 |
剩下的长尾是一整排 CVE 探测:CVE-2017-9841(PHPUnit RCE)、http-cve-2021-42013、CVE-2022-41082(Exchange)、thinkphp-cve-2018-20062、netgear_rce、fortinet-cve-2018-13379、jira_cve-2021-26086。这些我一个都没装,但它们照样每天来敲。
攻击不是来自「坏人的电脑」#
来源 AS 的分布才是最有意思的一栏:
| 来源 | 次数 |
|---|---|
| MICROSOFT-CORP-MSN-AS-BLOCK | 199 |
| Techoff Srv Limited | 191 |
| DIGITALOCEAN-ASN | 171 |
| Hangzhou Alibaba Advertising | 165 |
| Hetzner Online GmbH | 161 |
| WEB MASTER COLOMBIA SAS | 153 |
| Sai gon Postel Corporation | 137 |
前七名几乎全是云端机房:Azure、DigitalOcean、阿里云、Hetzner。扫描器跑在按小时计费的 VM 上,封掉一台,再开一台就是几秒钟的事,IP 还不一样。
这正好从数据面回答了我最初那个问题:封 IP 这条路,对手的成本比你低得多。而这也是社群黑名单的价值所在,那台新开的 VM 在打到你之前,通常已经打过别人了。
IMPORTANT
社群黑名单挡掉的量,是我自己抓到的三千倍
cscli metrics 的 bouncer 区块把两个来源分得很清楚:
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-bf 场景判定它是暴力破解,bouncer 把那个内网 IP 整个 drop 掉。
症状很怪:内网完全连不进去,但从外面绕进来反而正常,因为外网走的是另一条来源 IP。
我在测一个档案上传功能,那个 handler 有 bug,一上传就把整台机器卡住。机器一卡,我的电脑就开始疯狂重连 SSH 和 HTTPS。CrowdSec 看到的就是「有个 IP 在短时间内狂敲」,判定为攻击,封。
当下的画面是:手机的 SSH 进得去,电脑完全连不上,网站也全开不了。因为手机走 4G,是不同的 IP。
同一天稍晚又中一次,而这次的机制值得单独拿出来讲。看 cscli decisions list:
Ip:<我的出口IP> crowdsecurity/nginx-req-limit-exceeded ban 6 eventsnginx-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 永远不会被封:
# /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:
sudo cscli allowlists create home-dev -d "我的开发机 IP"
sudo cscli allowlists add home-dev <你的出口IP>
sudo cscli allowlists inspect home-devWARNING
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。所以「被自己挡」这件事从来不会主动告诉你,你只会发现连不上,然后开始猜。
# /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 的关键几行:
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])三个设计上的细节:
这个字串会被丢进 subprocess 执行。虽然用的是 list 形式(不经过 shell),但一个没验证的栏位迟早会出事。ipaddress.ip_address() 一行就挡掉了。
如果只是删掉封锁决定,那个触发封锁的行为往往还在继续(例如你还在狂重整),几秒后就会被重新封回去。所以解锁时顺手把 IP 加进一个叫 parole 的 allowlist,但只给 4 小时,不永久放行。
cog 检查 interaction.user.id == OWNER_ID,而且 OWNER_ID 没设的时候是拒绝所有人,不是放行所有人。指令回复一律 ephemeral,封锁清单不会留在频道里。
做完之后,/crowdsec_list 直接把目前封锁清单摊在手机上,每笔都有 IP、触发的场景、还剩多久:
认出自己那笔之后,/crowdsec_unblock 填 IP 就解开,回复会告诉你 parole 给了多久:
清单上那些 IP 顺带说明了一件事:http-cve-2021-41773、http-wordpress-scan、http-sensitive-files,全是前面统计里的那几种,而且 AS 查下去又是 Google Cloud 和 Azure。连被我拿来当示范的这一批,都还是跑在云端机房上的扫描器。
最后那个让整件事成立的关键,其实很简单:bot 是主动连出去 Discord 的。你的 IP 被防火墙 DROP,挡的是「连进来」的方向,完全不影响 bot 那条既有的对外连线。所以你被锁在门外时,它照样收得到你的指令。
一个架在自己机器上的 web 管理面板做不到这件事,因为你连得到它才能用它,而你正好连不到。用手机 SSH 则是绕过了封锁(行动网路是另一个 IP),但你还是得在手机上打完整的指令。
回到最初:Discord 安静了吗#
安静了,但原因跟我当初打算做的事不一样。
当时除了装 CrowdSec,我还准备了一份 netdata 的告警静音档,想把 4xx 那组 route 到 silent,只留 5xx 和「变慢」。写好了、指令也备妥了。
结果刚刚翻了一下:
$ 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 行来算:
扫描器在打到 nginx 之前就被 bouncer 在防火墙层 drop 掉了,log 里根本不会留下那笔 404,4xx 比例自然压在门槛底下,告警就不会触发。
这个结果比我原本要做的好得多。静音是把仪表遮起来,而这是真的没事发生。如果当初我先部署了静音档,现在 netdata 就算真的遇到扫描潮也不会叫,我会失去一个有效的讯号;而现在它一叫,通常就真的有事,像文章开头那张,那次是我自己的 Spotify API 在回圈。
TIP
先治源头,再考虑静音 告警很吵的时候,第一直觉常常是调门槛或静音。但如果吵的原因是「真的有很多坏请求进来」,那消除来源会连告警一起解决,而且保留了告警的判别力。静音应该留给「这个告警本身就不该存在」的情况。
fail2ban 后来怎么了#
老实说,我没有正式退役它。CrowdSec 上线之后就没再管,直到后来才发现它其实停在一个尴尬的状态:服务没在跑,设定档还留着。
$ systemctl is-active fail2ban
inactive
$ systemctl is-enabled fail2ban
disabled它是怎么停掉的也有点好笑:当初为了加区网白名单,我在 jail.d/ 丢了一个 drop-in,那个档让它开机解析失败,从此就没起来过。而我没发现,因为那段期间 CrowdSec 一直在挡,没有任何症状。
这里的教训不是「fail2ban 不好」,而是:两套功能重叠的防护并存,最危险的不是冲突,是你以为两边都在守。如果那时停掉的是 CrowdSec,我大概也要过很久才会知道。要换就换干净,并且确认新的那套真的在动。
一页式清单#
CrowdSec 自架检查清单
- 装完先
cscli version。从 ESM 拿到旧版不会有错误讯息,但后面所有问题都从这里开始。 - 升级过大版本就检查断掉的 symlink:
sudo find /etc/crowdsec -xtype l -delete,再cscli hub update+collections install --force。 - 用
cscli explain验解析,别只看cscli metrics的比例,那是累计值,reload不会清,要restart。 - SSH 来源只留 journalctl,同时收 auth.log 会让每次失败被算两次。
- 第一天就设区网 whitelist parser,不然关掉 SSH 密码那类操作很容易把自己锁在内网外。
- 公网出口 IP 进 allowlist,区网白名单救不了你人在外面的情况。
- 注意
nginx-req-limit-exceeded:你自己的 429 会喂给封锁器,狂重整就会封到自己。 - 判断是不是被自己挡:
i/o timeout而非 refused、手机正常电脑不通、连静态档都载不到。 - 准备一条不依赖 SSH 的解封路径,而且它要是「主动连出去」的架构才有用。
- CrowdSec 预设不通知,
profiles.yaml的 notifications 是注解掉的,想要就自己接。 - 告警很吵先治源头再谈静音,挡掉扫描器会让 4xx 自己消失,静音只是遮住仪表。
- 旧的那套要停就停干净,别留一个半死不活的服务让你误以为有双重保险。
- CrowdSec —— 官方文件docs.crowdsec.net
- CrowdSec Hub —— 场景、parser、collection 清单app.crowdsec.net/hub
- CrowdSec —— firewall bouncer(nftables / iptables)GitHub
- fail2ban —— 官方专案GitHub
- netdata —— 健康告警设定learn.netdata.cloud
- Censys —— 网际网路普查扫描器的说明与退出方式about.censys.io
还没有留言
✨ 成为第一个留言的人吧