iT邦幫忙

2026 iThome 鐵人賽

DAY 11
0
自我挑戰組

一鍵完成六套開源防禦系統整合系列 第 11 篇

nftables 在防禦節點上的角色:一張表、三個集合、四種責任

  • 分享至 

  • xImage
  •  

前面七篇裡 nftables 一直是配角:第 2 篇說它是「kernel 防火牆、只看 IP」,第 6 篇說封鎖決策會落到它身上,第 7 篇說它的 allowlist 是自鎖保險。本篇把它拉到台前,用一張真實的規則表說明它在主機 B 上到底管什麼、不管什麼、跟 docker 與 WAF 怎麼分工,最後附四個日常檢查指令的實際畫面。看完應該能回答:「這個 IP 現在到底有沒有被擋、還剩多久、是誰擋的」。

先講結論。nftables 在主機 B 上是最外層的殼:所有從實體網卡進來的封包,在 WAF、Suricata、docker 看到之前先經過它。它只做四件事——自鎖保險(管理來源與平台主機永遠放行)、封鎖落地(案件決策寫進帶 TTL 的 set)、ingest 面來源管制(能注入事件的埠只讓平台主機與管理來源連)、管理面來源管制(Grafana、EveBox 等只讓管理來源連)。它不看 HTTP 內容、不做速率限制、不讀 EDL,這三件事各有別人負責。整套規則集中在一張叫 inet secstack 的表裡,由 nftables.sh 從 .env 產生,不要手改。

一、它站在鏈上哪裡

第 1 篇的封包路徑裡,主機 B 的第一站就是 nftables。但「第一站」要再拆細:Linux 的 netfilter 有五個 hook,一個封包依它的目的地只會經過其中幾個,而 nftables 的每條 chain 掛在其中一個 hook 上、帶一個 priority 決定先後。主機 B 上與我們有關的只有兩個 hook:

hook 什麼封包會經過 主機 B 上誰掛在這裡(依 priority 由小到大=先到後)
input 目的地是主機自己的封包:SSH(22)、od-bridge(8500,host network)、本機服務 ingest_guard_input(-150)→ ssh_guard_input(-150,選用)→ input(-100,自鎖保險與封鎖)→ docker 的 ip filter(0)
forward 要轉送給容器的封包:WAF(8080/8082)、Vector 注入口(8688)、Grafana(3000)、EveBox(5636)、Vector API(8686)、Portainer(9443)。docker 發布的埠先在 prerouting 做 DNAT,之後進 forward ingest_guard_forward(-150)→ mgmt_guard_forward(-150)→ docker 的 ip filter(0)

封鎖 set 只掛在 input,不掛在 forward。這是刻意的。對外服務走 Cloudflare Tunnel 時,訪客封包從 cloudflared 容器經 docker 內部網段送到 WAF,根本不從實體網卡進來,nftables 看到的來源 IP 是 docker 網段而不是訪客。這種部署下攔截訪客的責任在 WAF(讀 Cf-Connecting-Ip)與 EDL(邊界設備),不在 nftables;nftables 的 blocklist 擋的是直接打到主機 B 的來源——掃描、暴力嘗試 SSH、直連 8500 之類。第 6 篇說「為什麼不建議用 Linux 軟體防火牆去讀 EDL」,根源就在這裡:它站的位置看不到該看的來源。

https://ithelp.ithome.com.tw/upload/images/20260925/20184261r9rQhtyLHe.png

二、一張表裡有什麼

整套規則只有一張表 inet secstack(inet 表示同時涵蓋 IPv4 與 IPv6)。表裡三個 set、四到五條 chain,全部由 nftables.sh 依 .env 產生:

物件 型態 內容 誰寫入
set allowlist ipv4_addr,interval ADMIN_IPS + 從 BEAK_BASE_URL 解析出的平台主機 IP。命中者在 input chain 一律 accept,排在 blocklist 前面 安裝腳本,固定
set blocklist ipv4_addr,interval + timeout 案件決策落地的封鎖目標,可以是單一 IP 或 CIDR,每個元素自帶剩餘時間,到期由 kernel 自動移除 od-bridge,動態
set blocklist6 ipv6_addr,同上 同上,IPv6 版 od-bridge,動態
chain input input,-100 三條規則:allowlist accept、blocklist drop、blocklist6 drop 安裝腳本
chain ingest_guard_forward forward,-150 8080/8082/8688 只放 ADMIN_IPS 與平台主機,其餘 drop 安裝腳本
chain ingest_guard_input input,-150 8500(od-bridge)同上 安裝腳本
chain mgmt_guard_forward forward,-150 3000/5636/8686/9443 只放 ADMIN_IPS 與平台主機,其餘 drop,兩條都帶 counter 安裝腳本
chain ssh_guard_input input,-150 只在 SSH_GUARD=1 時產生:22 埠只放 ADMIN_IPS,其餘限速記 log 後 drop 安裝腳本,選用

