EST. 2026 — บันทึกงานเซิร์ฟเวอร์จริง SYSADMIN · SECURITY · AI · DIRECTADMIN เล่มที่ 01
THE SYSADMIN ALMANAC
หน้าหลัก / Security / เปลี่ยนไฟร์วอลล์ผ่าน SSH แล้วล็อกตัวเอง: rollback แบบ flush คือกับดัก
Security

เปลี่ยนไฟร์วอลล์ผ่าน SSH แล้วล็อกตัวเอง: rollback แบบ flush คือกับดัก

โดย Ton-Or ·3 พ.ย. 2026·อ่าน 12 นาที
เปลี่ยนไฟร์วอลล์ผ่าน SSH แล้วล็อกตัวเอง: rollback แบบ flush คือกับดัก

ตัวย้อนกลับที่เขียนมาเพื่อกันพลาดอาจเป็นช่องโหว่เสียเอง ถ้ามันย้อนไปยังสถานะที่แย่กว่าสถานะที่เรากลัวจะเข้าไป

สรุปใน 1 นาที

อาการ/ปัญหา
ตัวย้อนกลับ 180 วินาทีของสคริปต์ push ไฟร์วอลล์ใช้ nft flush ruleset ซึ่งอาจทิ้งเครื่องไว้ไม่มีไฟร์วอลล์
ใครควรอ่าน
คนที่ push กฎ nftables ไป VPS ผ่าน SSH
ทางแก้แบบสั้น
ย้อนกลับไปยังกฎชุดเดิมที่ใช้ได้แทนการ flush ทั้งหมด และทดสอบตัวย้อนกลับด้วยการทำผิดโดยตั้งใจ
ข้ามไปขั้นตอน

พอดีผมมี VPS สองตัวที่ทำหน้าที่ฝั่งเว็บของลูกค้ารายหนึ่ง ต้องเปลี่ยนไปใช้ไฟร์วอลล์ nftables แบบ policy drop และย้ายพอร์ต SSH ไปด้วยพร้อมกัน การเปลี่ยนไฟร์วอลล์ผ่าน SSH เป็นงานเสี่ยงที่สุดอย่างหนึ่งที่ผมเคยทำ เพราะผิดครั้งเดียวคือล็อกตัวเองออกจากเครื่องที่อยู่ห่างไกล ผมเลยเขียนสคริปต์ apply_firewall.sh รอบแนวคิด "ทางหนีทีไล่" ก่อนอื่นเลย คือมี timer ย้อนกลับ 180 วินาทีคอยกู้ แต่ตอนรีวิวความปลอดภัยของสคริปต์รุ่นแรกเอง ผมเจอว่าตัวกู้ภัยนั้นมีกับดักอยู่ในตัว ซึ่งเป็นเรื่องที่ผมอยากเล่าให้ครบ

ทดลองบนเครื่องทดลองก่อนเสมอ

ชื่อโฮสต์ ที่อยู่ IP พอร์ต และกฎในบทความนี้เป็นตัวอย่างสมมติทั้งหมด (192.0.2.x, 198.51.100.x, พอร์ต SSH 2200) ใช้เป็นแนวคิดได้ แต่การแตะไฟร์วอลล์ของเครื่องจริงผ่านเครือข่ายเป็นงานที่พลาดแล้วเข้าเครื่องไม่ได้ ให้ลองบนเครื่องทดลองที่เข้าถึงทางอื่นได้ก่อนเสมอ ส่วนเครื่องที่ไม่ใช่ของตัวเองให้ถามเจ้าของก่อนลงมือ

ทำไมการเปลี่ยนไฟร์วอลล์ผ่าน SSH ถึงเสี่ยงที่สุด

เหตุผลง่ายมาก session SSH ที่ผมใช้พิมพ์คำสั่งอยู่คือเส้นทางเดียวที่เข้าเครื่องได้ พอกฎใหม่ policy drop ถูกโหลด ถ้าในกฎไม่มีทางให้ session ผมผ่าน (เช่นลืมใส่ที่อยู่ผมใน whitelist หรือย้ายพอร์ต SSH แล้วกฎยังชี้พอร์ตเก่า) session ที่ต่ออยู่จะยังไม่ขาดทันทีเพราะมี rule ของ established แต่ connection ใหม่จะเข้าไม่ได้อีก พอ session นี้หลุด ผมก็หมดทางกลับ

