nginx、ModSecurity v3、OWASP CRS 三層各做什麼;容器啟動時設定檔怎麼疊起來;一個請求的「異常分數」怎麼算出來、門檻是多少;Paranoia Level 是什麼。讀完本篇,看到 audit.log 裡的 Inbound Anomaly Score Exceeded (Total Score: 8) 就知道 8 是怎麼來的。

| 層 | 負責 | 不負責 |
|---|---|---|
| nginx | TCP 連線、HTTP 解析、反向代理、回應碼、access log、CORS 與安全標頭、/healthz(容器健康檢查用) | 不理解攻擊。它只知道「引擎說要中斷」就回 403 |
| connector | 在 nginx 的 rewrite/access/header filter/body filter/log 各階段呼叫引擎;把 $remote_addr、標頭、本文交給引擎 | 不做判斷、不含規則 |
| libmodsecurity | 規則語言、變數集合(ARGS、REQUEST_HEADERS、RESPONSE_BODY…)、轉換(urlDecode、lowercase…)、運算子(@rx、@pm、@detectSQLi…)、動作(deny、setvar、ctl…)、audit log 的產生 | 不決定「什麼算攻擊」,那是規則的事。沒有規則時它只是一個很貴的 pass-through |
| OWASP CRS | 偵測規則本體、異常分數計分、Paranoia Level 分級、方法與 Content-Type 白名單、回應外洩檢查 | 不認識任何特定應用;誤判排除要由使用者自己寫(第 4 篇) |
本安裝包沒有自己編譯任何東西,直接用 OWASP 官方的 owasp/modsecurity-crs:nginx。這個映像的設計是:nginx 與 ModSecurity 的設定檔都是模板,容器啟動時用環境變數填值後才寫成真正的設定檔。所以 docker-compose.yml 裡 WAF 服務的設定幾乎全是 environment:,而不是掛一堆 .conf 進去。
| 環境變數(compose 內) | 本安裝包的值 | 填進哪裡、作用 |
|---|---|---|
| BACKEND | ${WAF_BACKEND_URL} | nginx 的 proxy_pass。被保護網站的內網位址 |
| PORT / SSL_PORT | 8080 / 8443 | nginx 監聽埠。compose 只發布 8080;8443 在容器內存在但外面連不到 |
| MODSEC_RULE_ENGINE | ${WAF_RULE_ENGINE},預設 On | SecRuleEngine。On 會擋;DetectionOnly 只記錄不擋,全部規則照跑、分數照算、audit.log 照寫,只是不中斷 |
| PARANOIA | ${WAF_PARANOIA},預設 1 | CRS 的 tx.blocking_paranoia_level,見第七節 |
| ANOMALY_INBOUND / ANOMALY_OUTBOUND | 5 / 4 | 請求與回應的異常分數門檻,見第六節。5 與 4 是 CRS 的出廠預設,本安裝包沒有調 |
| MODSEC_AUDIT_ENGINE | RelevantOnly | SecAuditEngine。只記「相關」的交易,第 3 篇第一節 |
| MODSEC_AUDIT_LOG / _FORMAT / _PARTS | /var/log/modsec/audit.log / JSON / ABCFHJZ | audit log 的路徑、格式與內容段落。路徑掛到主機 waf/log/ 給 Vector 讀 |
| SET_REAL_IP_FROM / REAL_IP_HEADER | 127.0.0.2 / CF-Connecting-IP | nginx real_ip 模組。刻意設成永遠不會匹配的值,理由在第 2 篇第六節 |
| SERVER_NAME | _ | 接受任何 Host,hostname 的比對交給 cloudflared ingress 做 |
| BLOCKING_LOG_LEVEL / ERRORLOG_LEVEL | warn / warn | nginx error log 的詳細度。攔截事件會以 warn 進 docker compose logs waf-nginx |
除了環境變數,本安裝包只掛了兩個檔案進容器,都是模板:
| 主機檔案 | 作用 |
|---|---|
| waf/nginx-templates/logging.conf.template | nginx access log 格式,在原廠格式前多記一欄 $realip_remote_addr,方便對照「real_ip 換算前後的來源」。這份 log 沒有任何程式在讀,只是除錯用 |
| waf/modsec-overrides/modsecurity-override.conf | 本安裝包對 CRS 的覆寫:方法白名單與路徑層規則排除(第 4 篇第二節)。掛進 /etc/nginx/templates/modsecurity.d/,容器啟動時展開成 /etc/modsecurity.d/modsecurity-override.conf |
nginx 的 modsecurity.conf 只有兩行:開引擎、指定一個入口檔 /etc/modsecurity.d/setup.conf。所有規則都從這個入口依序 Include 進來,順序決定了誰能覆寫誰。這是映像檔內的實際內容(省略註解):
Include /etc/modsecurity.d/modsecurity.conf # 1 引擎行為
Include /etc/modsecurity.d/modsecurity-override.conf # 2 本安裝包的覆寫
Include /etc/modsecurity.d/owasp-crs/crs-setup.conf # 3 CRS 全域參數
Include /etc/modsecurity.d/owasp-crs/plugins/*-config.conf
Include /etc/modsecurity.d/owasp-crs/plugins/*-before.conf
Include /etc/modsecurity.d/owasp-crs/rules/*.conf # 4 規則本體(901 → 999)
Include /etc/modsecurity.d/owasp-crs/plugins/*-after.conf
| # | 檔案 | 內容 |
|---|---|---|
| 1 | modsecurity.conf | 引擎層設定:SecRuleEngine、要不要讀請求本文與回應本文、本文大小上限、audit log 條件與格式。與「什麼算攻擊」無關。實際值見第五節 |
| 2 | modsecurity-override.conf | 本安裝包的覆寫。放在 crs-setup 之前是有意的:這裡設的 tx.allowed_methods 這類變數,要趕在 CRS 的 901 初始化規則讀取之前存在,否則會被 CRS 的預設值蓋掉 |
| 3 | crs-setup.conf | CRS 的全域參數:Paranoia Level、異常分數門檻、預設允許的方法與 Content-Type 等。映像檔用環境變數把 PARANOIA、ANOMALY_INBOUND 這些填進去 |
| 4 | rules/*.conf | 規則本體,依檔名數字順序載入:901 初始化 → 905 常見例外 → 911~944 請求檢查 → 949 請求評分 → 950~956 回應檢查 → 959 回應評分 → 980 關聯記錄。數字就是規則 ID 的前三碼,看到 942100 就知道它在 942(SQLi)檔裡 |
| — | plugins/* | CRS 外掛的掛載點。本安裝包沒裝任何外掛,這幾行實際上是空的 |
容器啟動時 nginx 會把整套規則載入記憶體(啟動 log 會印 rules loaded inline/local/remote: 0/850/0)。覆寫檔有語法錯誤時容器起不來、網站直接不通,改完覆寫檔一定要看 docker compose logs waf-nginx。
| 誤會 | 實際 |
|---|---|
| 「命中一條規則就會被擋」 | 不是。CRS 4 的預設模式是異常分數(anomaly scoring):每條命中規則只加分,最後由 949 規則看總分有沒有到門檻。單一條 WARNING 等級的規則(3 分)不會讓請求被擋。這也是為什麼 audit.log 裡會有「命中規則但回 200」的交易 |
| 「WAF 會看懂應用程式邏輯」 | 不會。CRS 是通用規則,只認字串型態:SQL 語法片段、shell 命令、HTML 標籤、路徑穿越序列。「這個帳號沒權限看那筆資料」這種邏輯漏洞它完全看不到 |
| 「規則會自己更新」 | 不會。規則跟映像檔綁在一起,拉到什麼版本就用到什麼版本。Suricata 有 suricata-update 每日抓規則,WAF 沒有對應機制,要更新得重拉映像(第 4 篇第六節) |
這些是容器內 modsecurity.conf 生效中的指令,都是映像檔預設,本安裝包只透過環境變數改了 audit 相關的幾項。
| 指令 | 值 | 意義 |
|---|---|---|
| SecRuleEngine | On | 擋。改成 DetectionOnly 就只記錄 |
| SecRequestBodyAccess | on | 讀請求本文(POST 表單、JSON)。關掉的話 POST 內的攻擊完全看不到 |
| SecRequestBodyLimit / LimitAction | 13107200 / Reject | 請求本文超過 12.5 MB 直接拒絕(413)。nginx 這邊 client_max_body_size 0 是不限,所以實際上限是這一條 |
| SecResponseBodyAccess | on | 讀回應本文,才能抓 SQL 錯誤訊息、原始碼外洩 |
| SecResponseBodyMimeType | text/plain text/html text/xml | 只檢查這三種回應。JSON、圖片、下載檔不檢查(application/json 不在清單內,API 回應的外洩檢查是空的) |
| SecResponseBodyLimit / LimitAction | 1048576 / ProcessPartial | 回應本文只檢查前 1 MB,超過的部分照樣送出不檢查 |
| SecAuditEngine | RelevantOnly | 只記相關交易 |
| SecAuditLogRelevantStatus | ^(?:5 | 4(?!04)) |
| SecAuditLogParts | ABCFHJZ | 記哪幾段,第 3 篇第二節 |
| SecAuditLogFormat | JSON | 一行一交易,Vector 用 parse_json 讀 |
CRS 給每條偵測規則一個嚴重度,命中時依嚴重度加分到交易變數 tx.anomaly_score(請求)或 tx.outbound_anomaly_score(回應)。分數本身不擋人,擋人的是兩條評分規則:
| 嚴重度 | 加分 | 典型規則 |
|---|---|---|
| CRITICAL(ModSecurity 等級 2) | 5 | 942100 SQLi(libinjection)、941100 XSS(libinjection)、932 系列命令注入、930 系列路徑穿越 |
| ERROR(等級 3) | 4 | 部分協定攻擊、回應外洩 95x 系列多為此級 |
| WARNING(等級 4) | 3 | 920350 Host 標頭是 IP、920 系列多數協定強制規則、913 掃描器指紋 |
| NOTICE(等級 5) | 2 | 少數提示型規則 |
| 評分規則 | 看什麼 | 門檻(本安裝包) |
|---|---|---|
| 949110 | tx.anomaly_score,請求階段結束時 | ANOMALY_INBOUND=5。達到就 deny,回 403,並在 audit.log 記一條 Inbound Anomaly Score Exceeded (Total Score: N)。這條訊息本身的等級是 0(EMERGENCY),第 3 篇會說這對平台端的嚴重度有什麼影響 |
| 959100 | tx.outbound_anomaly_score,回應階段結束時 | ANOMALY_OUTBOUND=4。達到就攔下回應,攻擊者看到的是 403 而不是後端的錯誤頁 |
| 980 系列 | 兩邊的總分 | 不擋,只在 phase 5 寫一條關聯摘要進 log |
門檻 5 的意思是:一條 CRITICAL 就夠了,或是兩條 WARNING。下面是 2026-09-26 對防禦節點 8080 直打的實測(原始輸出在 /opt/tmp/verify/20260926-waf-doc-probe.log,位址已去識別化):
| 請求 | 命中規則與分數 | 總分 | 結果 |
|---|---|---|---|
| GET /?id=1' OR 1=1-- | 920350 Host 是 IP(+3,因為用 IP 直打)+ 942100 SQLi via libinjection(+5) | 8 ≥ 5 | 403 |
| GET /?q=alert(1) | 920350(+3)+ 941100 XSS via libinjection(+5)+ 941110 script tag(+5)+ 941160 HTML injection(+5)+ 941390 JS method(+5) | 23 ≥ 5 | 403 |
| GET /auth/login(正常頁面,用 IP 直打) | 920350(+3) | 3 < 5 | 200,但 audit.log 有一行 |
第三列是理解「命中不等於阻擋」最好的例子:用 IP 而不是 hostname 打 WAF,每個請求都會命中 920350,3 分不夠擋,但 RelevantOnly 的條件是「有規則命中」,所以照樣寫 log、照樣被 Vector 讀走。內網驗證時看到一堆 920350 事件是這個原因,不是攻擊。從 Cloudflare 進來的請求 Host 是網域名,不會命中這條。
CRS 把規則分成四級偏執程度。等級越高,規則越多、抓得越寬、誤判越多。本安裝包預設 PL1,也就是 CRS 的出廠預設。
| PL | 多了什麼 | 代價 |
|---|---|---|
| 1 | 基礎規則:libinjection 的 SQLi/XSS 偵測、明顯的命令注入與路徑穿越、協定強制、掃描器指紋、回應外洩。CRS 宣稱這一級對正常應用「幾乎不誤判」 | 抓不到經過混淆或分散在多個參數的攻擊 |
| 2 | 更多字串型態的 SQLi/XSS 規則、對特殊字元密度的檢查、更嚴的 Content-Type 與編碼檢查 | 對「合法輸入含有 SQL 關鍵字」的應用(筆記、程式碼片段、報表查詢)開始誤判,需要寫排除 |
| 3 | 把 PL2 的關鍵字規則再放寬比對條件、限制參數數量與長度 | 多數正常應用都需要一批排除規則才跑得起來 |
| 4 | 幾乎所有非字母數字的參數值都會加分 | 適合高度受限的 API,一般網站不可用 |
兩個實務上要知道的事:第一,PL 在 .env 改 WAF_PARANOIA 後 --reconfigure 生效,但升級 PL 之前一定先用 DetectionOnly 跑一段時間看 audit.log 多出哪些規則(第 4 篇第三節)。第二,CRS 每條規則都帶 paranoia-level/N 標籤(audit.log 的 tags 欄位看得到),所以事後可以從 log 分辨「這條誤判是升級 PL 才出現的嗎」。
這是容器內 rules/ 目錄的實際檔案,對應 CRS 4.25 的分組。原系列第 2 篇第二節有各群「抓什麼」的說明,這裡只補檔名與階段,方便對照 audit.log 的 file 欄位。
| 前綴 | 檔案 | 階段 | 備註 |
|---|---|---|---|
| 901 | REQUEST-901-INITIALIZATION | 請求 | 初始化 tx 變數、讀 crs-setup 的參數。覆寫檔的 900200 必須在它之前 |
| 905 | REQUEST-905-COMMON-EXCEPTIONS | 請求 | CRS 內建的少數例外(Apache 內部 dummy 連線之類) |
| 911 | REQUEST-911-METHOD-ENFORCEMENT | 請求 | 方法不在 tx.allowed_methods 內即 CRITICAL。本安裝包在覆寫檔補了 PUT/DELETE/PATCH/OPTIONS |
| 913 | REQUEST-913-SCANNER-DETECTION | 請求 | 掃描器 User-Agent 與已知探測路徑,資料檔 scanners-user-agents.data |
| 920 | REQUEST-920-PROTOCOL-ENFORCEMENT | 請求 | 協定強制:Host、Content-Length、編碼、副檔名、Content-Type。920350 在這裡 |
| 921 | REQUEST-921-PROTOCOL-ATTACK | 請求 | request smuggling、response splitting、標頭注入 |
| 922 | REQUEST-922-MULTIPART-ATTACK | 請求 | multipart 邊界異常 |
| 930 / 931 | REQUEST-930-APPLICATION-ATTACK-LFI / 931-RFI | 請求 | 本機/遠端檔案引入,資料檔 lfi-os-files.data、restricted-files.data |
| 932 | REQUEST-932-APPLICATION-ATTACK-RCE | 請求 | 命令注入,資料檔 unix-shell.data、windows-powershell-commands.data |
| 933 / 934 / 944 | REQUEST-933-PHP / 934-GENERIC / 944-JAVA | 請求 | PHP 函式名、原型污染與 SSRF、Java 反序列化與 Log4Shell 類字串 |
| 941 | REQUEST-941-APPLICATION-ATTACK-XSS | 請求 | XSS,含 libinjection 與多條字串規則。實測一個 命中四條 |
| 942 | REQUEST-942-APPLICATION-ATTACK-SQLI | 請求 | SQLi,942100 是 libinjection |
| 943 | REQUEST-943-SESSION-FIXATION | 請求 | 從參數塞 session id |
| 949 | REQUEST-949-BLOCKING-EVALUATION | 請求 | 請求評分與攔截(949110) |
| 950~956 | RESPONSE-950-DATA-LEAKAGES、951-SQL、952-JAVA、953-PHP、954-IIS、955-WEB-SHELLS、956-RUBY | 回應 | 回應外洩與 web shell 特徵,資料檔 sql-errors.data、web-shells-php.data 等 |
| 959 | RESPONSE-959-BLOCKING-EVALUATION | 回應 | 回應評分與攔截 |
| 980 | RESPONSE-980-CORRELATION | 記錄 | 寫關聯摘要,不擋 |
| 900 / 999 | REQUEST-900-EXCLUSION-RULES-BEFORE-CRS.conf.example、RESPONSE-999-…-AFTER-CRS.conf.example | — | CRS 給使用者放排除規則的兩個範本,副檔名是 .example 所以沒有載入。本安裝包的排除寫在覆寫檔,不用這兩個 |