注意兩個「為什麼要有它」:

  • 為什麼 ingest 面要管來源。8080/8082 是 WAF、8688 是 Vector 的事件注入口、8500 是 od-bridge。能直連這三種埠的人,要嘛能偽造 Cf-Connecting-Ip 讓 WAF 把攻擊歸因到別人頭上,要嘛能直接注入假事件讓平台建案。第 5 篇講的信任邊界,在主機 B 上就是靠這兩條 guard chain 守住的。
  • 為什麼管理面要管來源。EveBox(5636)與 Vector API(8686)出廠沒有認證,Portainer(9443)掛著 docker.sock。它們對內網開放等於整台主機對內網開放,所以只讓 ADMIN_IPS 進。

三、四種責任,各自的界線

責任 做的事 不做的事(由誰做)
自鎖保險 allowlist 命中一律 accept,且排在所有 drop 之前。平台主機與管理來源不管流程下了什麼決策都連得進來 不影響 WAF 打分數(WAF 對所有請求照樣評分)、不影響 EDL 與 CrowdSec(它們有各自的免封清單,見第 7 篇)
封鎖落地 od-bridge 收到 block 決策後 nft add element ... timeout Ns;unblock 就 delete element,元素已不在時視為成功(冪等) 不接受 allow 決策(第 7 篇:主機 B 只有一個封鎖來源,放行等於解封)、不做速率型封鎖(CrowdSec 的事)、不擋 HTTP 層攻擊(WAF 的事)
ingest 面來源管制 能注入事件或偽造歸因的埠只放平台主機與管理來源 不驗簽章(od-bridge 與平台之間的 HMAC 另外做)、不限制 docker 內部網段(cloudflared → WAF、vector → od-bridge 不受影響)
管理面來源管制 無認證的管理介面只放管理來源 不替這些介面加認證(Grafana 自己有登入,EveBox 與 Vector API 沒有,所以來源管制是它們唯一的門)

四、與 docker 共存:為什麼不 flush ruleset

大多數 nftables 教學第一行是 flush ruleset。主機 B 上絕對不能這樣做:docker 自己維護 ip nat、ip filter、ip6 nat、ip6 filter、ip raw 五張表,所有容器的 port forwarding 都靠它們。flush 一下,WAF、Grafana、Vector 全部從外面連不到,而 docker 不會自動補回。

nftables.sh 的做法是只重建自己那一張表:先 table inet secstack(不存在就建、存在就無事)再 delete table inet secstack,然後整張重新宣告。docker 的五張表從頭到尾沒被碰。這也是為什麼 nft list tables 會看到六張表並存——那是正常狀態,不是誰污染了誰。

重建會清空 blocklist。刪表再建表,set 裡的元素當然跟著消失。腳本在重建前用 nft -j list set 把 blocklist 與 blocklist6 連同剩餘 timeout 存下來,重建後逐筆補回。所以改 .env 重跑 nftables.sh 不會把正在生效的封鎖弄丟。但如果你手動 nft flush table inet secstack 或 delete table,就沒有人替你補了——這種情況下 od-bridge 下一輪拉到的決策也不會重放已經落地過的封鎖,直到 TTL 到期前那個 IP 都處於「平台記錄是已封鎖、主機 B 實際沒擋」的狀態。

priority 的取值也是為了共存:docker 的 filter chain 掛在 priority 0,我們的 guard chain 用 -150(nft 顯示成 mangle,那只是 -150 的別名)、自鎖與封鎖用 -100,全部排在 docker 之前。這樣「來源不在名單就 drop」的判定發生在 docker 決定要不要轉送給容器之前,drop 掉的封包 docker 根本看不到。

五、od-bridge 怎麼寫進去

od-bridge 每 5 秒向平台拉一次決策,其中執行點含 nftables 的走 enforcers/nftables.py。它做的事非常薄——只有 add element 與 delete element 兩個動作,表與 set 的存在假設安裝時已經建好:

# block,帶 TTL(秒);CIDR 也是同一句
nft add element inet secstack blocklist '{ 203.0.113.45 timeout 600s }'

# unblock;元素已不在(TTL 到期或從未封鎖)視為成功
nft delete element inet secstack blocklist '{ 203.0.113.45 }'