ผมเลยออกแบบให้สคริปต์ทำเป็นลำดับ โดยทุกขั้นก่อนเปลี่ยนจริงต้องมีทางถอยเสมอ

  1. scp กฎใหม่ขึ้นไปเป็นไฟล์ nftables.conf.new ยังไม่แตะของจริง
  2. เขียน sshd drop-in (พอร์ต keys-only ปิด forwarding MaxAuthTries 4) แล้ว sshd -t ก่อน reload
  3. เขียน fail2ban jail.local โดย ignoreip มาจากไฟล์ whitelist ไฟล์เดียวกับไฟร์วอลล์ จะแบนตัวเองไม่ได้
  4. nft -c -f ตรวจ syntax ของไฟล์ใหม่โดยไม่ใช้จริง
  5. สำรองไฟล์ปัจจุบันเป็น nftables.conf.bak
  6. systemd-run ตั้ง timer 180 วินาทีให้คืนไฟล์ .bak
  7. mv ไฟล์ใหม่เข้าที่ แล้ว nft -f ใช้งาน
  8. จากเครื่องแอดมิน เปิด SSH session ใหม่ บนพอร์ตใหม่
  9. เฉพาะเมื่อผ่านเท่านั้น ถึงจะหยุด timer, enable nftables และ restart fail2ban
สะพานเชือกที่มีตาข่ายนิรภัยรองด้านล่างเขียนว่ากฎก่อนหน้า และมีตาข่ายอีกผืนที่ขาดเป็นรูเขียนว่า flush
ตาข่ายรองสะพานต้องจับได้จริง ตาข่ายที่เป็นรูไม่ช่วยอะไร และบางครั้งแย่กว่าไม่มี เพราะคนข้ามสะพานเชื่อว่ามีของรอง

ลำดับในสคริปต์ จากฝั่งเครื่องแอดมิน

ส่วนที่ลงมือจริงเป็น bash ที่รันจากเครื่องแอดมิน ผมย่อและเปลี่ยนชื่อเป็นตัวอย่างให้อ่านง่าย เนื้อสำคัญอยู่ที่ลำดับ

#!/bin/bash
# apply_firewall.sh  รันจากเครื่องแอดมิน  (host-a คือ alias ใน ~/.ssh/config)
set -euo pipefail
HOST=host-a
NEW_PORT=2200

scp nftables.conf "$HOST:/etc/nftables.conf.new"          # 1

ssh "$HOST" 'bash -s' <<'REMOTE'
set -e
# 2 sshd drop-in แล้วทดสอบก่อน reload
cat > /etc/ssh/sshd_config.d/10-hardening.conf <<'EOF'
Port 2200
PasswordAuthentication no
AllowTcpForwarding no
X11Forwarding no
MaxAuthTries 4
EOF
sshd -t && systemctl reload ssh

# 4 ตรวจ syntax ก่อนของจริง
nft -c -f /etc/nftables.conf.new

# 5 สำรองไฟล์ปัจจุบัน (ถ้ามี)
[ -f /etc/nftables.conf ] && cp -a /etc/nftables.conf /etc/nftables.conf.bak

# 6 ตัวย้อนกลับ 180 วินาที
systemd-run --quiet --unit=fw-rollback --on-active=180 /bin/sh -c \
 'if [ -f /etc/nftables.conf.bak ]; then cp -f /etc/nftables.conf.bak /etc/nftables.conf; nft -f /etc/nftables.conf; else nft flush ruleset; fi'

# 7 ใช้กฎใหม่
mv -f /etc/nftables.conf.new /etc/nftables.conf
nft -f /etc/nftables.conf
REMOTE

# 8 session ใหม่ บนพอร์ตใหม่ ต่อจริงด้วย key ไม่ reuse connection เดิม
if ssh -p "$NEW_PORT" -o BatchMode=yes -o ControlPath=none -o ConnectTimeout=10 "$HOST" true; then
  # 9 ผ่านแล้วเท่านั้น ถึงยกเลิกตัวย้อนกลับ
  ssh -p "$NEW_PORT" "$HOST" 'systemctl stop fw-rollback.timer && systemctl enable nftables && systemctl restart fail2ban'
  echo '[OK] applied, rollback timer stopped'
else
  echo '[ERR] new session failed: leave the timer running, it restores the old rules in <=180s'
  exit 1
fi

