iT邦幫忙

2026 iThome 鐵人賽

DAY 18
0
自我挑戰組

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

驗證清單:怎麼證明每條線在做它該做的事(上)

  • 分享至 

  • xImage
  •  

昨天講到防禦主機的處理流程中的第一個路口,有兩條線可前進,今天先把證明這兩條去向講清楚,後天把下個處理的repo[verification]也簡介,方便沒接觸過的朋友有概念,然後會繼續連講數篇這兩條流程的處理過程

驗證清單的上下兩篇會硬一點,請同學們撐下去

前幾篇講的都是機制:每一站做什麼、為什麼這樣排。這一篇(上+下)只講解一個問題:你怎麼知道它現在真的在做。上一 篇把架構分成線上與線外兩條線,驗證也跟著分成兩組,再加上線外的去程與回程,一共三組、十八個檢查點。每一項都寫成「要證明什麼、打什麼指令、通過長什麼樣」,最後一節是日常只要跑的三行。

先講結論。兩條線之間沒有函式呼叫,所以一條線的證據不能拿來證明另一條線。SQL injection 回 403 只證明線上;平台出現案件只證明線外去程;kernel 裡多一個元素只證明線外回程。三組證據互不代替,少看一組,那一段壞了幾天都不會有人知道(第七節有一個真實的例子)。另一個原則是每項斷言都要有反證:除了「該擋的擋了」,還要看「該放的放了」;除了「該進平台的進了」,還要看「不該進的沒進」。

一、驗證的四個原則

原則 理由 違反時會發生什麼
分線驗,證據不跨線 線上與線外只靠兩個交接點(紀錄檔、封鎖清單)相連,任何一邊都不知道另一邊的死活(第 9 篇第三節)。這是刻意的故障隔離,代價就是一邊壞了另一邊不會報錯 看到 403 就認定「整套都在運作」。實際上線外可能已經停了幾天
每項斷言配一個反證 只有正面測試的檢查,在「全部都擋」或「全部都放」的壞掉狀態下一樣會通過。WAF 把所有請求都回 403,SQL injection 探測照樣是 403 規則引擎設成只偵測、來源管制被改成全開、過濾條件失效,這三種狀態都通過正面測試
交接點兩側都要驗 交接點是「一邊寫、另一邊讀」。寫的一方正常不代表讀的一方有在讀:audit.log 有新行不代表 Vector 讀到了,/edl 有內容不代表有設備在抓 最常見的斷點都在交接點,不在元件內部
看最靠近事實的那一層 容器狀態是 docker 的說法,回應碼才是事實;決策狀態 applied 是 od-bridge 的回報,kernel 裡的元素才是事實;案件數是平台的視角,ClickHouse 才是全量 拿轉述當證據。轉述通常是對的,出事的時候剛好就是它不對的時候

二、檢查點地圖與準備

與昨天那篇的圖不同,node位置佈局都一樣,但線路和標記不同!
https://ithelp.ithome.com.tw/upload/images/20261002/20184261XIYtmXG1uM.png

全篇沿用安裝章節的位址:主機 A(平台)192.168.0.111,主機 B(防禦節點)192.168.0.112,工作機 192.168.0.50 在主機 B 的 ADMIN_IPS 內,被保護網站就是主機 A 上的平台(路徑前綴 /beakplatform)。指令前面標「工作機」的在工作機打,標「主機 B」的在主機 B 打。

兩個準備動作,後面每一節都會用到:

#### 工作機:設定目標,並產生這一輪驗證的記號
B=http://192.168.0.112:8080; P=/beakplatform
M=chk$(date +%s); echo "這一輪的記號:$M"

