EST. 2026 — บันทึกงานเซิร์ฟเวอร์จริง SYSADMIN · SECURITY · AI · DIRECTADMIN เล่มที่ 01
THE SYSADMIN ALMANAC
หน้าหลัก / Security / libmodsecurity3 + nginx — exclusion ที่ใช้ได้จริงมีแบบเดียว: ctl:ruleEngine
Security

libmodsecurity3 + nginx — exclusion ที่ใช้ได้จริงมีแบบเดียว: ctl:ruleEngine

โดย Ton-Or ·10 ก.ค. 2026·อ่าน 9 นาที
libmodsecurity3 + nginx — exclusion ที่ใช้ได้จริงมีแบบเดียว: ctl:ruleEngine

ความรู้ ModSecurity ส่วนใหญ่ใน internet เขียนจากยุค v2 บน Apache — พอเอามาใช้กับ libmodsecurity3 บน nginx หลายอย่างไม่ทำงาน และที่ร้ายคือมันเงียบ ไม่ error ให้รู้ บทความนี้คือผลจากการลองครบทุกท่าจนเจอทางที่เชื่อถือได้จริง

พอดีใครใช้ ModSecurity v3 (libmodsecurity3) คู่กับ nginx + OWASP CRS แล้วเคยพยายามยกเว้น false positive ให้เว็บสักตัว บทความนี้อาจช่วยประหยัดเวลาให้หลายชั่วโมงครับ เพราะความรู้ส่วนใหญ่ใน internet เขียนมาจากยุค ModSecurity v2 (Apache) ซึ่งหลายอย่างใช้กับ v3 ไม่ได้ — และมันเงียบด้วย ไม่ error ให้รู้

โดเมนในตัวอย่างเป็นชื่อสมมติ

ปัญหาตั้งต้น: WooCommerce โดน WAF บล็อค

เว็บ WooCommerce ของลูกค้า กดสั่งซื้อสินค้าแล้วโดนดัก อาการฝั่ง browser คือ HTTP 406 (ผม config ให้ WAF ตอบ 406 เวลา block เพื่อให้แยกออกจาก 403 ของแอปได้ทันทีว่าใครดัก) ไปดู audit log:

tail -1 /var/log/nginx/modsec_audit.log | python3 -m json.tool | grep -E 'ruleId|match|uri'
# ruleId 942430 — Restricted SQL Character Anomaly Detection
# uri: /?wc-ajax=checkout

ของจริงคือลูกค้ากรอกที่อยู่มีอักขระพิเศษ + payload ของ wc-ajax เป็น serialized data ยาวๆ — CRS นับอักขระต้องสงสัยแล้วเกิน threshold เป็น false positive คลาสสิกของ WooCommerce

ทางที่เอกสารส่วนใหญ่บอก (แล้วไม่ทำงาน)

ตำรา CRS มาตรฐานบอกให้ทำ runtime exclusion แบบนี้:

SecRule REQUEST_URI "@contains wc-ajax" \
    "id:99001,phase:1,pass,nolog,ctl:ruleRemoveById=942430"

ผมใส่ reload nginx เรียบร้อย — ยังโดน 406 เหมือนเดิมเป๊ะ ไม่มี error ไม่มี warning ใน log ใดๆ เหมือน rule เราไม่มีตัวตน เลยไล่ลองครบทุกท่าที่มีในตำรา:

  • ctl:ruleRemoveById=942430 — เงียบ ไม่มีผล
  • ctl:ruleRemoveTargetById=942430;ARGS:billing_address_1 — เงียบ ไม่มีผล
  • SecRuleUpdateTargetById (ตอน load) — ไม่มีผลเช่นกัน
  • ctl:ruleEngine=Off — ทำงาน
  • ctl:ruleEngine=DetectionOnly — ทำงาน (log แต่ไม่บล็อค)
ไม่ใช่ bug เฉพาะเครื่อง

เป็น limitation ที่รู้กันใน issue tracker ของ libmodsecurity3 มานาน — runtime rule removal ตระกูล ruleRemove ทำงานไม่ครบ/ไม่ทำงานใน v3 โดยเฉพาะเมื่อ rule เป้าหมายเป็น chained rule หรืออยู่คนละ phase สิ่งที่ยังเชื่อถือได้จริงมีตระกูลเดียวคือ ctl:ruleEngine

แนวทางที่ใช้จริง: ปิด engine แบบจำกัดขอบเขตให้แคบที่สุด

ในเมื่อปิดได้ทั้ง engine อย่างเดียว หน้าที่เราคือทำให้ "ขอบเขตที่ปิด" แคบจนยอมรับได้ — ล็อคทั้ง Host และ URI ด้วย chained rule:

# ปิด WAF เฉพาะ wc-ajax ของโดเมนเดียว เท่านั้น
SecRule REQUEST_HEADERS:Host "@streq shop-example.com" \
    "id:99009,phase:1,pass,nolog,chain"
    SecRule REQUEST_URI "@rx (?i)[?&]wc-ajax=" "ctl:ruleEngine=Off"

# หน้าแอดมินระบบภายใน: ไม่ปิดสนิท แต่เปลี่ยนเป็น log-only (ยังเห็นใน audit log)
SecRule REQUEST_HEADERS:Host "@streq panel-example.com" \
    "id:99011,phase:1,pass,nolog,chain"
    SecRule REQUEST_URI "@rx ^/admin/" "ctl:ruleEngine=DetectionOnly"

