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

ลำดับในสคริปต์ จากฝั่งเครื่องแอดมิน
ส่วนที่ลงมือจริงเป็น 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 เก่าที่ยังค้างอยู่
กับดักของรุ่นแรก: ย้อนกลับด้วย flush ruleset
นี่คือเรื่องหลักของบทความ รุ่นแรกที่ผมเขียน ตัวย้อนกลับคือ nft flush ruleset ล้วนๆ ความคิดตอนนั้นคือ "ถ้ากฎใหม่ผิดแล้วผมถูกล็อกออก ก็ล้างกฎทิ้งให้เข้าได้อีกครั้ง" ฟังดูสมเหตุสมผล และถ้ามองแค่ว่าผมเข้าเครื่องได้ไหม มันก็ตอบโจทย์ ปัญหาคือมันเข้าได้เพราะ ทุกคนเข้าได้
nftables ที่ไม่มี chain เลยหมายถึงไม่มีกฎ ไม่มี policy drop ทุกแพ็กเก็ตผ่าน พูดง่ายๆ คือ timer ที่ผมเขียนมาเพื่อกู้ตัวเอง จะ "ล้างไฟร์วอลล์ทั้งหมด" ทิ้งเครื่องให้เปิดโล่งทุกพอร์ตในจังหวะที่ผมเข้าเครื่องไม่ได้พอดี และอาจไม่มีใครรู้ด้วยว่าเครื่องกำลังเปิดโล่งอยู่ เพราะผมคิดว่าตัวเองแค่ล็อกตัวเองออก ผมตรวจเจอตอนรีวิวความปลอดภัยของสคริปต์
ย้อนกลับ = nft flush ruleset
เข้าเครื่องได้อีกครั้ง แต่เพราะไม่มีไฟร์วอลล์เหลือ policy กลายเป็น accept ทุกพอร์ตเปิดให้ทั้งอินเทอร์เน็ตในช่วงที่แอดมินยังแก้ไม่ได้ ผลของการผิดกลายเป็นแย่กว่าตอนที่ยังไม่ได้เริ่ม
ย้อนกลับ = คืนไฟล์เดิม แล้ว 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 ของผมเอง

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
ยังไม่มีความคิดเห็น — เป็นคนแรกเลย