三件值得知道的:

  • TTL 由 kernel 管,不是 od-bridge 管。元素加進去之後 od-bridge 就不再過問,到期由 kernel 自己移除。所以 od-bridge 重啟、甚至整個停掉,已經落地的封鎖照樣倒數、照樣到期消失。
  • 同一個 IP 再 block 一次「不會」刷新 TTL。對已存在的元素再 add element,nft 回傳成功(rc 0)但沿用原本的倒數,新給的 timeout 被丟掉(實測:先 600s 再 60s,expires 仍是 9m59s)。所以同一目標第二次下封鎖決策時,od-bridge 回報「已落地」是真的,但剩餘時間是第一次那個;要延長得先 unblock 再 block。第六節畫面 2 可以自己驗。
  • IPv6 自動分流。od-bridge 依目標值判斷是 v4 還是 v6,寫進對應的 set,決策本身不需要標。

od-bridge 另外提供 GET /state/nft(8500 埠,僅本機與 ADMIN_IPS 可連),把三個 set 的現況轉成 JSON。它讀的是 nft -j list set 的即時輸出,不是自己的快取,所以拿來核對「平台認為封了」與「主機 B 實際擋了」是否一致很方便,第六節第 3 個畫面就是這個用法。

六、四個日常檢查指令的實際畫面

以下輸出取自一台實際運作中的防禦節點(nftables 1.0.9,Ubuntu 24.04),主機名稱與 IP 已換成本系列文章的示範環境:主機 B waf-vm、平台主機 192.168.0.111、管理來源 192.168.0.50。示範用的封鎖目標取自 RFC 5737 文件保留網段(203.0.113.0/24、198.51.100.0/24),這兩段在 Internet 上不會路由,可以放心拿來練習。所有指令都要 root 或 sudo,nft 對一般使用者連 list 都不給看。

畫面 1 整張表健檢:nft list table inet secstack

安裝後、改完 .env 重跑之後、或懷疑規則被動過的時候,第一件事就是把整張表印出來。要看的是:表在不在、三個 set 在不在、allowlist 的元素是不是你預期的那幾個、四條 chain 的 hook 與 priority 對不對。

admin@waf-vm:~$ sudo nft list table inet secstack
table inet secstack {
	set allowlist {
		type ipv4_addr
		flags interval
		elements = { 192.168.0.50, 192.168.0.111 }
	}

	set blocklist {
		type ipv4_addr
		flags interval,timeout
	}

	set blocklist6 {
		type ipv6_addr
		flags interval,timeout
	}

	chain input {
		type filter hook input priority -100; policy accept;
		ip saddr @allowlist accept
		ip saddr @blocklist drop
		ip6 saddr @blocklist6 drop
	}

	chain ingest_guard_forward {
		type filter hook forward priority mangle; policy accept;
		iifname != "ens18" accept
		tcp dport { 8080, 8082, 8688 } ip saddr { 192.168.0.50, 192.168.0.111 } accept
		tcp dport { 8080, 8082, 8688 } drop
	}

	chain ingest_guard_input {
		type filter hook input priority mangle; policy accept;
		iifname != "ens18" accept
		tcp dport 8500 ip saddr { 192.168.0.50, 192.168.0.111 } accept
		tcp dport 8500 drop
	}

	chain mgmt_guard_forward {
		type filter hook forward priority mangle; policy accept;
		iifname != "ens18" accept
		tcp dport { 3000, 5636, 8686, 9443 } ip saddr { 192.168.0.50, 192.168.0.111 } counter packets 0 bytes 0 accept
		tcp dport { 3000, 5636, 8686, 9443 } counter packets 0 bytes 0 drop
	}
}

幾個讀法:

  • priority mangle 就是 -150,nft 列印時會把幾個常用數值換成名字,.conf 裡寫的仍是數字。
  • blocklist 底下沒有 elements 行=目前沒有任何生效中的封鎖,這在剛安裝或案件很少的環境是正常的。
  • policy accept 是每條 chain 的預設,也就是「規則沒命中就放行」。這張表的設計是只擋明確要擋的,不是白名單式的全擋;主機整體的預設拒絕若有需要,是另外一層的事。
  • 沒看到 ssh_guard_input 表示 SSH_GUARD=0,22 埠沒有被限制來源。
  • mgmt_guard_forward 兩條規則的 counter 都是 0,表示自上次重建以來沒有人從實體網卡碰過管理埠——不論放行或擋下。這兩個數字是判斷「有沒有人在敲管理介面」最便宜的指標。

畫面 2 現在封了誰、還剩多久:nft list set 與 nft get element

案件簽核完、od-bridge 說已落地,最直接的驗證就是看 set。下面先手動放兩個示範元素(一個單一 IP 十分鐘、一個 /24 一小時)模擬 od-bridge 的寫入,再列出來:

