EST. 2026 — บันทึกงานเซิร์ฟเวอร์จริง SYSADMIN · SECURITY · AI · DIRECTADMIN เล่มที่ 01
THE SYSADMIN ALMANAC
หน้าหลัก / Security / ปลด CSF อัตโนมัติให้ออฟฟิศจริง แต่ไม่ปลดให้ attacker ด้วย geo+account gate
Security

ปลด CSF อัตโนมัติให้ออฟฟิศจริง แต่ไม่ปลดให้ attacker ด้วย geo+account gate

โดย Ton-Or ·4 มิ.ย. 2026·อ่าน 12 นาที
ปลด 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 + ลูกค้าฝั่งห่างไกล) ก๊อปไปปรับใช้ได้

Placeholder ที่ใช้ในบทความ

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 เดียวแล้วจบ แต่ซ้อนหลายชั้นเพราะแต่ละชั้นครอบเคสคนละแบบ ภาพรวมเป็นแบบนี้

unblock pipelineรหัสผิดexim auth fail->LFD / DA-BFMสั่งบล็อก IP->ABLOCK_REPORT hook->B.path watcher 5s->Ccron เช้า->Dmanual .caps

ไล่ทีละชั้น

  • 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

ด่านของทุก auto-layer ก่อนปลด IPIP โดนบล็อกLFD / DA-BFMaccount ครบ 100%?set_id ล่าสุด 50 อันเป็น @office-a.examplegeoiplookup = TH?ไม่ใช่ TH = ต่างชาติปลดบล็อกcsf -dr / csf -trcsf -ta 86400ใช่ใช่หยุดทั้งรอบไม่มี geoiplookupไม่ใช่คงบล็อกมี account อื่นปนอยู่ไม่ใช่คงบล็อกต่างชาติที่ brute credsต้องผ่านครบทุกด่าน ไม่ครบแม้ข้อเดียวคือปล่อยบล็อกไว้
ด่านที่ IP ต้องผ่านก่อนถูกปลด หลังปลดแล้ว whitelist กัน re-ban ไว้ 24 ชั่วโมง

ทำไมต้องมีหลาย Layer

หลายคนอาจถามว่าทำ Layer เดียวพอไหม คำตอบคือไม่พอ เพราะแต่ละชั้นมีรูที่อีกชั้นต้องมาอุด

Layer A — BLOCK_REPORT hook

ครอบ

  • LFD บล็อก (smtpauth/distattack/imapd)

ไม่ครอบ

  • DA-BFM ที่ใช้ csf -d ตรง ๆ
Layer B — .path watcher

ครอบ

  • ทุก write บน csf.deny / csf.tempban

ไม่ครอบ

  • กรณี systemd ตาย / inotify queue เต็ม
Layer C — cron 07:30-08:30

ครอบ

  • safety-net ช่วง office boot window

ไม่ครอบ

  • ระหว่างวัน (ให้ path watcher คุมแทน)
Layer D — manual .caps

ครอบ

  • ผมเช็คสดตอนแกะปัญหาด้วยมือ

ไม่ครอบ

  • ไม่ได้ออกแบบให้ปลดอัตโนมัติ

ขอเล่าเหตุผลของแต่ละชั้นทีละตัว

ทำไมต้องมี 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 ออกทีหลัง

บทเรียน GeoIP regex

เวลา 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 อีกชั้นเท่านั้น

Health check ที่ขาดไม่ได้

ตัว 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

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