iT邦幫忙

2026 iThome 鐵人賽

DAY 15
0
自我挑戰組

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

WAF第 3 篇 audit.log:WAF 與平台之間唯一的線

  • 分享至 

  • xImage
  •  

WAF 不會呼叫任何 API、不會送 webhook。它與整套架構的接點只有一個檔案:audit.log。本篇說明這個檔案什麼時候會多一行、一行裡有什麼、Vector 從裡面取哪些欄位變成平台事件、以及它每小時被輪替一次會有什麼後果。

一、什麼時候會寫一行

audit engine 設成 RelevantOnly,加上 SecAuditLogRelevantStatus "^(?:5|4(?!04))",一筆交易只要符合下面任一條就會在 phase 5 寫入:

條件 寫嗎 例子
至少一條規則命中(不論有沒有到門檻) 寫 用 IP 直打命中 920350、回 200 的正常頁面;被 403 的 SQLi
回應碼 5xx 寫 後端掛掉時的 502;後端自己的 500
回應碼 4xx,但不是 404 寫 後端回的 405、401、403、413
回應碼 404,且沒有規則命中 不寫 亂猜路徑但內容無害的請求。走 Cloudflare 時這類請求最多,刻意排除,否則 log 會被掃描器灌爆
2xx/3xx,且沒有規則命中 不寫 所有正常流量
nginx 在解析階段就拒絕的請求 不寫 TRACE 405、畸形 HTTP 400(第 2 篇第三節)

推論:正常流量在 audit.log 裡零紀錄。這份檔案天生就是告警清單,不是存取紀錄,所以 Vector 讀它時不需要「先篩掉正常請求」這一步。nginx 另有 access log(logging.conf.template 定義的 main 格式,多記一欄 real_ip 換算前的來源),但沒有任何程式在讀它,只用來在容器 log 裡除錯。

二、一行裡有哪幾段:ABCFHJZ

ModSecurity 的 audit log 以字母標示段落,SecAuditLogParts 決定記哪些。本安裝包記 A、B、C、F、H、J、Z,JSON 格式時它們會合併成一個 transaction 物件:

段 內容 JSON 裡的位置 為什麼要/不要
A 交易摘要:時間、unique_id、client_ip、client_port、host_ip、host_port transaction.* 頂層 必記。client_ip 是歸因的依據
B 請求標頭 transaction.request.headers、method、uri、http_version 必記。CF-Connecting-IP、XFF、User-Agent 都在這裡
C 請求本文 transaction.request.body 記,作為 payload 證據。POST 的內容會原樣落地,含表單密碼欄位,這是 audit.log 要限制讀取權限的原因
E 回應本文 — 不記。回應本文可能很大且含敏感資料,外洩偵測在 phase 4 做完就夠了
F 回應標頭 transaction.response.headers、http_code 記。看得出回應是後端回的(有後端自己的 CSP、Allow 標頭)還是 nginx 回的 403
H 審計摘要:命中的規則清單、引擎狀態、producer 版本 transaction.messages[]、transaction.producer 必記。Vector 的 rule_id 與 severity 從這裡來
J multipart 上傳的檔案資訊 (有檔案上傳時) 記,檔名與大小,不含檔案內容
Z 結束標記 — 格式要求,必有

三、一行實例逐段解讀

下面是 2026-09-26 實測那筆 SQLi 探測的 audit.log,去識別化:直連來源改成 cloudflared 的 IP、Host 改成示範網域、補上走 Cloudflare 時會有的 CF-Connecting-IP,其餘欄位原樣。實際檔案是一行,這裡分段排版並加註解。