#### 主機 B:把 od-bridge 的狀態頁壓成一行(狀態頁是 HTML,這個函式只是去掉標籤)
odstat() { curl -s http://127.0.0.1:8500/stats | sed 's/<style>.*<\/style>//; s/<[^>]*>/ /g' | tr -s ' \n' ' ' | grep -o 'uptime.*valid for: [0-9]*s'; echo; }

# 主機 B:從 Vector 的注入口送一筆合成事件。用法:inject <規則代號> <攻擊者 IP>
inject() {
  curl -s -o /dev/null -w "注入 $1 %{http_code}\n" -X POST -H 'Content-Type: application/json' http://127.0.0.1:8688/ --data "$(python3 -c "
import json, uuid, datetime, sys
print(json.dumps({'correlation_id': str(uuid.uuid4()), 'source_system': 'vector', 'event_class': 'web_activity',
  'occurred_at': datetime.datetime.now(datetime.timezone.utc).strftime('%Y-%m-%dT%H:%M:%SZ'), 'severity_id': 3,
  'finding': {'title': '驗證事件 ' + sys.argv[1], 'rule_id': sys.argv[1], 'rule_set': 'manual'},
  'actor': {'ip': sys.argv[2]}, 'target': {'host': 'integrated-waf-test.example', 'url': '/verify'}}))" "$1" "$2")"
}

記號是一個只在這一輪出現的字串,夾在每個探測的網址裡。之後不管在 audit.log 還是 ClickHouse,用它就能只撈出自己剛才打的那幾筆,不必靠「最後幾行」或「最近幾分鐘」去猜。inject 送的事件與安裝包的 --test-event 同一個形狀,差別是規則代號與攻擊者 IP 可以自己指定,第四節的 O6 與第五節的 R1 會用到。odstat 印出來的一行長這樣,後面會反覆看它:

uptime 86412s · last executor iter +2s ago Intake (Vector → bridge → BP) 37 total 37 2xx 0 4xx 0 5xx 0 unreachable Executor (BP /decisions) 4 processed 4 applied 0 partial 0 failed SA token logins: 97 · 429: 0 · GET 401: 0 · current token valid for: 512s

三、線上:六項

線上要證明的是三件事:該擋的來源連不進來、該擋的請求被擋而該放的請求被放、判定之後有照規矩寫下紀錄。

# 要證明的事 性質 通過的樣子
L1 來源管制在擋:名單外的機器連不到 WAF 反證 從不在 ADMIN_IPS 的機器連 8080,連線逾時(curl 回應碼 000、結束碼 28)。不是 403,也不是連線被拒:封包在 nftables 就被丟了,WAF 沒看到它
L2 正常請求被放行 反證 登入頁回 200。這一項防的是「WAF 把什麼都擋掉」
L3 攻擊請求被擋 正證 SQL injection 與 XSS 探測都回 403
L4 那個 403 是 WAF 依規則回的 正證 audit.log 對應那一行 is_interrupted 為真,命中規則裡有 942100(或 941xxx)與 949110。403 也可能是後端自己回的,只有這一行分得出來
L5 覆寫檔有生效 反證 PATCH 回 405。405 是後端回的,代表 PATCH 通過了 WAF。若回 403 且 audit.log 出現 911100,是方法白名單(覆寫檔 900200)沒載入
L6 交接點寫的一方:該寫的寫了,不該寫的沒寫 正證+反證 每個有命中或 4xx/5xx 的探測各一行;用主機名稱打的正常請求(沒有任何規則命中、回 200)不多一行

L1 要借一台名單外的機器打一次:

#### 名單外的機器
curl -s -m 5 -o /dev/null -w '%{http_code}\n' http://192.168.0.112:8080/; echo rc=$?
#### 預期:000 與 rc=28

L2 到 L6 在工作機一次打完。第五個請求帶了一個假的 CF-Connecting-IP,那是替第四節的 O2 準備的;第六個請求用主機名稱而不是 IP 當 Host,是 L6 的反證。

#### 工作機(接續上一節的 B、P、M)
curl -s -o /dev/null -w 'L2 正常     %{http_code}\n' "$B$P/auth/login?m=$M"
curl -s -o /dev/null -w 'L3 SQLi     %{http_code}\n' "$B$P/?m=$M&id=1%27%20OR%201=1--"
curl -s -o /dev/null -w 'L3 XSS      %{http_code}\n' "$B$P/?m=$M&q=%3Cscript%3Ealert(1)%3C/script%3E"
curl -s -o /dev/null -w 'L5 PATCH    %{http_code}\n' -X PATCH "$B$P/auth/login?m=$M"
curl -s -o /dev/null -w 'O2 假標頭   %{http_code}\n' -H 'CF-Connecting-IP: 198.51.100.99' "$B$P/?m=$M&fake=1&id=1%27%20OR%201=1--"
curl -s -o /dev/null -w 'L6 主機名稱 %{http_code}\n' -H 'Host: waf.test' "$B$P/auth/login?m=$M&named=1"

預期依序 200、403、403、405、403、200。接著到主機 B 用記號撈 audit.log:

#### 主機 B(M 填工作機印出的記號)
M=chk1790813364
sudo grep "$M" /opt/integrated-waf/waf/log/audit.log | python3 -c '
import sys, json
for line in sys.stdin:
    t = json.loads(line)["transaction"]
    print(t["request"]["method"], t["request"]["uri"][:64], t["response"]["http_code"], t["client_ip"], t.get("is_interrupted"))
    for m in t.get("messages", []):
        print("    ", m["details"]["ruleId"], m["message"][:60])'
GET /beakplatform/auth/login?m=chk1790813364 200 192.168.0.50 False
     920350 Host header is a numeric IP address
GET /beakplatform/?m=chk1790813364&id=1%27%20OR%201=1-- 403 192.168.0.50 True
     920350 Host header is a numeric IP address
     942100 SQL Injection Attack Detected via libinjection
     949110 Inbound Anomaly Score Exceeded (Total Score: 8)
GET /beakplatform/?m=chk1790813364&q=%3Cscript%3Ealert(1)%3C/script%3E 403 192.168.0.50 True
     920350 Host header is a numeric IP address
     941100 XSS Attack Detected via libinjection
     941110 XSS Filter - Category 1: Script Tag Vector
     941160 NoScript XSS InjectionChecker: HTML Injection
     941390 Javascript method detected
     949110 Inbound Anomaly Score Exceeded (Total Score: 23)
PATCH /beakplatform/auth/login?m=chk1790813364 405 192.168.0.50 False
     920350 Host header is a numeric IP address
GET /beakplatform/?m=chk1790813364&fake=1&id=1%27%20OR%201=1-- 403 192.168.0.50 True
     920350 Host header is a numeric IP address
     942100 SQL Injection Attack Detected via libinjection
     949110 Inbound Anomaly Score Exceeded (Total Score: 8)

六個請求只有五行,這就是 L6:

  • 前五個請求用 IP 當 Host,每一筆都命中 920350(Host 標頭是 IP),有命中就會寫,即使是回 200 的登入頁。
  • 第六個請求用主機名稱,沒有任何規則命中、回 200,所以不在 audit.log 裡。正常流量在這個檔案裡零紀錄(第 2 篇第二節),這一行缺席才是對的。如果它出現了,代表 audit engine 被改成全記,檔案會被正常流量灌爆。
  • client_ip 是工作機。第五筆帶了假標頭,client_ip 仍然是工作機,標頭裡的 198.51.100.99 只是被原樣記在 request.headers 裡。要不要相信它,是線外的事。

--verify 涵蓋哪幾項
安裝包自帶的健康檢查是 sudo bash /opt/integrated-waf/install.sh --verify(--status 只列容器與 blocklist,不做探測)。它涵蓋的範圍要分清楚:

--verify 的列 實際做的事 對應本篇哪一項
容器 docker compose ps 無。行程活著不等於在做事
ClickHouse /ping、od-bridge /health、Vector 設定檔 各打一次健康端點;vector validate 無。元件可回應,不代表有事件流過
WAF / 經 8080 打根路徑 接近 L2:200/3xx/404 表示 WAF 有把請求轉給後端,502 是連不到後端
WAF SQLi 探測 經 8080 打一個 SQL injection L3。這是 --verify 唯一真的驗到行為的一列
平台連線 對平台的執行帳號登入端點送空內容,回 400/401 就算連得到 O4、R2 的前提(網路通),不是它們本身
執行帳號登入、cloudflared 翻容器 log 找「登入成功」「已註冊連線」的訊息 R2 的前提。節點跑了幾天之後這兩列可能顯示「尚未看到」,因為它找的是近期或開頭的 log;這時以 odstat 的 current token valid for 與 docker compose logs --tail 400 cloudflared
主機防火牆 inet secstack 表在不在、blocklist 幾筆 L1、R3 的前提

--verify 不送任何事件,所以它全部通過只能說明線上在擋、元件活著、網路通,對線外兩段沒有說任何話。它是安裝當下的檢查,不是運作中的監測。

對外入口有開時再加一項

有設 Cloudflare Tunnel 的環境,上面的探測要從 Internet 再打一次(手機熱點即可),因為走 tunnel 的請求在主機 B 上經過的是另一條路:

curl -s -o /dev/null -w '%{http_code}\n' "https://<對外主機名稱>/beakplatform/?m=$M&id=1%27%20OR%201=1--"

預期 403;audit.log 那一行的 client_ip 是 cloudflared 的 172.18.0.250,你的公網 IP 在 CF-Connecting-IP 標頭裡。這筆事件的攻擊者是公網位址,會通過第四節的過濾送進平台,所以它同時是不靠合成事件的端對端驗證。

四、線外去程:六項

去程要證明的是:紀錄有被讀走、攻擊者是誰沒有被騙、該送平台的送了而不該送的沒送、平台收到之後建了案。

# 要證明的事 性質 通過的樣子
O1 交接點讀的一方:Vector 讀到了 audit.log 的每一行 正證 ClickHouse 裡含記號的筆數等於 audit.log 裡含記號的行數(上一節是 5)
O2 歸因沒有被標頭騙 反證 帶假標頭那一筆,ClickHouse 的 actor_ip 是工作機,不是 198.51.100.99。工作機不在可信直連來源清單內,誰打的就記誰(第 5 篇第一節)
O3 過濾在作用:內網對內網的事件不進平台 反證 上面五筆打完,odstat 的 total 沒有增加。事件在 ClickHouse 有、在平台沒有,這是預期,不是遺失
O4 簽章與轉送成功 正證 --test-event 之後 od-bridge 的 log 出現 forwarded cid=… src=vector status=200;odstat 的 total 與 2xx 各加一,5xx 與 unreachable 不動
O5 平台建案 正證 示範企業的資安人員在「開放防禦 → 資安案件處置中心」看到標題含 TEST- 的案件
O6 封頂在作用:同一個鍵連送只過得了配額內的筆數 反證 同來源、同規則、同攻擊者連送三筆,odstat 的 total 只加一

O1、O2 用同一句查詢:

#### 主機 B
cd /opt/integrated-waf
CH_PW=$(sudo grep '^CLICKHOUSE_PASSWORD=' .env | cut -d= -f2-)
sudo docker compose exec -T clickhouse clickhouse-client --user secstack --password "$CH_PW" --query \
  "SELECT finding_rule_id, severity_id, IPv6NumToString(actor_ip) AS actor, substring(target_url,1,64) AS url
   FROM secstack.events WHERE source_system='coraza' AND target_url LIKE '%$M%' ORDER BY event_time"
920350	2	::ffff:192.168.0.50	/beakplatform/auth/login?m=chk1790813364
920350	4	::ffff:192.168.0.50	/beakplatform/?m=chk1790813364&id=1%27%20OR%201=1--
920350	4	::ffff:192.168.0.50	/beakplatform/?m=chk1790813364&q=%3Cscript%3Ealert(1)%3C/script%3E
920350	2	::ffff:192.168.0.50	/beakplatform/auth/login?m=chk1790813364
920350	4	::ffff:192.168.0.50	/beakplatform/?m=chk1790813364&fake=1&id=1%27%20OR%201=1--

讀這五列時注意三件事:

  • 五列對五行,O1 通過。探測後要等幾秒:Vector 以批次寫入 ClickHouse,最長 5 秒送一次。
  • 帶 fake=1 那一列的 actor 是工作機,O2 通過。位址顯示成 ::ffff: 開頭是因為欄位型態是 IPv6,IPv4 位址以對映形式存放。
  • finding_rule_id 全部是 920350,不是 942100。Vector 取的是第一條命中的規則,用 IP 當 Host 打的請求第一條永遠是 920350。嚴重度則取整筆裡最嚴重的:被擋的是 4,只命中 920350 的是 2。所以看「有沒有被擋」要看 severity_id,不要看規則編號。從 Internet 用主機名稱打的 SQL injection,這一欄才會是 942100。

O3、O4、O5 連著做,前後各看一次 odstat:

#### 主機 B
odstat                                                  # 記下 total 與 2xx
sudo bash /opt/integrated-waf/install.sh --test-event
odstat                                                  # total 與 2xx 各加一

第一次 odstat 的 total 與做第三節探測之前相同,就是 O3:那五筆的攻擊者與目標都是內網位址,在 Vector 的 intake_filter 就被丟了。--test-event 送的是一筆攻擊者 203.0.113.42 的合成事件,過得了過濾,幾秒後印出 forwarded … status=200,然後到平台看 O5。

O6 用 Vector 的注入口連送三筆同鍵事件。封頂的鍵是「來源系統|規則|攻擊者 IP」,WAF 與 Suricata 以外的來源每個鍵 300 秒只放 1 筆:

#### 主機 B
odstat
for i in 1 2 3; do inject TEST-CAP 203.0.113.42; done
sleep 6; odstat

預期:三次注入都回 200,但 odstat 的 total 只加一。三個 200 是注入口收下了,不是送出去了;後兩筆在封頂那一關被丟棄,ClickHouse 裡三筆都在。平台會多一張「驗證事件 TEST-CAP」的案件,事件數是 1。WAF 事件的配額是每鍵 300 秒 5 筆、Suricata 是每鍵 3600 秒 1 筆,最後還有全域每 60 秒 8 筆(第 3 篇第一節),所以這一輪驗證的合成事件不要在一分鐘內送超過八筆,否則被丟的原因會變成全域封頂,看不出是哪一道在作用。


上一篇
防禦主機的流程分叉點--分成:線上阻擋與線外處置
系列文
一鍵完成六套開源防禦系統整合 共 18 篇
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言