admin@waf-vm:~$ sudo nft add element inet secstack blocklist '{ 203.0.113.45 timeout 600s }'
admin@waf-vm:~$ sudo nft add element inet secstack blocklist '{ 198.51.100.0/24 timeout 3600s }'
admin@waf-vm:~$ sudo nft list set inet secstack blocklist
table inet secstack {
	set blocklist {
		type ipv4_addr
		flags interval,timeout
		elements = { 198.51.100.0/24 timeout 1h expires 59m57s949ms,
			     203.0.113.45 timeout 10m expires 9m57s932ms }
	}
}

每個元素兩個時間:timeout 是加入時給的總長度,expires 是此刻剩下多久。第二個才是你要看的。要查某一個 IP 是否命中,不必肉眼掃整份清單,用 get element:

admin@waf-vm:~$ sudo nft get element inet secstack blocklist '{ 203.0.113.45 }'
table inet secstack {
	set blocklist {
		type ipv4_addr
		flags interval,timeout
		elements = { 203.0.113.45 timeout 10m expires 9m57s877ms }
	}
}
admin@waf-vm:~$ echo $?
0

admin@waf-vm:~$ sudo nft get element inet secstack blocklist '{ 203.0.113.99 }'
Error: Could not process rule: No such file or directory
get element inet secstack blocklist { 203.0.113.99 }
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
admin@waf-vm:~$ echo $?
1

get element 對 interval set 會做區間比對:查 198.51.100.77 會命中 198.51.100.0/24 那一筆。所以「這個 IP 有沒有被擋」用它問就對了,不要拿 list set 的輸出去 grep 單一 IP,CIDR 元素會漏掉。回傳碼 0/1 也適合寫進腳本。

順帶驗證第五節那條:對已存在的元素再 add 一次、給更短的 timeout,回傳成功但倒數不變:

admin@waf-vm:~$ sudo nft add element inet secstack blocklist '{ 203.0.113.45 timeout 60s }'
admin@waf-vm:~$ echo $?
0
admin@waf-vm:~$ sudo nft list set inet secstack blocklist | grep 203.0.113.45
			     203.0.113.45 timeout 10m expires 9m41s102ms }

手動解除與 od-bridge 的 unblock 是同一句。第二次刪同一個元素會報錯,這就是 od-bridge 把「element does not exist」視為成功的原因——TTL 已到期或從未封鎖,結果狀態都是「不在名單裡」,沒有理由回報失敗:

admin@waf-vm:~$ sudo nft delete element inet secstack blocklist '{ 203.0.113.45 }'
admin@waf-vm:~$ sudo nft delete element inet secstack blocklist '{ 203.0.113.45 }'
Error: element does not exist
delete element inet secstack blocklist { 203.0.113.45 }
                                         ^^^^^^^^^^^^

畫面 3 核對 od-bridge 的視角:/state/nft 對 nft -j

平台的處置中心說某個 IP「已落地」,但那是 od-bridge 回報的結果;要確認 kernel 真的擋了,就拿 od-bridge 的狀態端點與 nft 自己的 JSON 輸出對照。兩邊讀的是同一份 kernel 資料,只是格式不同,所以數字應該對得上(expires 會差幾秒,那是兩次查詢之間的時間差):

admin@waf-vm:~$ curl -s http://127.0.0.1:8500/state/nft | python3 -m json.tool
{
    "blocklist": {
        "198.51.100.0/24": 3597,
        "203.0.113.45": 597
    },
    "blocklist6": {},
    "allowlist": [
        "192.168.0.111",
        "192.168.0.50"
    ]
}

admin@waf-vm:~$ sudo nft -j list set inet secstack blocklist | python3 -m json.tool
{
    "nftables": [
        { "metainfo": { "version": "1.0.9", "release_name": "Old Doc Yak #3", "json_schema_version": 1 } },
        {
            "set": {
                "family": "inet", "name": "blocklist", "table": "secstack",
                "type": "ipv4_addr", "handle": 6, "flags": [ "interval", "timeout" ],
                "elem": [
                    { "elem": { "val": { "prefix": { "addr": "198.51.100.0", "len": 24 } }, "timeout": 3600, "expires": 3597 } },
                    { "elem": { "val": "203.0.113.45", "timeout": 600, "expires": 597 } }
                ]
            }
        }
    ]
}