{"transaction": {
  "client_ip": "172.18.0.250",          // 直連 WAF 的對象。經 Cloudflare 時是 cloudflared 容器,
                                        // 不是攻擊者;Vector 據此決定要不要信下面的標頭
  "time_stamp": "Sat Sep 26 15:33:16 2026",   // 容器本地時間(UTC),非 ISO 格式;Vector 不用它
  "server_id": "407b8483…", "client_port": 33586,
  "host_ip": "172.18.0.7", "host_port": 8080, // WAF 容器自己的位址與埠
  "unique_id": "179043679631.558543",   // 這筆交易的 ID,Vector 放進 evidence.raw_log_ref.locator
  "is_interrupted": true,               // 真的被擋了
  "request": {
    "method": "GET", "http_version": "1.1",
    "hostname": "app.example.com",
    "uri": "/beakplatform/?id=1%27%20OR%201=1--",   // 未解碼的原始 URI
    "body": "",
    "headers": {
      "Host": "app.example.com",
      "User-Agent": "curl/8.5.0",
      "Accept": "*/*",
      "CF-Connecting-IP": "203.0.113.42",     // Cloudflare 寫的真實來源
      "X-Forwarded-For": "203.0.113.42",
      "Cf-Ipcountry": "TW"
    }
  },
  "response": {
    "http_code": 403,
    "headers": { "Content-Type": "text/html", "Server": "nginx", … }   // nginx 的 403,沒有後端標頭
  },
  "producer": {
    "modsecurity": "ModSecurity v3.0.16 (Linux)",
    "connector": "ModSecurity-nginx v1.0.4",
    "secrules_engine": "Enabled",          // DetectionOnly 時這裡會是 DetectionOnly
    "components": ["OWASP_CRS/4.25.1"]
  },
  "messages": [                            // 命中的規則,依命中順序
    { "message": "SQL Injection Attack Detected via libinjection",
      "details": {
        "ruleId": "942100", "severity": "2",           // 等級 2 = CRITICAL,+5 分
        "file": "/etc/modsecurity.d/owasp-crs/rules/REQUEST-942-APPLICATION-ATTACK-SQLI.conf",
        "lineNumber": "…",
        "data": "Matched Data: s&1c found within ARGS:id: 1' OR 1=1--",   // libinjection 的指紋與命中的參數
        "match": "Matched \"Operator `DetectSQLi' with parameter `' against variable `ARGS:id' …",
        "tags": ["application-multi","language-multi","platform-multi","attack-sqli",
                 "paranoia-level/1","OWASP_CRS","OWASP_CRS/SQL-INJECTION","capec/1000/152/248/66"],
        "ver": "OWASP_CRS/4.25.1", "maturity": "0", "accuracy": "0" } },
    { "message": "Inbound Anomaly Score Exceeded (Total Score: 5)",
      "details": {
        "ruleId": "949110", "severity": "0",           // 等級 0 = EMERGENCY;這條是「評分後決定擋」
        "file": "…/REQUEST-949-BLOCKING-EVALUATION.conf",
        "data": "", "tags": ["anomaly-evaluation", "OWASP_CRS", …] } }
  ]
}}
看 log 時常問的問題 看哪裡
攻擊者是誰 不是 client_ip。看 request.headers 的 CF-Connecting-IP,但只有 client_ip 是 cloudflared 時才能信(第 2 篇第五節)
有沒有被擋 is_interrupted,或 response.http_code 是 403 且 messages 裡有 949110/959100
被哪條規則抓到、抓到什麼 messages[].details.ruleId 與 data。data 會標出命中的變數名(ARGS:id),寫排除規則時就靠它
是不是升 PL 才出現的 tags 裡的 paranoia-level/N
是 WAF 擋的還是後端回的錯 response.headers:有後端自己的標頭(CSP、Allow、Set-Cookie)就是後端回的
payload 全文 GET 在 request.uri,POST 在 request.body。都是原始未解碼內容

四、Vector 怎麼把它變成平台事件

Vector 用 file source 尾隨 waf/log/audit*.log(主站與歡迎頁兩個檔),每讀到一行就 parse_json,經 modsec_with_alerts_only 過濾(有 messages 或 http_code ≥ 400 才留,實際上 RelevantOnly 已經保證了這件事),再由 ocsf_from_modsec 轉成平台契約的 OCSF 事件。對照表:

