昨天講到防禦主機的處理流程中的第一個路口,有兩條線可前進,今天先把證明這兩條去向講清楚,後天把下個處理的repo[verification]也簡介,方便沒接觸過的朋友有概念,然後會繼續連講數篇這兩條流程的處理過程
驗證清單的上下兩篇會硬一點,請同學們撐下去前幾篇講的都是機制:每一站做什麼、為什麼這樣排。這一篇(上+下)只講解一個問題:你怎麼知道它現在真的在做。上一 篇把架構分成線上與線外兩條線,驗證也跟著分成兩組,再加上線外的去程與回程,一共三組、十八個檢查點。每一項都寫成「要證明什麼、打什麼指令、通過長什麼樣」,最後一節是日常只要跑的三行。
先講結論。兩條線之間沒有函式呼叫,所以一條線的證據不能拿來證明另一條線。SQL injection 回 403 只證明線上;平台出現案件只證明線外去程;kernel 裡多一個元素只證明線外回程。三組證據互不代替,少看一組,那一段壞了幾天都不會有人知道(第七節有一個真實的例子)。另一個原則是每項斷言都要有反證:除了「該擋的擋了」,還要看「該放的放了」;除了「該進平台的進了」,還要看「不該進的沒進」。
| 原則 | 理由 | 違反時會發生什麼 |
|---|---|---|
| 分線驗,證據不跨線 | 線上與線外只靠兩個交接點(紀錄檔、封鎖清單)相連,任何一邊都不知道另一邊的死活(第 9 篇第三節)。這是刻意的故障隔離,代價就是一邊壞了另一邊不會報錯 | 看到 403 就認定「整套都在運作」。實際上線外可能已經停了幾天 |
| 每項斷言配一個反證 | 只有正面測試的檢查,在「全部都擋」或「全部都放」的壞掉狀態下一樣會通過。WAF 把所有請求都回 403,SQL injection 探測照樣是 403 | 規則引擎設成只偵測、來源管制被改成全開、過濾條件失效,這三種狀態都通過正面測試 |
| 交接點兩側都要驗 | 交接點是「一邊寫、另一邊讀」。寫的一方正常不代表讀的一方有在讀:audit.log 有新行不代表 Vector 讀到了,/edl 有內容不代表有設備在抓 | 最常見的斷點都在交接點,不在元件內部 |
| 看最靠近事實的那一層 | 容器狀態是 docker 的說法,回應碼才是事實;決策狀態 applied 是 od-bridge 的回報,kernel 裡的元素才是事實;案件數是平台的視角,ClickHouse 才是全量 | 拿轉述當證據。轉述通常是對的,出事的時候剛好就是它不對的時候 |
與昨天那篇的圖不同,node位置佈局都一樣,但線路和標記不同!
全篇沿用安裝章節的位址:主機 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:
--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--
讀這五列時注意三件事:
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 篇第一節),所以這一輪驗證的合成事件不要在一分鐘內送超過八筆,否則被丟的原因會變成全域封頂,看不出是哪一道在作用。