兩種情況下這個對照特別有用:

  • 平台顯示已封、主機 B 上沒有:多半是第四節警告的那種——有人重建過表而沒補回元素,或 TTL 已到期但平台的顯示還沒更新。看 /state/nft 沒有就是真的沒有。
  • /state/nft 回錯誤或連不上:od-bridge 沒在跑,或它沒有執行 nft 的權限。這時 kernel 裡的既有封鎖仍在倒數,但新決策落不了地。

畫面 4 改設定前的乾跑與共存確認:nft -c -f 與 nft list tables

改了 .env 的 ADMIN_IPS 要重跑 nftables.sh 之前,或手上有一份改過的 .conf 想套用之前,先用 -c(check)讓 nft 只解析不套用。語法錯、set 型態不合、chain 重複宣告都會在這一步被抓出來,而 kernel 裡的規則一個位元都不會動:

admin@waf-vm:~$ sudo nft -c -f /etc/nftables.conf
admin@waf-vm:~$ echo $?
0
admin@waf-vm:~$ sudo head -8 /etc/nftables.conf
#!/usr/sbin/nft -f
#
# 防禦節點主機防火牆(由 Integrated-WAF/nftables.sh 產生,勿手改;改 .env 後重跑)
#
# 不使用 flush ruleset:docker 的 ip nat / ip filter 由 docker 管理。
# 只以 delete + create 重建 inet secstack 一張表。

table inet secstack
admin@waf-vm:~$ ls -la /etc/nftables.conf*
-rwxr-xr-x 1 root root 2448 Sep  1 00:53 /etc/nftables.conf
-rwxr-xr-x 1 root root  243 Aug  5  2025 /etc/nftables.conf.bak.20260901-003815
-rwxr-xr-x 1 root root 2448 Sep  1 00:52 /etc/nftables.conf.bak.20260901-005339

-c 沒有輸出就是通過。nftables.sh 每次重寫 .conf 之前都會留一份帶時間戳的備份,所以「改壞了想退回上一版」只要 nft -f 那個 .bak 檔。最舊那份 243 位元組的是 Ubuntu 套件出廠的空範本,可以看出這台主機是什麼時候第一次被安裝腳本接管的。

最後確認 docker 的表還在、沒被誤刪,以及開機會自動還原:

admin@waf-vm:~$ sudo nft list tables
table inet secstack
table ip nat
table ip filter
table ip6 nat
table ip6 filter
table ip raw
admin@waf-vm:~$ systemctl is-enabled nftables; systemctl is-active nftables
enabled
active

六張表並存是正常狀態(第四節)。nftables.service 開機時會執行 /etc/nftables.conf,把 inet secstack 連同 allowlist 還原;blocklist 不會還原,因為 .conf 裡的 blocklist 是空的——重開機等於所有生效中的封鎖歸零。這是刻意的取捨:TTL 型封鎖本來就是暫時措施,長期封鎖該由 EDL 交給邊界設備(第 6 篇)。

七、常見誤解

誤解 實際
nftables 擋掉的就是 WAF 看到的攻擊者 只有直連主機 B 實體網卡的來源會被它擋。走 Cloudflare Tunnel 的訪客不經過實體網卡,nftables 對他們完全不可見;那條路上的攔截靠 WAF 與 EDL
把 IP 加進 allowlist 就能免於 WAF 檢查 allowlist 只影響 input chain 的 accept,WAF 對所有 HTTP 請求照常打分數。要免檢查的是 WAF 自己的設定,不是 nftables
手改 /etc/nftables.conf 就能改規則 下次跑 nftables.sh(安裝腳本的 --reconfigure 會跑)整份會被覆寫。要改的是 .env;需要腳本沒提供的規則就另建一張表,不要動 inet secstack
blocklist 空的表示 od-bridge 沒在動 可能只是沒有生效中的封鎖:TTL 到期會自動消失、重開機會歸零、案件流程可能只選了 EDL 執行點。看 /state/nft 能不能回應才是 od-bridge 活著的證據
可以用 flush ruleset 重來 會連 docker 的五張表一起清掉,所有容器的 port forwarding 立刻失效,而 docker 不會自動補回。只能重建 inet secstack 那一張
nft list set ... grep IP 能判斷有沒有被擋
對同一個 IP 再下一次更長的封鎖就能延長 已存在的元素再 add 會成功但倒數不變(第五節實測)。要延長先 unblock 再 block,或等它到期後再封
priority 顯示 mangle 表示規則跑到 mangle 表去了 那只是 -150 這個數值的別名,nft 列印時自動替換。這張表沒有任何 mangle 動作

上一篇
白名單不是一份 IP 清單:四種語意、一個不對稱
下一篇
WAF 元件篇:nginx + ModSecurity v3 + OWASP CRS(一)
系列文
一鍵完成六套開源防禦系統整合 共 18 篇
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言