起因不是被入侵,是

我的 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. 舊的那套要停就停乾淨,別留一個半死不活的服務讓你誤以為有雙重保險。
參考連結