มีข้อควรจำสองข้อ ข้อแรก ถ้า ssh config ของใครเปิด ControlMaster ไว้ ต้องใส่ ControlPath=none ตอนทดสอบ session ใหม่ ไม่งั้น "session ใหม่" จะวิ่งซ้อนบนการเชื่อมต่อเดิม ซึ่งไม่ได้พิสูจน์อะไรเลย ข้อสอง ขั้นที่ 8 ผมใช้ key ล้วนกับพอร์ตใหม่ ถ้ามันต่อได้แปลว่ากฎใหม่ปล่อย connection ใหม่เข้ามาจริง ไม่ใช่แค่ session เก่าที่ยังค้างอยู่

ทางหนีต้องมาก่อน แล้วค่อยเปลี่ยนจริงตรวจ syntaxnft -c -fสำรองไฟล์nftables.conf.bakตั้ง timerคืนกฎใน 180 sใช้กฎใหม่nft -fsession ใหม่พอร์ตใหม่ผ่าน หยุด timerenable + restart fail2banล้ม timer ทำงานคืนกฎเดิมจาก .bakทุกขั้นต้องจบก่อน 180 วินาที ไม่งั้น timer คืนกฎเดิมให้เอง0 s180 s
ตัวย้อนกลับถูกตั้งก่อนใช้กฎใหม่เสมอ และจะถูกยกเลิกก็ต่อเมื่อ session ใหม่ต่อได้จริง

กับดักของรุ่นแรก: ย้อนกลับด้วย flush ruleset

นี่คือเรื่องหลักของบทความ รุ่นแรกที่ผมเขียน ตัวย้อนกลับคือ nft flush ruleset ล้วนๆ ความคิดตอนนั้นคือ "ถ้ากฎใหม่ผิดแล้วผมถูกล็อกออก ก็ล้างกฎทิ้งให้เข้าได้อีกครั้ง" ฟังดูสมเหตุสมผล และถ้ามองแค่ว่าผมเข้าเครื่องได้ไหม มันก็ตอบโจทย์ ปัญหาคือมันเข้าได้เพราะ ทุกคนเข้าได้

nftables ที่ไม่มี chain เลยหมายถึงไม่มีกฎ ไม่มี policy drop ทุกแพ็กเก็ตผ่าน พูดง่ายๆ คือ timer ที่ผมเขียนมาเพื่อกู้ตัวเอง จะ "ล้างไฟร์วอลล์ทั้งหมด" ทิ้งเครื่องให้เปิดโล่งทุกพอร์ตในจังหวะที่ผมเข้าเครื่องไม่ได้พอดี และอาจไม่มีใครรู้ด้วยว่าเครื่องกำลังเปิดโล่งอยู่ เพราะผมคิดว่าตัวเองแค่ล็อกตัวเองออก ผมตรวจเจอตอนรีวิวความปลอดภัยของสคริปต์

รุ่นแรก

ย้อนกลับ = nft flush ruleset

เข้าเครื่องได้อีกครั้ง แต่เพราะไม่มีไฟร์วอลล์เหลือ policy กลายเป็น accept ทุกพอร์ตเปิดให้ทั้งอินเทอร์เน็ตในช่วงที่แอดมินยังแก้ไม่ได้ ผลของการผิดกลายเป็นแย่กว่าตอนที่ยังไม่ได้เริ่ม

รุ่นที่แก้

ย้อนกลับ = คืนไฟล์เดิม แล้ว nft -f

กลับไปกฎที่ใช้อยู่ก่อนเปลี่ยน policy drop ยังอยู่ เข้าเครื่องได้เหมือนก่อนเริ่ม ส่วน flush จะใช้เฉพาะเมื่อไม่เคยมีไฟล์เดิมเลย ซึ่งสถานะก่อนเริ่มก็คือไม่มีไฟร์วอลล์อยู่แล้ว

รุ่นแรก ย้อนด้วย flushรุ่นแก้ คืนไฟล์เดิมกฎใหม่ผิด ล็อกตัวเองออกtimer ครบ 180 วินาทีnft flush rulesetไม่มี chain accept ทุกพอร์ตกฎใหม่ผิด ล็อกตัวเองออกtimer ครบ 180 วินาทีcp .bak + nft -fกลับกฎเดิม policy drop ยังอยู่รุ่นแก้ flush เฉพาะเมื่อไม่เคยมีไฟล์เดิมมาก่อน
ตัวย้อนกลับที่ดีต้องพากลับไปสถานะที่ปลอดภัยเท่ากับก่อนเริ่ม ไม่ใช่สถานะที่เข้าเครื่องได้ง่ายที่สุด

