EST. 2026 — บันทึกงานเซิร์ฟเวอร์จริง SYSADMIN · SECURITY · AI · DIRECTADMIN เล่มที่ 01
THE SYSADMIN ALMANAC
หน้าหลัก / Security / ปรับแต่ง ModSecurity บน DirectAdmin ให้พอใช้จริงกับ shared host
Security

ปรับแต่ง ModSecurity บน DirectAdmin ให้พอใช้จริงกับ shared host

โดย Ton-Or ·20 พ.ค. 2026·อ่าน 11 นาที
ปรับแต่ง ModSecurity บน DirectAdmin ให้พอใช้จริงกับ shared host

DA default ModSecurity พอดูได้ แต่ไม่ครบสำหรับ shared host จริง false positive ก็มาก ถูก scanner รัวก็มาก ผมเลยเพิ่มอีก 4 ส่วน — ทั้งหมดนี้ "เกิน" DA ship ไม่ได้อยู่ใน CustomBuild หรือ panel

ในซีรีส์ย้าย CentOS 7 ไป Debian 12 ตอนที่ว่าด้วย security ผมเล่าเรื่อง ModSecurity BEFORE-CRS exclusion ไว้สั้น ๆ พอจบตอนมีคนถามเข้ามาเพิ่มหลายราย ผมเลยแยกออกมาเป็นบทความเฉพาะ เผื่อใครจะเอาไปทำตามได้ครบกระบวน

เครื่องที่ผมดูแลตั้งแต่ migrate มา Debian 12 ใช้ DirectAdmin CustomBuild ML stack ก็คือ ModSecurity 3 + OWASP CRS 4.x + Comodo WAF ตัวที่ DA ship มาให้ ค่า default พอดูได้ในระดับหนึ่ง แต่กับ shared hosting ที่มีลูกค้าหลายสิบรายบนเครื่องเดียวมันไม่ครบ ปัญหาที่เจอจริงคือสองด้านพร้อมกัน — ฝั่งหนึ่ง false positive โผล่รัวจนลูกค้าใช้งานปกติไม่ได้ อีกฝั่ง scanner ภายนอกยิงเข้ามาทั้งวันแต่ระบบกลับไม่ตอบสนอง

ผมเลยต้อง customize เพิ่ม 4 ส่วน บอกตรง ๆ ก่อนว่าทุกอย่างที่จะเล่าต่อจากนี้ "เกิน" สิ่งที่ DA ship มา ไม่ได้อยู่ใน CustomBuild และไม่มีในหน้า panel ต้องลงมือแก้ที่ระดับไฟล์เอง

ภาพรวม 4 ส่วนที่เพิ่ม

ทั้ง 4 ส่วนทำงานคนละจุดในเส้นทาง request แต่เสริมกันเป็นชั้น ถ้ามองเป็นผังการไหลของ request หนึ่งตัวที่เข้ามาก็ประมาณนี้

เส้นทาง requestnginx->ModSecexclusion ก่อน CRS->OWASP CRS->error_log->CSFcredscan + LF_MODSEC->ban

สรุปสิ่งที่เพิ่มและแก้

  • exclusion 3 rule ใน REQUEST-900-* ที่โหลด ก่อน CRS ตำแหน่งนี้สำคัญมาก ถ้าโหลดทีหลัง CRS fire ไปแล้ว exclusion ไม่ทันการณ์
  • credscan custom regex ใน CSF — ban ทันทีตั้งแต่ hit แรก 24 ชั่วโมง ซึ่ง DA ไม่มีกลไกนี้
  • แก้ CSF MODSEC_LOG path ที่ DA ship ผิด ทำให้ LF_MODSEC ไม่เคย fire เลยแม้ scanner จะรัวแค่ไหน
  • self-heal cron กัน DA rebuild มาเขียนทับไฟล์ exclusion ของเรา

ไล่ทีละส่วน

1. Exclusion 3 rule (โหลดก่อน CRS)

ที่เก็บ custom rule ปกติของ DA สำหรับ user อยู่ที่ /usr/local/directadmin/data/admin/modsecurity_rules ปัญหาคือ DA โหลดไฟล์นี้ หลัง CRS ซึ่งสายเกินไป พอ request เข้ามา CRS fire ก่อนแล้ว exclusion ที่เราเขียนไว้ไม่ทันได้ทำงาน