ส่วนตัวผมเลือกใช้สองระดับ: path ที่ payload มัน "สกปรกโดยธรรมชาติ" (wc-ajax, editor ของ CMS) ปิด Off ไปเลย ส่วน path ที่แค่ FP ประปราย ใช้ DetectionOnly เผื่อวันหลังกลับมาไล่ log ได้

ตำแหน่งไฟล์สำคัญมาก: ต้องโหลด "ก่อน" CRS

ผมเคยวางไฟล์ exclusion ชื่อ zzz-exclusions.conf (ตั้งใจให้โหลดท้ายสุด ตามนิสัยไฟล์ config ทั่วไป) — ปรากฏ rule phase:1 ของ CRS (เช่น 920440) ยังบล็อคอยู่ เพราะมันประมวลผลจบไปก่อนที่ exclusion ของเราจะถูกอ่านถึง

ไฟล์ exclusion แบบ runtime ctl ต้องอยู่ที่ไฟล์ที่ CRS เตรียมไว้ให้โดยเฉพาะ ซึ่งโหลดก่อน rule ทั้งหมด:

/etc/modsecurity.d/REQUEST-900-EXCLUSION-RULES-BEFORE-CRS.conf

(คู่กันคือ RESPONSE-999-...-AFTER-CRS.conf สำหรับ directive ประเภท SecRuleUpdate* — ซึ่งบน v3 ก็อย่างที่บอกไป อย่าเพิ่งหวังพึ่ง)

วางไฟล์ถูกที่: โหลดก่อน CRSREQUEST-900-EXCLUSION-RULES-BEFORE-CRS.confโหลดก่อน rule ทั้งหมดกฎ CRSเช่น 920440 phase 1ผ่านWAF ปล่อยผ่านแล้ววางผิดที่: zzz-exclusions.conf โหลดท้ายสุดกฎ CRSเช่น 920440 phase 1zzz-exclusions.confถูกอ่านทีหลัง ไม่ทัน406ยังถูกบล็อกเหมือนเดิมCRS ประมวลผลจบไปก่อนที่ไฟล์ของเราจะถูกอ่านถึง
ไฟล์ exclusion ใน REQUEST-900 BEFORE-CRS ถูกอ่านก่อนกฎ CRS ส่วนไฟล์ zzz-exclusions.conf ถูกอ่านทีหลัง กฎ CRS phase 1 ประมวลผลจบไปแล้วและยังบล็อกอยู่

เทสยังไงไม่ให้หลอกตัวเอง

สองกับดักที่ผมเจอเองระหว่างทำ:

1. แยกให้ออกว่าใครบล็อค — 406 = ModSecurity (ตาม config ของผม), 403 = แอป/nonce ของ WordPress เอง เคยนั่งแก้ WAF อยู่ครึ่งชั่วโมงทั้งที่ตัวบล็อคจริงคือ WooCommerce nonce หมดอายุ

2. เทสผ่าน public IP เสมอ — curl จากตัวเซิร์ฟเวอร์ชี้ 127.0.0.1 จะเข้า default vhost ไม่ใช่ vhost ของโดเมนที่มี WAF config นั้น:

curl -sk --resolve shop-example.com:443:203.0.113.10 \
  'https://shop-example.com/?wc-ajax=checkout' \
  -X POST -d 'billing_address_1=1/2--3' \
  -o /dev/null -w '%{http_code}\n'
# ก่อนแก้: 406 / หลังแก้: 403 (= WAF ปล่อยผ่านแล้ว ที่เหลือ nonce ของ Woo ตามปกติ)

สรุป

  1. libmodsecurity3 (v3) + nginx: ctl:ruleRemoveById และญาติๆ ไม่ทำงานและเงียบ — อย่าเสียเวลา
  2. ของที่ใช้ได้จริง: ctl:ruleEngine=Off|DetectionOnly จำกัดขอบเขตด้วย chained rule Host+URI
  3. ไฟล์ exclusion ต้องโหลดก่อน CRS (REQUEST-900-...-BEFORE-CRS.conf)
  4. block response code ตั้งให้ต่างจากของแอป (406 vs 403) ชีวิตจะง่ายขึ้นมาก
  5. เทสผ่าน public IP ไม่ใช่ 127.0.0.1

ใครยังอยู่กับ ModSecurity v2 บน Apache ตำราเดิมยังใช้ได้ครับ แต่ถ้าย้ายมา v3 บน nginx เมื่อไหร่ จำไว้ข้อเดียวก็พอ: เชื่อ ctl:ruleEngine ตัวเดียว ที่เหลือเทสก่อนเชื่อเสมอ ตรงไหนผมอธิบายตกหล่นหรือพิมพ์ผิด เดี๋ยวกลับมาแก้เพิ่มให้ภายหลังครับ

อยากให้เราทำให้ไหม?

ให้เราช่วยติดตั้ง ตั้งค่า ย้ายระบบ หรือแก้ปัญหาเซิร์ฟเวอร์ เพื่อให้คุณมีเวลาไปทำงานที่สำคัญกว่า

คุยเรื่องงานกับเรา

เคยเจองานแบบนี้ไหม? ติดตรงไหน?

มาแลกวิธีกันต่อในบอร์ด LinuxThai

→ คุยต่อในบอร์ด

ความคิดเห็น 0

ยังไม่มีความคิดเห็น — เป็นคนแรกเลย