ส่วนตัวผมสรุปว่ากฎของทางหนีมีข้อเดียว คือ ต้องพากลับไปที่สถานะที่เคยปลอดภัย ไม่ใช่สถานะที่เข้าง่าย และต้องคิดจากมุมคนนอกที่ไม่รู้ว่าเรากำลังล็อกตัวเองอยู่ ตามใจชอบว่าจะเรียกว่ากฎอะไรก็ได้ แต่ผมเรียกว่า "ย้อนกลับต้องไม่แย่กว่าไม่ทำ"

อีกข้อที่ต้องบอกไว้ด้วยเพื่อไม่ให้ใครสับสน รุ่นแก้คืน "ไฟล์" ที่สำรองไว้ ซึ่งสมมติว่ากฎที่รันอยู่ก่อนเริ่มตรงกับไฟล์นั้น ถ้าใครเคยแก้กฎสดด้วยมือโดยไม่ได้เซฟลงไฟล์ ตัวย้อนกลับจะไม่ได้พากลับไปที่กฎสดนั้น ให้เก็บกฎสดลงไฟล์ก่อนใช้สคริปต์แบบนี้

flush ruleset ในไฟล์กฎ ไม่ใช่ตัวปัญหา

สังเกตว่าไฟล์ nftables.conf ที่สร้างขึ้นเอง ขึ้นต้นด้วย flush ruleset เหมือนกัน แต่อันนี้ไม่เป็นไร เหตุผลคือเวลาโหลดด้วย nft -f ทั้งไฟล์ทำงานเป็น transaction เดียว ถ้าไฟล์ผิดตรงไหน ทั้งไฟล์ไม่ถูกใช้ และกฎเก่ายังอยู่ครบ ต่างจาก flush ที่รันเดี่ยวๆ ใน timer ซึ่งไม่มีกฎอื่นตามมาเติม

กฎที่ผมสร้างจากรายการต้นทางด้วยสคริปต์ มีโครงเป็นแบบนี้ (ตัวอย่างสมมติ ไม่ใช่กฎจริง)

#!/usr/sbin/nft -f
flush ruleset

table inet fw {
  set wl4 { type ipv4_addr; flags interval; auto-merge; elements = { 203.0.113.10 } }
  set wl6 { type ipv6_addr; flags interval; auto-merge; }
  set web4 { type ipv4_addr; flags interval; auto-merge; }
  set web6 { type ipv6_addr; flags interval; auto-merge; }

  chain input {
    type filter hook input priority 0; policy drop;
    ct state established,related accept
    ct state invalid drop
    iif lo accept
    ip saddr @wl4 accept                 # whitelist เข้าได้ทุกพอร์ต
    ip6 saddr @wl6 accept
    meta l4proto { icmp, ipv6-icmp } accept
    tcp dport { 80, 443 } ip saddr @web4 accept     # เว็บ เฉพาะจาก set ที่อนุญาต
    udp dport 443 ip saddr @web4 accept
    limit rate 5/minute log prefix "fw-drop: "      # log การ drop แบบจำกัดอัตรา
  }
  chain forward { type filter hook forward priority 0; policy drop; }
  chain output  { type filter hook output priority 0; policy accept; }
}

ผมใช้ policy drop ที่ input, drop ที่ forward, และปล่อย output ตามปกติ ส่วน set ที่ใช้อนุญาตพอร์ตเว็บเฉพาะบางต้นทางผมแยกเล่าไว้ในเรื่อง set ที่อนุญาต CDN กับบอท ส่วนการสแกนระบบด้วยเครื่องมืออื่นดู debian-server-security-scan และหลักความปลอดภัยกว้างๆ ตอนให้ AI ทำงานผ่าน SSH อยู่ใน claude-ssh-safe ของผมเอง

ประตูที่ติดกุญแจใหม่ กำลังถูกลองไขจากด้านนอกด้วยกุญแจดอกที่สองก่อนจะทิ้งกุญแจดอกเก่า
ทดลองไขประตูใหม่จากด้านนอกด้วยกุญแจดอกที่สองให้ได้ก่อน ค่อยทิ้งกุญแจดอกเก่า นี่คือขั้น session ใหม่ในสคริปต์

fail2ban กับ whitelist ไฟล์เดียวกัน

เรื่องเล็กที่ช่วยชีวิต คือผมให้ ignoreip ของ fail2ban มาจากไฟล์ whitelist ไฟล์เดียวกับที่ใช้สร้างกฎไฟร์วอลล์ ถ้าแยกสองไฟล์ วันหนึ่งจะมีคนอัปเดตอันหนึ่งแล้วลืมอีกอัน แล้ว fail2ban แบนที่อยู่ที่ไฟร์วอลล์อนุญาต หรือกลับกัน ค่าที่ผมตั้งคือ bantime 1 ชั่วโมง findtime 10 นาที maxretry 5 action แบบ nftables backend เป็น systemd