ทางแก้คือเอา exclusion ไปวางในไฟล์ที่ stack โหลด ก่อน CRS ก็คือไฟล์ชื่อ REQUEST-900-EXCLUSION-RULES-BEFORE-CRS.conf ใน /etc/modsecurity.d/ ตามชื่อมันบอกอยู่แล้วว่า before CRS

# 99001 — per-IP whitelist (monitor server ของผม)
SecRule REMOTE_ADDR "@ipMatch 203.0.113.99" "id:99001,phase:1,pass,nolog,ctl:ruleRemoveById=920350"

# 99002 — global disable rule 920450 (WP Gutenberg ส่ง X-HTTP-Method-Override ปกติ)
SecAction "id:99002,phase:1,pass,nolog,ctl:ruleRemoveById=920450"

# 99003 — per-URL ปิด ModSec engine บน phpMyAdmin (false positive รัว whack-a-mole ไม่ไหว)
SecRule REQUEST_URI "@beginsWith /phpMyAdmin/" "id:99003,phase:1,pass,nolog,ctl:ruleEngine=Off"

อธิบายทีละ rule ว่ามาจากปัญหาอะไร เพราะถ้าเข้าใจที่มาจะปรับใช้กับเครื่องตัวเองได้ถูก

99001 (per-IP) — Monitor server ของผมยิง health check เข้ามาทุกนาทีโดยใช้ IP ตรง ๆ ไม่ได้ใช้ hostname ผลคือมันไป trigger CRS rule 920350 ที่แปลว่า "Host header เป็น numeric IP" ทุก request ที่ผ่านมา ผมจึง whitelist เฉพาะ IP นั้นออกจาก rule นี้ rule เดียว CRS ตัวอื่นยังตรวจ IP นั้นตามปกติ ไม่ได้ปลดล็อกทั้งชุด ขอบเขตจึงแคบ

99002 (global) — WordPress Gutenberg block editor ส่ง header X-HTTP-Method-Override: PUT มาด้วยตามดีไซน์ของ WP รุ่นใหม่ แต่ rule 920450 มอง header ตัวนี้เป็นของน่าสงสัยแล้ว block ทิ้ง ผลคือ admin กด publish ไม่ได้ เด้ง error "Response is not valid JSON" ผมเลย disable rule นี้แบบ global ทุก IP เพราะ header นี้ถูกต้องตาม spec ของ WP สมัยใหม่ ไม่ใช่ของแปลกปลอม

99003 (per-URL) — phpMyAdmin เป็นตัวสร้าง false positive รัวที่สุด มันโดน CRS หลายตัวพร้อมกัน (932380, 921140, 942100 และอีกหลายตัว) ถ้าจะมานั่ง whitelist รายตัวมันคือ whack-a-mole ไม่จบ ผมเลือกปิด ModSec engine ทั้งก้อนเฉพาะ path นี้ ความเสี่ยงตรงนี้ผม "ระบุได้" — path นี้ต้อง login DA admin ก่อน แล้วยังมี brute-force protection ที่ระดับ .htaccess คุมอยู่อีกชั้น จึงไม่ได้เปลือยขนาดนั้น

ก่อนปิด engine ทั้ง path

การปิด ruleEngine ทั้งก้อนเหมาะกับ path ที่มีด่านยืนยันตัวตนคุมอยู่ก่อนแล้วเท่านั้น เช่น phpMyAdmin ที่ต้อง login admin path สาธารณะที่ใครก็เข้าถึงได้ ห้ามปิดแบบนี้เด็ดขาด ให้ whitelist เป็นราย rule แทน

ใครจะเพิ่ม exclusion ใหม่ในอนาคต วิธีหาคือดูเหตุการณ์ที่ log แล้วมองหา [id "XXXXXX"] ในบรรทัดที่ ModSec บันทึก จากนั้นเลือก type ให้เหมาะกับขอบเขตของปัญหา

เลือก scope ให้แคบที่สุด

3 แบบที่เลือกได้

  • per-IP — ปัญหาเกิดจาก IP เดียว (monitor, office)
  • global — rule นั้นผิดจริงกับทุกคน (false positive เชิงโครงสร้าง)
  • per-URL — path เดียวที่จัดการเป็นราย rule ไม่ไหว
หลักการ

อย่าเปิดกว้างเกินจำเป็น

  • เริ่มจาก scope แคบสุดก่อน
  • ปลดทีละ rule ก่อนค่อยปิดทั้ง path
  • ปิดทั้ง engine คือทางสุดท้าย

