🔑 關鍵洞察
✦ 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
還沒有留言
✨ 成為第一個留言的人吧