# /etc/fail2ban/jail.local  (ตัวอย่าง ignoreip สร้างจากไฟล์ whitelist ตอน apply)
[DEFAULT]
bantime  = 1h
findtime = 10m
maxretry = 5
backend  = systemd
banaction = nftables
ignoreip = 127.0.0.1/8 ::1 203.0.113.10

[sshd]
enabled = true
port    = 2200

หลังเปลี่ยนที่วางแผนไว้ ผมสั่ง accept ค่าใหม่เข้า baseline ของ AIDE ด้วยสคริปต์ accept ตัวเดียว เพื่อให้รายงานรายวันไม่รกไปด้วยข้อแตกต่างที่ผมตั้งใจทำเอง ไม่งั้นพอมีของจริงผิดปกติจะหาไม่เจอ

ทดสอบตัวย้อนกลับด้วยการทำผิดโดยเจตนา

ผมไม่ไว้ใจตัวย้อนกลับที่ยังไม่เคยเห็นมันทำงานจริง วิธีทดสอบที่ผมใช้คือทำบนเครื่องทดลองก่อน ด้วยการตั้งกฎผิดโดยเจตนา คือบล็อกพอร์ต SSH แล้วจับเวลาดู 180 วินาทีให้ครบ สิ่งที่ต้องเห็นมีสองอย่างพร้อมกัน เข้าเครื่องได้อีกครั้ง และ nft list ruleset ยังแสดงกฎเดิมที่เป็น policy drop ไม่ใช่ว่างเปล่า

# บนเครื่องทดลอง หลังตั้งกฎผิดแล้วรอครบ 180 วินาที
nft list ruleset | head -n 20          # ต้องเห็น table inet fw และ policy drop ของกฎเดิม
systemctl list-timers 'fw-rollback*'  # timer ควรหายไปหลังทำงานเสร็จ
journalctl -u fw-rollback.service -n 20 --no-pager
เช็กลิสต์ก่อนใช้กับเครื่องจริง

ตัวย้อนกลับถูกตั้งก่อนใช้กฎใหม่, ตัวย้อนกลับคืนไฟล์เดิมไม่ใช่ flush, ทดสอบ session ใหม่ผ่านการเชื่อมต่อใหม่จริง, ยกเลิก timer เฉพาะเมื่อผ่าน, ทดสอบด้วยกฎผิดโดยเจตนาบนเครื่องทดลองแล้วเห็นกฎเดิมกลับมา, และ ignoreip ของ fail2ban มาจากไฟล์เดียวกับไฟร์วอลล์

ผู้ให้บริการ VPS บางเจ้ามีคอนโซลฉุกเฉินให้เข้าเครื่องทางอื่น แต่ผมไม่ถือเป็นแผนหลัก เพราะอาจไม่มีในทุกที่ และเวลาที่ต้องใช้มักเป็นเวลาที่ช้าที่สุด แผนหลักคือสคริปต์ที่ย้อนกลับเองได้โดยไม่ต้องมีคนมาช่วย

สรุปสิ่งที่ผมจะทำเหมือนเดิม

ถ้าต้องเปลี่ยนไฟร์วอลล์เครื่องไกลอีกครั้ง ผมจะทำตามนี้ ตรวจ syntax ก่อนทุกครั้ง สำรองของเดิม ตั้งตัวย้อนกลับก่อนแตะของจริง และให้ตัวย้อนกลับพากลับไปสถานะเดิม ไม่ใช่สถานะที่ว่างเปล่า จากนั้นพิสูจน์ด้วยการเปิด session ใหม่จริงๆ ก่อนจะยกเลิกตัวย้อนกลับ ส่วนเรื่อง flush ผมไม่ได้ห้ามใช้ทุกที่ ในไฟล์กฎที่โหลดทีเดียวทั้งไฟล์ใช้ได้ แต่ห้ามเอาไปเป็นคำสั่งเดี่ยวในตัวกู้ภัย ตามสะดวกของแต่ละที่ว่าจะเสริมอะไรเพิ่ม เช่น แจ้งเตือนทันทีเมื่อ timer ทำงาน ตรงไหนผมเขียนตกหรือกฎในเครื่องคุณต่างจากตัวอย่าง เดี๋ยวกลับมาแก้เพิ่มให้ภายหลังครับ

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

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

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

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

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

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

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

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