2. Credscan — ban scanner ตั้งแต่ hit แรก

DA default ใช้ BFM (Brute Force Monitor) threshold ปกติ scanner ต้อง hit ราว 50 ครั้งขึ้นไปถึงจะโดน ban ปัญหาคือ scanner ยุคนี้ฉลาดขึ้น มันยิงเปลี่ยน path ไปเรื่อย ตัวที่ทำ credential stuffing จริงจะ hit ทีละไฟล์ คนละ path กัน ผลคือมันไม่เคยสะสมถึง threshold ของ BFM เลย เพราะ BFM นับการ fail ซ้ำ ๆ บนจุดเดียว ไม่ได้นับลายเซ็นของพฤติกรรม probe

ผมเลยเขียน custom regex ให้ CSF จับเฉพาะลายเซ็นของการ probe credential file หรือ backdoor shell แล้ว ban ทันทีตั้งแต่ hit แรก 24 ชั่วโมง ไฟล์อยู่ที่ /usr/local/csf/bin/regex.custom.pm

sub custom_line {
    my $line = shift;
    my $lgfile = shift;

    if (($globlogs{CUSTOM1_LOG}{$lgfile}) and
        ($line =~ /\[client (\d+\.\d+\.\d+\.\d+)\].*ModSecurity.*\[uri "[^"]*?\/(wp-config\.php|\.env|\.git\/config|\.ssh\/id_rsa|credentials\.json|alfa[0-9]*\.php|shell\.php|cmd\.php|r57\.php|c99\.php|[0-9]\.php)"/i)) {
        return ("Credential/scanner probe ($2)",
                $1,           # IP ที่จะ ban
                "credscan",   # ชื่อ trigger
                "1",          # 1 hit ban ทันที
                "*",          # ทุก port
                "86400",      # ban 24 ชม.
                "0");
    }
    return 0;
}
1;

จุดสำคัญของ regex นี้คือ field ที่ส่งกลับ — ค่า "1" คือจำนวน hit ที่ trigger หมายความว่า hit เดียวก็ ban และ "86400" คือระยะเวลา ban เป็นวินาที ก็คือ 24 ชั่วโมงนั่นเอง

patterns ที่ผมจับมีรวมประมาณ 25 ตัว แบ่งเป็นหมวดได้ดังนี้

  • Credential files — credentials.json, firebase-adminsdk*, gcp-*.json, keyfile.json, secret.json
  • Env files — .env, .env.production, .env.development
  • Cloud — .aws/credentials, gcloud/application_default
  • Git / SSH — .git/config, .ssh/id_rsa, .npmrc, .pypirc
  • PHP info disclosure — phpinfo.{bak,save,old}, info.php~, .info.php
  • WordPress config — wp-config.php, wp-config-sample.php, wp-config.php.{bak,save,old,orig}
  • Backdoor shells — wp-good.php, shell.php, cmd.php, alfa*.php, xleet*.php, r57.php, c99.php, m.php, [0-9].php

ผลที่วัดได้ภายใน 3-4 ชั่วโมงแรกหลัง deploy ลองดูจาก log ที่ผมตัดมา (sanitize IP แล้ว) จะเห็นว่ามีของเข้ามายิงตลอด

03:58  20.x.x.x    JP   probed wp-config-sample.php
04:38  20.x.x.x    SG   probed wp-config-sample.php
04:51  20.x.x.x    CA   probed wp-config-sample.php
06:06  194.x.x.x   DE   probed .git/config
07:09  104.x.x.x   US   probed wp-config-sample.php

ทุกตัวโดน ban ตั้งแต่ hit แรก ของเดิม DA default ปล่อยให้สะสมถึงราว 177 hit กว่าจะ trigger ลด attack surface ที่เปิดให้ attacker probe ลงไปได้เยอะมาก

ทำไม 1-hit ถึงปลอดภัยกับลูกค้าจริง

pattern พวกนี้คือไฟล์ที่ "ไม่มีเหตุให้ user ปกติเรียก" เลย — ไม่มีใครเปิดเว็บแล้วเผลอไปขอ .ssh/id_rsa หรือ c99.php การ ban hit เดียวจึงแทบไม่มี false positive ต่างจากการตั้ง threshold ต่ำกับ path ทั่วไปที่อาจโดน user จริง

3. แก้ CSF MODSEC_LOG path bug

อันนี้เป็น bug ที่ DA ship config มาผิดตั้งแต่แรก เปิด /etc/csf/csf.conf จะเห็นค่านี้

MODSEC_LOG = "/var/log/apache/modsec_audit.log"

ปัญหาคือ ModSecurity 3 บน nginx ที่ DA ใช้อยู่ตอนนี้ log จริง ๆ ไปที่ /var/log/nginx/error_log ไม่ใช่ path ของ Apache ผลที่ตามมาคือ CSF watch ผิดไฟล์ พอ watch ผิดไฟล์ LF_MODSEC ก็ไม่เคย fire เลย ต่อให้ ModSec block ไป 100 กว่าครั้งต่อชั่วโมง CSF ก็ยังเงียบสนิทเพราะมันไปจ้องไฟล์ที่ไม่มีอะไรเกิดขึ้น

ModSecurity 3บน nginx/var/log/nginx/error_loglog จริงเขียนที่นี่/var/log/apache/modsec_audit.logpath ที่ DA ship มาผิด ไม่มีอะไรเกิดขึ้นCSFLF_MODSECหลังแก้ก่อนแก้ความเงียบของ LF_MODSEC ไม่ใช่สัญญาณว่าปลอดภัย แต่เป็นสัญญาณว่า CSF จ้องผิดไฟล์
ModSecurity 3 บน nginx เขียน log ที่ error_log แต่ค่า MODSEC_LOG ที่ DA ship มาชี้ไปไฟล์ของ Apache ทำให้ CSF ไม่เห็นเหตุการณ์เลย ต้องแก้ให้ชี้ไฟล์เดียวกัน

แก้ตามนี้

sed -i 's|^MODSEC_LOG = .*|MODSEC_LOG = "/var/log/nginx/error_log"|' /etc/csf/csf.conf
sed -i 's|^LF_MODSEC = .*|LF_MODSEC = "25"|' /etc/csf/csf.conf
sed -i 's|^LF_MODSEC_PERM = .*|LF_MODSEC_PERM = "1800"|' /etc/csf/csf.conf
csf -r

ค่าที่ผมใช้ พร้อมเหตุผล

  • LF_MODSEC = 25 — ban เมื่อ hit ครบ 25 ครั้งต่อชั่วโมง พอแยกพฤติกรรม scanner ออกจาก false positive ปกติของ user
  • LF_MODSEC_PERM = 1800 — ban 30 นาที ไม่ต้อง permanent เพราะ IP พวกนี้ส่วนใหญ่หมุนเวียน การ perm ไปก็เปลือง memory ของ iptables เปล่า ๆ
  • CUSTOM1_LOG = /var/log/nginx/error_log — ตั้งให้ชี้ไฟล์เดียวกัน เพราะ credscan regex ในส่วนที่ 2 ก็อ่านไฟล์นี้
ตรวจก่อนเชื่อว่าระบบทำงาน

ถ้าเครื่อง DA shared host ของคุณมีลูกค้าเยอะแต่ไม่เคยเห็น LF_MODSEC trigger เลยสักครั้ง อย่าเพิ่งดีใจว่า "ไม่มีคนยิง" ให้สงสัยไว้ก่อนว่า path นี้ผิด แล้วไปเช็ค MODSEC_LOG ทันที ความเงียบในที่นี้คือสัญญาณว่า CSF จ้องผิดไฟล์ ไม่ใช่สัญญาณว่าปลอดภัย

หลังแก้แค่ไม่กี่นาที LF_MODSEC ก็เริ่ม fire ตามที่ควรจะเป็น

4. Self-heal — กัน DA rebuild ทับ exclusion

ปัญหาถัดมาคือเรื่องความคงทน เวลา DA update หรือ CustomBuild rebuild (เช่นรัน ./build modsecurity_rules) มันอาจ wipe ไฟล์ใน /etc/modsecurity.d/ ทิ้ง exclusion ที่เราอุตส่าห์เขียนไว้หายเงียบ ๆ โดยไม่มีใครรู้ ผลคือ false positive ทั้งหมดกลับมา แล้วลูกค้าค่อยมาโวยทีหลังว่าใช้งานไม่ได้ ซึ่งกว่าจะรู้ว่าต้นเหตุคือ rebuild ก็เสียเวลาไล่นาน

ผมแก้ด้วย safety-net script คู่กับ daily cron แนวคิดคือให้ script เช็ค marker ว่า rule ครบไหม ถ้าครบก็ออก ถ้าหายก็ restore กลับเต็มไฟล์เอง

cat > /usr/local/directadmin/scripts/custom/check_modsec_exclusions.sh <<'EOF'
#!/bin/bash
TARGET=/etc/modsecurity.d/REQUEST-900-EXCLUSION-RULES-BEFORE-CRS.conf

# Marker check — ถ้ามี rule ครบ = OK ออกเลย
if [ -f "$TARGET" ] \
   && grep -q 'id:99001' "$TARGET" \
   && grep -q 'id:99002' "$TARGET" \
   && grep -q 'id:99003' "$TARGET"; then
    exit 0
fi

# Restore template เต็มไฟล์
cat > "$TARGET" <<'RULES_EOF'
SecRule REMOTE_ADDR "@ipMatch 203.0.113.99" "id:99001,phase:1,pass,nolog,ctl:ruleRemoveById=920350"
SecAction "id:99002,phase:1,pass,nolog,ctl:ruleRemoveById=920450"
SecRule REQUEST_URI "@beginsWith /phpMyAdmin/" "id:99003,phase:1,pass,nolog,ctl:ruleEngine=Off"
RULES_EOF

nginx -t && systemctl reload nginx
EOF
chmod 755 /usr/local/directadmin/scripts/custom/check_modsec_exclusions.sh

# Cron daily
ln -sf /usr/local/directadmin/scripts/custom/check_modsec_exclusions.sh \
       /etc/cron.daily/modsec-exclusions-check

ที่เลือกวาง script ไว้ใน scripts/custom/ เพราะ DA ไม่แตะ directory นี้ตอน rebuild script จึงรอด DA update เสมอ ส่วน symlink เข้า /etc/cron.daily/ ทำให้มันถูกรันทุกวัน ถ้าไฟล์ exclusion พังเมื่อไหร่ อย่างช้าภายใน 24 ชั่วโมงก็ restore กลับมาเอง

หลักของ self-heal

ของที่ "เกิน" สิ่งที่ DA ดูแล ต้องมีกลไกพาตัวเองกลับมาหลัง update เสมอ จุดสำคัญคือต้องวางไฟล์ใน path ที่ rebuild ไม่แตะ (เช่น scripts/custom/ และ CSF preserved path) แล้วใช้ marker check แบบ idempotent — รันซ้ำกี่รอบก็ไม่พัง

เรื่อง survival strategy ตอน DA update ผมยังมีอีกหลายชั้น (template override, 99-custom-*.ini, post-build hook) ซึ่งอยู่นอกขอบเขตบทความนี้ ครอบไว้ในซีรีส์ migration ตอน security แล้ว

สรุปไฟล์ที่เพิ่มและแก้จาก DA default

ทั้งหมดที่เล่ามาแตะไฟล์อยู่ไม่กี่จุด สรุปเป็นรายการให้เห็นภาพ

NEW       /etc/modsecurity.d/REQUEST-900-EXCLUSION-RULES-BEFORE-CRS.conf
NEW       /usr/local/csf/bin/regex.custom.pm
NEW       /usr/local/directadmin/scripts/custom/check_modsec_exclusions.sh
NEW       /etc/cron.daily/modsec-exclusions-check
MODIFIED  /etc/csf/csf.conf   (MODSEC_LOG + LF_MODSEC + CUSTOM1_LOG)
4ไฟล์เพิ่มใหม่
1ไฟล์แก้
3exclusion rule
~25credscan pattern

ทั้งหมดนี้รอด DA update ได้เพราะวางอยู่ใน path ที่ rebuild ไม่แตะ — /etc/modsecurity.d/ มี self-heal cron พากลับมา, /usr/local/csf/bin/regex.custom.pm อยู่ใน CSF preserved path, และ scripts/custom/ DA ไม่ยุ่งด้วยอยู่แล้ว

credscan regex ตัวจริงของผมยาวกว่าตัวอย่างข้างบนพอสมควร มีอีกหลาย pattern รวมถึง backdoor shell โบราณ ๆ ที่ scanner ปี 2026 ยังยิงเข้ามาอยู่ ใครจะหยิบไปต่อยอด หลักการเหมือนกันหมด — จับลายเซ็นที่ user ปกติไม่มีเหตุเรียก แล้ว ban ให้ไว ส่วนค่า threshold กับเวลา ban ปรับตามปริมาณลูกค้าและความ aggressive ที่รับได้ของแต่ละเครื่อง ตามสะดวกครับ

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

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

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

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

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

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

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

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