平台事件欄位 取自 audit.log 規則與備註
source_system 固定 coraza 這個名稱是早期架構用 Coraza 引擎時定的,後來換成 ModSecurity 但契約沒改,平台的 API Key scope、路由規則、ClickHouse 裡看到 coraza 都是指這條 WAF 線。改名等於改動平台端所有依賴,刻意保留
event_class 固定 web_activity Suricata 是 network_activity
finding.rule_id messages[0].details.ruleId 只取第一條命中規則。SQLi 例子裡是 942100 而不是 949110。平台併案用「同 IP + 同 rule_id」,所以用 IP 直打時第一條永遠是 920350,所有內網驗證會併成同一案。messages 為空(純 5xx/4xx)時 rule_id 是空字串,平台不併案,一事件一張單
finding.title messages[0].message,截 200 字 同上,取第一條的訊息
finding.rule_set 固定 OWASP CRS
severity_id 整筆 messages 裡最嚴重的 severity,對映後 見下表。注意 949110 的等級是 0,所以只要被擋,平台上一律是 4(Critical),不管實際命中的是 SQLi 還是兩條 WARNING 湊到 6 分。命中但沒被擋的(例如單一 920350)依那條規則的等級對映
actor.ip client_ip 在可信清單內 → CF-Connecting-IP,否則 XFF 第一段,否則 client_ip 可信清單 TRUSTED_INGRESS_LIST 預設只有 cloudflared 的 172.18.0.250。內網直打時 client_ip 是來源機器自己的內網 IP(2026-09-26 實測:從管理機打,log 記的就是管理機的 IP,不是 docker 網關),不在清單內,事件的 actor 就是那台機器;想在測試時帶假標頭要把那台機器的 IP 加進 TRUSTED_INGRESS_EXTRA
actor.xff X-Forwarded-For 原文 證據欄位,平台存進案件的 actor_xff,不參與判定
actor.country Cf-Ipcountry,僅可信來源時 不可信來源一律空字串
actor.user_agent request.headers.User-Agent
target.host / target.url request.headers.Host / request.uri Host 是內網 IP 開頭(192.168./10./172./127.)時,配上內網 actor 會被 intake_filter 整筆丟掉(內網對內網),所以內網驗證的 WAF 事件進 ClickHouse 但不進平台
target.service 依檔名:audit-welcome.log → waf-welcome,否則 waf-nginx 平台處置中心可據此分辨刺探的是主站還是歡迎頁
occurred_at now()(Vector 讀到的時間) 不用 time_stamp,因為它不是 ISO 格式且不帶時區。差距是秒級,可忽略
correlation_id uuid_v4() 每讀一行產生一個新的。這代表 Vector 重讀同一行會變成新事件;file source 有 checkpoint 所以正常不會重讀,但手動 cat audit.log.bak >> audit.log 這種操作會製造重複案件
evidence.raw_log_ref {type: modsec, locator: unique_id} 拿 unique_id 可以回 ClickHouse 的 raw 欄位或 audit.log 找整筆
evidence.snippet request.method 目前只放方法,payload 本文要看 raw

嚴重度對映(ModSecurity 沿用 syslog 等級,數字越小越嚴重;平台契約是 1 Low 到 4 Critical):

ModSecurity severity CRS 名稱 平台 severity_id 什麼情況
0、1、2 EMERGENCY/ALERT/CRITICAL 4 Critical 所有被擋的交易(949110/959100 是 0);命中任何 CRITICAL 規則
3 ERROR 3 High 只命中 ERROR 級且未達門檻(少見,ERROR 4 分離門檻只差 1)
4 WARNING 2 Medium 只命中 WARNING 級:920350、單一 913 掃描器指紋等,回 200 放行的那些
5、6、7 NOTICE/INFO/DEBUG 1 Low 幾乎不會出現
messages 為空 — 1 Low(預設 7 → 1) 純 5xx/4xx 交易:後端 502、405 之類。這就是原系列說「後端掛掉建的案件嚴重度到不了自動封鎖門檻」的原因

