ปลด CSF อัตโนมัติให้ออฟฟิศจริง แต่ไม่ปลดให้ attacker ด้วย geo+account gate
ออฟฟิศลูกค้ารายหนึ่งที่ผมดูแล mail server ให้ ใช้ IP หมุนทุกวัน แล้วมีเครื่องผีตั้งรหัสเก่าค้างยิง auth ผิดทั้งวัน จน CSF บล็อก IP สาธารณะที่ทั้งออฟฟิศแชร์กัน ผมแก้ต้นเหตุไม่ได้เพราะเข้าไม่ถึงเครื่องลูกค้า เลยต้องวางระบบบรรเทาอาการฝั่ง server ของตัวเองล้วน ๆ.
ออฟฟิศลูกค้ารายหนึ่งที่ผมดูแล mail server ให้ ใช้สายอินเทอร์เน็ตแบบ PPPoE ซึ่ง public IP หมุนเปลี่ยนทุกวัน ในออฟฟิศมีเครื่องอยู่เครื่องหนึ่งตั้งรหัสผ่านอีเมลเก่าค้างไว้ ส่ง auth ด้วยรหัสผิดรัว ๆ ตลอดทั้งวัน ผลลัพธ์คือ CSF กับ DA Brute Force Manager มองว่า IP สาธารณะของออฟฟิศกำลัง brute force เลยสั่งบล็อก IP ที่ทุกคนในออฟฟิศแชร์กันใช้ พอ IP โดนบล็อก ทั้งออฟฟิศก็ส่งเมลไม่ได้พร้อมกัน เป็นรอบ ๆ ทั้งวัน
ปัญหาคือต้นเหตุจริง ๆ มันอยู่ฝั่งลูกค้า แต่ฝั่งลูกค้าแก้ไม่ได้ ด้วยข้อจำกัด 3 อย่าง
ทำไมแก้ตรงต้นเหตุไม่ได้
ในเคสนี้ผมเจอกำแพง 3 ด้านที่ทำให้แก้ที่ต้นทางไม่ได้เลย
- ลูกค้าไม่มี IT staff ในออฟฟิศ ผมบอกว่า "ลอง logout อีเมล account นี้ออกจากทุกเครื่อง" ก็ทำไม่ได้ เพราะไม่มีใครรู้ว่าเครื่องไหนเปิดอะไรค้างไว้บ้าง
- ออฟฟิศอยู่ไกล จะส่งคนเข้าไปเซอร์วิสก็ไม่คุ้ม ทั้งระยะทางและค่าใช้จ่าย
- ผมเข้าไม่ถึง LAN ของลูกค้า มองเห็นได้แค่ log ฝั่งสาธารณะบน mail server ที่ตัวเองดูแลเท่านั้น
สามข้อนี้แปลว่า device รหัสเก่าซึ่งเป็นต้นเหตุ มันแก้ได้แต่ฝั่งลูกค้า แต่ฝั่งลูกค้าไม่มีกำลังที่จะแก้ ผมเลยต้องวิ่งหาทางแก้จากฝั่ง server ของตัวเอง คือบรรเทาอาการให้ออฟฟิศกลับมาส่งเมลได้ ระหว่างที่ลูกค้าค่อย ๆ ตามหาเครื่องผีตัวนั้นไป
เงื่อนไขที่ผมตั้งไว้
ก่อนลงมือ ผมเขียนเงื่อนไขให้ตัวเองก่อนว่าระบบที่จะวางต้องตอบโจทย์อะไรบ้าง
- ปลดให้ออฟฟิศจริงให้เร็วที่สุด ระดับวินาที ไม่ใช่นาที ไม่งั้นออฟฟิศติดตั้งแต่เช้ายันบ่าย
- ห้ามเผลอไปปลดให้ attacker ต่างชาติที่เอา credential รั่วมา brute ในเคสนี้ผมเห็น IP จาก CN/IN/VN/KR/BR ใน log ปนเข้ามาตลอด
- ไม่เพิ่ม service ที่ลูกค้าต้องคอยดูแล ทุกอย่างวางบนเครื่องของผมฝั่งเดียว
- reversible ทั้งหมด เมื่อไหร่ที่ลูกค้าแก้เครื่องผีได้ ผมต้องถอด service ออกแล้วเครื่องกลับมาสะอาดเหมือนเดิม
บทความนี้คือไฟล์จริงทุกตัวที่ผมวางอยู่ตอนเขียน ผม sanitize ชื่อ IP โดเมนเป็น placeholder ทั้งหมด แต่ logic ไม่แตะเลย ใครเจอเคสคล้าย ๆ กัน (IP หมุน + ghost device + ลูกค้าฝั่งห่างไกล) ก๊อปไปปรับใช้ได้
office-a = ชื่อออฟฟิศลูกค้าจำลอง / office-a.example = mail domain ของออฟฟิศ / [email protected] = account เครื่องผีที่ตั้งรหัสเก่าค้าง / 203.0.113.42 = public IP ออฟฟิศ (ช่วง RFC 5737 สำหรับเอกสาร) / 192.168.1.44 = LAN IP เครื่องผี / web1 = mail server ของผม (DA + CSF + LFD) / bak1 = เครื่อง bot แยกอยู่คนละ datacenter / botuser = user ที่ bot รันอยู่ใต้บน bak1 / @example_monitor_bot = bot handle บน Telegram. ชื่อไฟล์ใน /usr/local/sbin/, /etc/systemd/system/, /etc/cron.d/ ใช้ prefix csf-office-sweep-* แทนของจริง ปรับเป็นชื่อลูกค้าคุณเองได้.
โครงสร้าง 3 ชั้น auto + 1 ชั้น manual
ผมไม่ได้วาง pipeline เดียวแล้วจบ แต่ซ้อนหลายชั้นเพราะแต่ละชั้นครอบเคสคนละแบบ ภาพรวมเป็นแบบนี้
ไล่ทีละชั้น
- Layer A BLOCK_REPORT hook ปลดทันที แต่ครอบเฉพาะการบล็อกที่มาจาก LFD
- Layer B systemd .path watcher ปลดภายใน 5 วินาที ครอบ DA-BFM ที่ใช้
csf -dตรง ๆ - Layer C cron ช่วง 07:30 ถึง 08:30 เป็น safety-net ตอนออฟฟิศเปิดเครื่องเช้า
- Layer D manual
.capsผ่านบอท ผมไว้เช็คสุขภาพระบบเอง
ทุก auto-layer ผ่าน gate เดียวกันแบบเป๊ะ ๆ คือ
set_id ต้องเป็น *@office-a.example ทั้ง 100% AND geoiplookup ต้องได้ TH
ถ้าไม่ครบสองเงื่อนไขนี้ จะปล่อยบล็อกไว้เฉย ๆ เพราะถือว่าเป็น attacker และเมื่อปลดแล้วจะ whitelist กัน re-ban ไว้ 24 ชั่วโมงด้วย csf -ta <IP> 86400
ทำไมต้องมีหลาย Layer
หลายคนอาจถามว่าทำ Layer เดียวพอไหม คำตอบคือไม่พอ เพราะแต่ละชั้นมีรูที่อีกชั้นต้องมาอุด
ครอบ
- LFD บล็อก (smtpauth/distattack/imapd)
ไม่ครอบ
- DA-BFM ที่ใช้ csf -d ตรง ๆ
ครอบ
- ทุก write บน csf.deny / csf.tempban
ไม่ครอบ
- กรณี systemd ตาย / inotify queue เต็ม
ครอบ
- safety-net ช่วง office boot window
ไม่ครอบ
- ระหว่างวัน (ให้ path watcher คุมแทน)
ครอบ
- ผมเช็คสดตอนแกะปัญหาด้วยมือ
ไม่ครอบ
- ไม่ได้ออกแบบให้ปลดอัตโนมัติ
ขอเล่าเหตุผลของแต่ละชั้นทีละตัว
ทำไมต้องมี B เพราะ DA Brute Force Manager ใน DirectAdmin มันสั่ง csf -d ตรง ๆ ไม่ผ่าน LFD ดังนั้น BLOCK_REPORT hook ของ Layer A จะไม่ยิงเลย IP เลยค้างอยู่ใน csf.deny ทั้งวัน ผมจึงใช้ systemd .path watcher เฝ้าไฟล์ /etc/csf/csf.deny ไว้ พอมีการแก้ไขไฟล์เมื่อไหร่ ก็ยิง sweep ทันที
ทำไมต้องมี C เพราะถ้า systemd .path service เคยตาย (อย่างเคสที่ผมเจอจริง ดูหัวข้อ failure mode ด้านล่าง) cron จะครอบช่วง office boot window 07:30 ถึง 08:30 ไว้ ออฟฟิศเปิดเครื่องเช้ามาเจอเครื่องผี retry รหัสผิด ก็จะถูกปลดอัตโนมัติทันก่อนเริ่มงาน
ทำไมต้องมี D เพราะตัว sweep ไม่ได้ถูก wire เข้า monitored services list ของบอท ถ้า service ตายเงียบ ๆ จะไม่มี alert เด้งมาเตือน ผมเลยทำคำสั่ง .caps sweep-health ไว้เช็คเอง ถือเป็น single source of truth ของสุขภาพ sweep
ไฟล์และตำแหน่งทั้งระบบ
ก่อนลงรายละเอียดแต่ละ Layer ขอวางแผนผังไฟล์ทั้งหมดให้เห็นภาพรวมก่อน เผื่อใครจะ map กับเครื่องตัวเอง
| ไฟล์ | หน้าที่ | perm |
|---|---|---|
| /usr/local/sbin/csf-cond-unblock.sh | Layer A — BLOCK_REPORT hook | 750 root:root |
| /usr/local/sbin/csf-office-sweep.sh | Layer B/C — sweep หลัก | 755 |
| /etc/systemd/system/csf-office-sweep.path | Layer B — เฝ้า csf.deny + tempban | - |
| /etc/systemd/system/csf-office-sweep.service | Layer B — oneshot service | - |
| /etc/systemd/system/csf-office-sweep.service.d/burst.conf | drop-in ยก StartLimit | - |
| /etc/cron.d/csf-office-sweep | Layer C — cron 07:30-08:30 | 644 |
| /usr/local/sbin/caps-analyze.sh | Layer D — analyzer 5 section | 755 |
| /etc/sudoers.d/botuser-phase1 | 5 บรรทัด NOPASSWD ให้ botuser | - |
| /etc/csf/csf.conf (BLOCK_REPORT) | ชี้ไปที่ csf-cond-unblock.sh | - |
| /var/log/csf-office-sweep.log | log การปลดของ sweep | - |
Layer A — BLOCK_REPORT hook
Layer แรกคือ hook ที่ CSF เรียกทุกครั้งที่มันบล็อก IP เริ่มจากแก้ csf.conf ก่อน
BLOCK_REPORT = "/usr/local/sbin/csf-cond-unblock.sh"
# context: LF_PERMBLOCK เลื่อน temp ban -> permanent หลัง 4 ครั้ง/วัน
# ghost device รหัสผิดจะโดน temp >4 ครั้ง/วัน -> ขึ้น csf.deny permanent
LF_PERMBLOCK = "1"
LF_PERMBLOCK_INTERVAL = "86400"
LF_PERMBLOCK_COUNT = "4"
LF_PERMBLOCK_ALERT = "1"
แก้เสร็จต้อง systemctl restart lfd หนึ่งครั้ง
ตัว hook script เองอยู่ที่ /usr/local/sbin/csf-cond-unblock.sh แนวคิดคือมันรับ IP กับ reason ที่ CSF ส่งมาเป็น argument แล้วตัดสินว่าจะปลดหรือไม่
#!/bin/bash
# CSF BLOCK_REPORT hook - auto-unblock IP if cause was ONLY @office-a.example mail-auth fails.
# Args: $1=IP $2=ports $3=perm $4=inout $5=time $6=reason $7=app
set -e
IP="$1"; REASON="$6"
[ -z "$IP" ] && exit 0
# Match LFD (smtpauth/distattack/imapd) + DA BFM (dovecot/exim/BFM) reasons
echo "$REASON" | grep -qiE 'smtpauth|distattack|pop3d|imapd|dovecot|exim|BFM' || exit 0
# Fast path: LFD DISTATTACK ระบุ account ใน reason -> match @office-a.example
if echo "$REASON" | grep -qE '\[[^]]*@office-a\.example\]'; then
/usr/sbin/csf -dr "$IP" >/dev/null 2>&1 || true
/usr/sbin/csf -tr "$IP" >/dev/null 2>&1 || true
logger -t csf-cond-unblock "unblocked $IP - distattack on @office-a.example"
exit 0
fi
# Slow path: BFM reason ไม่ระบุ account -> ดึงจาก exim mainlog ย้อน 50 บรรทัด
RECENT_USERS=$(grep -F "$IP" /var/log/exim/mainlog /var/log/exim/mainlog.1 2>/dev/null \
| grep 'authenticator failed' | tail -50 \
| grep -oE "set_id=[^)[:space:]]+" | sort -u)
[ -z "$RECENT_USERS" ] && exit 0
# ANY non-office account -> mixed attacker -> keep block
if echo "$RECENT_USERS" | grep -qvE '^set_id=\S*@office-a\.example$'; then
logger -t csf-cond-unblock "keep block $IP - mixed accounts"
exit 0
fi
# All @office-a.example -> unblock
/usr/sbin/csf -dr "$IP" >/dev/null 2>&1 || true
/usr/sbin/csf -tr "$IP" >/dev/null 2>&1 || true
logger -t csf-cond-unblock "unblocked $IP - only @office-a.example fails"
exit 0
มีจุดที่อยากชี้ให้ดูสามอย่าง
- Layer A ไม่เช็ค geo เพราะ LFD บล็อกน้อยพอที่ overhead ของ geoiplookup ไม่คุ้มจะเรียก (DA-BFM บล็อกบ่อยกว่ามาก) ผมเลยยก geo ไปเช็คใน Layer B แทน
set_idคือ field ใน exim mainlog ที่บอกว่า account ไหนเป็นคนลอง auth- ที่แยก fast path กับ slow path เพราะ LFD บางเคสฝัง account ไว้ใน reason เป็น
[user@domain]ตรง ๆ ถ้าเจอแบบนั้นก็ match ได้เลยไม่ต้องเปิดอ่าน log ส่วน BFM ที่ไม่ระบุ account ค่อยไปขุดจาก mainlog
Layer B — systemd path watcher + service
Layer B คือพระเอกที่ครอบ DA-BFM เริ่มจากไฟล์ .path ที่เฝ้าไฟล์ ban ของ CSF
[Unit]
Description=Watch CSF ban files for office ghost-device sweep trigger
[Path]
PathModified=/etc/csf/csf.deny
PathModified=/var/lib/csf/csf.tempban
Unit=csf-office-sweep.service
[Install]
WantedBy=multi-user.target
แล้วก็ service แบบ oneshot ที่ .path เรียก
[Unit]
Description=Office ghost-device auto-unblock sweep
After=lfd.service csf.service
ConditionPathExists=/usr/local/sbin/csf-office-sweep.sh
# default - overridden by burst.conf
StartLimitIntervalSec=30
StartLimitBurst=10
[Service]
Type=oneshot
ExecStart=/usr/local/sbin/csf-office-sweep.sh
Nice=10
และ drop-in ที่ยกค่า StartLimit ขึ้น ซึ่งจะอธิบายว่าทำไมในหัวข้อ failure mode
[Unit]
# Raised from 10/30s after manual batch-unblock triggered 13 path events in 17s
# -> systemd refused start -> service stuck failed for 29h. Script-internal 5s
# debounce still caps CPU; this is purely systemd-level safety belt.
StartLimitIntervalSec=60
StartLimitBurst=30
หัวใจจริง ๆ อยู่ที่ตัว sweep script ที่ /usr/local/sbin/csf-office-sweep.sh มันจะรวบ IP ที่โดนบล็อกทั้งหมด แล้วกรองด้วย gate account + geo ก่อนปลด
#!/bin/bash
# Periodic sweep: unblock IPs whose recent fails are 100% @office-a.example
# AND IP geo-locates to TH, then whitelist 24h.
set -e
LOG=/var/log/csf-office-sweep.log
# Debounce 5s - CSF credscan can write csf.deny many times/sec
LOCK=/var/run/csf-office-sweep.last
NOW=$(date +%s)
if [ -f "$LOCK" ]; then
LAST=$(cat "$LOCK" 2>/dev/null || echo 0)
if [ $(( NOW - LAST )) -lt 5 ]; then
exit 0
fi
fi
echo "$NOW" > "$LOCK"
# Snapshot csf -t (DENY + ALLOW) + csf.deny (BFM perm)
CSF_TEMP=$(csf -t 2>/dev/null)
TEMPS=$(echo "$CSF_TEMP" | awk '/^DENY/ {print $2}')
ALLOWS=$(echo "$CSF_TEMP" | awk '/^ALLOW/ {print $2}')
PERMS=$(grep -E 'BFM:.*(dovecot|exim)' /etc/csf/csf.deny 2>/dev/null | awk '{print $1}')
ALL=$(echo -e "$TEMPS\n$PERMS" | sort -u | grep -E '^[0-9]+\.[0-9]+\.[0-9]+\.[0-9]+$')
[ -z "$ALL" ] && exit 0
ts() { date '+%F %T'; }
# Fail-closed: geoiplookup not available -> abort entire sweep
if ! command -v geoiplookup >/dev/null 2>&1; then
echo "[$(ts)] geoiplookup not found - sweep aborted (fail-closed)" >> "$LOG"
exit 0
fi
for IP in $ALL; do
# Skip permanent allow / temp allow
if grep -qE "^${IP}( |$)" /etc/csf/csf.allow 2>/dev/null; then
continue
fi
if echo "$ALLOWS" | grep -qE "^${IP}$"; then
continue
fi
USERS=$(grep -F "$IP" /var/log/exim/mainlog /var/log/exim/mainlog.1 2>/dev/null \
| grep 'authenticator failed' | tail -50 \
| grep -oE "set_id=[^)[:space:]]+" | sort -u)
[ -z "$USERS" ] && continue
# ANY non-office account -> real attacker
if echo "$USERS" | grep -qvE '^set_id=\S*@office-a\.example$'; then
continue
fi
# Geo check - must be TH
CC=$(geoiplookup "$IP" 2>/dev/null | grep -oE '[A-Z]{2},' | head -1 | tr -d ',')
if [ "$CC" != "TH" ]; then
echo "[$(ts)] skip $IP - @office fails but country=$CC (not TH)" >> "$LOG"
continue
fi
/usr/sbin/csf -dr "$IP" >/dev/null 2>&1 || true
/usr/sbin/csf -tr "$IP" >/dev/null 2>&1 || true
/usr/sbin/csf -ta "$IP" 86400 "office ghost device TH - auto-whitelist 24h" >/dev/null 2>&1 || true
echo "[$(ts)] unblocked + whitelisted 24h: $IP (TH)" >> "$LOG"
done
exit 0
GeoIP regex trap ที่เคยทำ self-ban ตัวเอง
จุดนี้เป็นบทเรียนที่เจ็บที่สุดในเคสนี้ ตอนแรกผมเขียน regex ดึง country code แบบนี้
# ผิด: match "IP" ใน "GeoIP" ก่อน country code
CC=$(geoiplookup "$IP" | grep -oE '[A-Z]{2}' | head -1) # ได้ "IP"
# ถูก: ต้องมี comma ตามท้าย
CC=$(geoiplookup "$IP" | grep -oE '[A-Z]{2},' | head -1 | tr -d ',')
geoiplookup ตอบกลับมาในรูป GeoIP Country Edition: TH, Thailand พอผมใช้ regex [A-Z]{2} แบบโล่ง ๆ มันจะไป match คำว่า "IP" ในคำว่า "GeoIP" ก่อนถึง country code จริง ผลคือทุก IP กลายเป็น country "IP" หมด แล้วเงื่อนไขก็เพี้ยน ปลดมั่วไปหมด โชคดีที่ผมจับเจอตอน test ก่อนขึ้น production ทางแก้คือบังคับให้มี comma ตามท้าย แล้วค่อยตัด comma ออกทีหลัง
เวลา parse output ของ geoiplookup อย่า match แค่ [A-Z]{2} ลอย ๆ เพราะตัว label มีคำว่า GeoIP ที่มี "IP" ปนอยู่ ให้ผูกกับ delimiter จริง (comma) เสมอ regex ที่กว้างเกินไปบนข้อความที่ควบคุมเงื่อนไขความปลอดภัย เท่ากับเปิดประตูให้ปลดมั่ว.
Layer C — cron safety-net
Layer C สั้น ๆ แค่ cron สองบรรทัดครอบช่วงเช้า
30,45 7 * * * root /usr/local/sbin/csf-office-sweep.sh
0,15,30 8 * * * root /usr/local/sbin/csf-office-sweep.sh
ผมจำกัดให้รันเฉพาะช่วง office boot window คือ 07:30 ถึง 08:30 เท่านั้น เพราะระหว่างวัน Layer B (path watcher) คุมอยู่แล้ว ไม่จำเป็นต้องเอา cron มารันถี่ ๆ ให้เปลือง
Failure mode ที่เคยเจอ — StartLimitBurst stalemate
อันนี้คือเหตุผลที่ต้องมี drop-in burst.conf และเป็นเหตุผลที่ Layer C กับ Layer D มีอยู่
systemd มีกลไก anti-flood ในตัว ถ้า service ถูกสั่ง start เกิน StartLimitBurst ครั้งภายใน StartLimitIntervalSec มันจะปฏิเสธแล้วเข้า failed state ที่ไม่ auto-recover เอง
วันหนึ่งผมกระตุ้นมันเองโดยไม่ตั้งใจ คือไปเคลียร์ csf.deny แบบ batch ปลด 13 IP รวดเดียวภายใน 17 วินาที ผลคือ path watcher ยิง 13 ครั้งติด ๆ ไปชน StartLimitBurst default ที่ตั้งไว้ 10 ครั้งต่อ 30 วินาที service เลย failed แล้ว sweep ก็ตายเงียบไป 29 ชั่วโมง กว่าจะรู้ตัวก็ตอนลูกค้าทักมาว่าเมลส่งไม่ออก
วิธีกู้คืน
systemctl reset-failed csf-office-sweep.service csf-office-sweep.path
systemctl start csf-office-sweep.path
systemctl status csf-office-sweep.path # ต้องขึ้น active (waiting)
ป้องกันไม่ให้ซ้ำด้วย drop-in burst.conf ที่ยก burst เป็น 30 ครั้งต่อ 60 วินาที (ไฟล์ที่โชว์ใน Layer B) เรื่อง CPU ไม่ต้องห่วง เพราะตัว script เองมี lockfile debounce 5 วินาทีคุมอยู่แล้ว ค่า StartLimit นี้เป็นแค่เข็มขัดนิรภัยระดับ systemd อีกชั้นเท่านั้น
ตัว sweep ไม่ได้อยู่ใน monitored services list ของบอท ดังนั้นถ้ามันตายเงียบ จะไม่มี alert เด้งมา นี่คือสาเหตุที่ผมต้องมี Layer D มาตั้งแต่แรก ใช้ .caps sweep-health เช็คสถานะ systemd ของ .path กับ .service เองเป็นประจำ.
Layer D — caps analyzer + bot integration
bot ของผมแยกเครื่อง คือ bak1 รัน Telegram bot ส่วน web1 เป็น mail server ผมทำคำสั่ง .caps ให้บอท SSH ข้ามไป web1 รัน caps-analyze.sh แล้วโยน output กลับมาที่ Telegram ตัว analyzer แบ่งเป็น 5 section
| section | เช็คอะไร |
|---|---|
| activity | success vs fail count ของ @office-a.example ใน mainlog + จำนวน account ที่ distinct |
| working | top 5 IP ที่ auth สำเร็จ (= device จริง) พร้อม account + geo + rDNS |
| broken | account ที่ fail อย่างเดียว รหัสผิดล้วน (= ghost device candidate) |
| blocked-and-unblock | row ใน csf.deny ของ @office-a.example + auto-unblock เฉพาะที่เป็น TH และ office-only |
| sweep-health | systemd state ของ .path กับ .service + log tail ล่าสุด |
ฝั่ง sudoers ผมให้บอท sudo ได้แค่ 5 บรรทัดเท่านั้น ใน /etc/sudoers.d/botuser-phase1
botuser ALL=(root) NOPASSWD: /usr/local/sbin/caps-analyze.sh activity
botuser ALL=(root) NOPASSWD: /usr/local/sbin/caps-analyze.sh working
botuser ALL=(root) NOPASSWD: /usr/local/sbin/caps-analyze.sh broken
botuser ALL=(root) NOPASSWD: /usr/local/sbin/caps-analyze.sh blocked-and-unblock
botuser ALL=(root) NOPASSWD: /usr/local/sbin/caps-analyze.sh sweep-health
ทำไมต้องระบุ 5 บรรทัด ไม่ใช้ wildcard
# อันตราย: bot สั่ง subcommand อะไรก็ได้
botuser ALL=(root) NOPASSWD: /usr/local/sbin/caps-analyze.sh *
# ปลอดภัย: ระบุทุก section explicit
botuser ALL=(root) NOPASSWD: /usr/local/sbin/caps-analyze.sh activity
botuser ALL=(root) NOPASSWD: /usr/local/sbin/caps-analyze.sh working
...
ถ้าใส่ * ลงไป วันที่บอทถูก compromise attacker จะใส่ argument อะไรก็ได้ทันที แต่ถ้าระบุทีละ section แบบ explicit ต่อให้บอทโดน compromise ก็ทำได้แค่ 5 อย่างที่ผมกำหนดไว้ blast radius เล็กลงเยอะ และอย่าลืม visudo -cf /etc/sudoers.d/botuser-phase1 เช็ค syntax ก่อน save ทุกครั้ง
กลไกความปลอดภัย — ทำไมกล้าปล่อยให้ auto-unblock
คำถามที่ควรถามคือ ปลด IP อัตโนมัติมันอันตรายไหม คำตอบของผมคือกล้าปล่อยเพราะ gate มันแคบจริง ๆ ทุก IP ต้องผ่านด่านพวกนี้ครบถึงจะถูกปลด
| Gate | ตรวจอะไร | ผ่านยังไง |
|---|---|---|
| Account check | set_id 50 อันล่าสุดต้อง 100% @office-a.example | ถ้ามี @gmail.com / @yahoo.com ปนเข้ามา ให้คงบล็อก |
| Geo check | geoiplookup = TH เท่านั้น | non-TH คงบล็อก (= cred stuffing ต่างชาติ) |
| Fail-closed | geoiplookup ใช้ไม่ได้ ไม่ปลดอะไรเลย | sweep abort แทนการปลดมั่ว |
| Debounce | lockfile 5 วินาที | กัน CPU pin จาก CSF credscan flood |
| Whitelist 24h | csf -ta 86400 หลังปลด | กัน re-ban loop แต่สั้นพอที่พรุ่งนี้ออฟฟิศย้าย IP ก็ไม่ติดยาว |
| Sudoers per-section | ไม่ใช้ wildcard | bot compromise = blast radius เล็ก |
| Mail-only trigger | กรอง dovecot/exim ใน csf.deny | SSH brute / web brute ไม่เข้า pipeline นี้ |
ที่ต้องย้ำคือ service ตัวนี้ไม่ได้แก้ต้นเหตุ มันแค่บรรเทาอาการระหว่างที่ลูกค้าตามหาเครื่องผี การแก้ขาดจริง ๆ คือหา device ตัวนั้นเจอแล้ว reset รหัส หรือเลิกใช้ account นั้นไปเลย พอแก้ได้แล้วก็ถอด service ออก
ถอด service เมื่อแก้ ghost device ได้แล้ว
ขั้นตอนถอดทั้งหมด ทำตามลำดับ Layer
# Layer A
sed -i 's|^BLOCK_REPORT = .*|BLOCK_REPORT = ""|' /etc/csf/csf.conf
systemctl restart lfd
rm -f /usr/local/sbin/csf-cond-unblock.sh
# Layer B
systemctl disable --now csf-office-sweep.path
rm -f /etc/systemd/system/csf-office-sweep.path
rm -f /etc/systemd/system/csf-office-sweep.service
rm -rf /etc/systemd/system/csf-office-sweep.service.d
systemctl daemon-reload
rm -f /usr/local/sbin/csf-office-sweep.sh
# Layer C
rm -f /etc/cron.d/csf-office-sweep
# Layer D
rm -f /usr/local/sbin/caps-analyze.sh
# ลบ 5 บรรทัดใน /etc/sudoers.d/botuser-phase1 (ใช้ visudo)
# ลบ caps command ใน bot config + restart bot
ทุกอย่าง reversible เครื่องกลับมาสะอาดเหมือนไม่เคยมี service นี้อยู่
สรุป
Pattern ที่ใช้ในเคสนี้คือ ปลดอัตโนมัติแต่มี gate แคบมาก เหมาะกับเคสที่มีลักษณะแบบนี้
- ลูกค้าใช้ ISP ที่ IP หมุน (PPPoE / DHCP / mobile-LTE / 4G/5G)
- มี ghost device หรือ stale credential ในออฟฟิศที่หาตัวยาก
- ลูกค้าไม่มี IT in-house จะไปเซอร์วิสก็ไม่ได้หรือไม่คุ้ม
- คุณต้องไม่เผลอไปปลด attacker จริงพร้อมกัน
ถ้าจะ adapt ไปใช้กับเคสตัวเอง
- เปลี่ยน account match pattern จาก @office-a.example เป็นโดเมนลูกค้าคุณ
- เปลี่ยน geo gate ถ้าออฟฟิศเดินทางบ่อย เช่น TH+SG หรือ TH+US แต่ต้องรู้ว่าขยาย scope = ลดความปลอดภัยลง
- ถ้าไม่ใช่ mail brute ก็เปลี่ยน trigger source จาก dovecot/exim เป็น sshd/ftp/http ตามเคส
- สิ่งที่ต้องคงไว้ไม่แตะคือ fail-closed, debounce, whitelist สั้น และ sudoers explicit per-command
ตัวสคริปต์ทั้งหมดข้างบนเป็นของจริงที่รันอยู่ตอนเขียน ผม sanitize แค่ชื่อ IP โดเมน เอาไปแกะดู structure แล้วปรับให้เข้ากับงานตัวเองได้เลย ดีกว่าเขียนใหม่ตั้งแต่ศูนย์ ตามสะดวก หลักการเหมือนกันหมด
อยากให้เราทำให้ไหม?
ให้เราช่วยติดตั้ง ตั้งค่า ย้ายระบบ หรือแก้ปัญหาเซิร์ฟเวอร์ เพื่อให้คุณมีเวลาไปทำงานที่สำคัญกว่า
เคยเจองานแบบนี้ไหม? ติดตรงไหน?
มาแลกวิธีกันต่อในบอร์ด LinuxThai



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