五、過濾與封頂對 WAF 事件的實際效果

轉成 OCSF 之後分兩路:ClickHouse 拿全量,od-bridge 那一路要再過三道閘門。針對 WAF 事件:

閘門 對 WAF 事件的規則 後果
intake_filter actor 是內網 IP 且 target.host 是內網 IP → 丟。不看嚴重度(Suricata 才丟 Low) 內網驗證的事件不進平台;經 Cloudflare 的一律進,包括 Medium 的 920350 類
分來源封頂 同「coraza|rule_id|actor_ip」每 300 秒最多 5 筆 掃描器一分鐘打 300 個 SQLi,平台只收到 5 筆,ClickHouse 有 300 筆。比 Suricata 的每小時 1 筆寬很多,理由是 WAF 事件是已擋下的攻擊證據,讓 SOC 看得到強度
全域封頂 不分來源每 60 秒 8 筆 分散式掃描時保護平台主機。超額的丟棄,不排隊
平台端併案 同 actor_ip + 同 rule_id 在 60 分鐘內併入同一案 SOC 看到「一個 IP、一條規則、事件數 N」,不是 N 張單

封頂是丟棄,不是聚合。「平台上這個 IP 有 5 筆事件」的意思是至少 5 筆,真實數字要查 ClickHouse 的 events 表(source_system='coraza')。做攻擊面統計時不要看案件數。

六、輪替:每小時一次,保留六小時

主機端 cron(/etc/cron.hourly/secstack-rotate-logs,由 install.sh 產生)每小時對 audit.log 與 audit-welcome.log 做 copy + truncate:先複製成 audit.log.YYYYMMDD-HH,再把原檔清空。五分鐘後壓成 gz,超過 360 分鐘的刪除。用 copy + truncate 而不是 rename 是因為 nginx 持有的 fd 要維持有效,且不依賴 logrotate 的降權機制。

後果 說明
audit.log 本身只是六小時的緩衝 要查歷史一律去 ClickHouse(90 天)。audit.log 的角色是「給 Vector 讀」,不是「給人查」
truncate 當下有一個遺失窗口 Vector 是尾隨讀取,truncate 發生在它讀走之前的那幾行會消失,每小時一次、約一秒。這是 ClickHouse 也不是 100% 全量的原因之一
Vector 不會重讀輪替檔 source 的 exclude 排除了 audit*.log.* 與 *.gz,所以輪替出來的檔案不會被當成新檔再讀一次
檔案擁有者是容器內的 nginx(uid 101) 主機上看到的擁有者名稱會是 uid 101 對應到的任何本機帳號,這是正常的。cron 以 root 執行所以不受影響;手動看檔案用 sudo

七、常見的「這不是攻擊」

在 ClickHouse 或平台看到 實際是什麼
rule 920350 Host header is a numeric IP address,Medium,actor 是某台內網管理機 有人用 IP 直打 8080 做驗證。經 Cloudflare 的請求 Host 是網域名,不會有這條。內網對內網會被 intake_filter 丟掉,所以通常只在 ClickHouse 看到
rule_id 空、severity Low、很多不同 actor 對同一 host 後端掛了,WAF 對每個正常訪客回 502。這種案件不該封 IP(原系列第 4 篇情境 C)
target.service = waf-welcome,各種 913/930 規則 掃描器在刺探根路徑與常見漏洞路徑。歡迎頁存在的目的就是讓這些變成事件
rule 911100 Method is not allowed by policy 覆寫檔的方法白名單沒生效(容器沒重建)或客戶端用了 TRACE 以外的非標準方法。前者會讓所有 REST API 的 PUT/DELETE/PATCH 全部 403,第 4 篇第二節

上一篇
WAF第 2 篇 一個請求在 WAF 容器內的旅程
下一篇
WAF第 4 篇 設定、調校與驗證
系列文
一鍵完成六套開源防禦系統整合 共 18 篇
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言