ไฟร์วอลล์ขาออกทำระบบเช็คชื่อโดเมนพัง — root ทดสอบแล้วผ่าน
เคสนี้เป็นเคสที่ทุกคนในทีมทดสอบแล้วบอกว่าปกติ ping ได้ เปิดเว็บได้ ยิงพอร์ตทดสอบก็ผ่าน แต่แอปยังพังอยู่อย่างนั้นเป็นวัน ความต่างอยู่ที่คำเดียวคือเราทดสอบในนามใคร เพราะกฎไฟร์วอลล์ขาออกมันแยกตามเจ้าของ process ไม่ได้แยกตามเครื่อง
สรุปใน 1 นาที
- อาการ/ปัญหา
- หน้าสั่งซื้อโดเมนขึ้นว่าไม่ว่างทุกชื่อ หลังย้ายระบบปฏิบัติการ
- ใครควรอ่าน
- แอดมินที่คุมไฟร์วอลล์ขาออกแบบ DROP
- ทางแก้แบบสั้น
- เพิ่มพอร์ต 43 ของ whois ในลิสต์ที่อนุญาต และทดสอบในนามผู้ใช้ที่รันแอปจริง ไม่ใช่ root
พอดีมีระบบสั่งซื้อโดเมนตัวหนึ่งที่ผมดูแลอยู่ มีคนแจ้งว่าหน้าเช็คชื่อว่างใช้ไม่ได้แล้ว พิมพ์ชื่อไหนเข้าไปก็ขึ้นว่าไม่ว่างหมด ผมลองมั่วชื่อยาวๆ ที่ไม่มีทางมีใครจดแน่ๆ ก็ยังขึ้นว่าไม่ว่างอยู่ดี ซึ่งอาการแบบนี้อ่านออกได้ทันทีว่าไม่ใช่เรื่องข้อมูล แต่เป็นเรื่องที่การตรวจสอบมันไม่ได้ทำงานเลย แล้วโค้ดตีความว่าตรวจไม่ผ่านเท่ากับไม่ว่าง
ก่อนจะเข้าเรื่อง ผมอยากแยกให้ชัดก่อนว่าบทความนี้ไม่ใช่เรื่องเดียวกับที่ผมเคยเขียนเรื่องไฟร์วอลล์ไปสามตอน สามตอนนั้นเป็นเรื่องขาเข้าทั้งหมด คือใครจะเข้ามาหาเราได้บ้าง บล็อก brute force ปลดบล็อกออฟฟิศ คุม GeoIP ส่วนเล่มนี้เป็นขาออก คือเครื่องเราออกไปหาใครได้บ้าง ซึ่งเป็นคนละชุดกฎ คนละไฟล์ตั้งค่า และคนละวิธีตรวจโดยสิ้นเชิง
อาการดังทันที
ปิดพอร์ตผิดตัวแล้วรู้เลยภายในนาที เพราะมีคนเข้าเว็บไม่ได้ เข้า SSH ไม่ได้ มีเสียงเรียกเข้ามาทันที ผลกระทบชัด หาเจอเร็ว
อาการเงียบและหลอก
เว็บยังขึ้น SSH ยังเข้าได้ ทุกอย่างดูปกติหมด พังแค่ฟีเจอร์เดียวที่ต้องต่อออกไปข้างนอก และมักพังแบบ "ได้ผลลัพธ์ผิด" ไม่ใช่ "ขึ้น error" ซึ่งหาเจอยากกว่ากันมาก
ค่าทั้งหมดในบทความนี้ผมเปลี่ยนเป็นค่าตัวอย่างแล้ว ทั้งชื่อผู้ใช้ ชื่อโฮสต์ และหมายเลขปลายทาง ส่วนลำดับการไล่หาสาเหตุคือของจริง
อาการ: ทุกชื่อขึ้นว่าไม่ว่าง
สิ่งแรกที่ผมทำคือแยกให้ออกว่าเป็นที่ข้อมูลหรือเป็นที่การเชื่อมต่อ วิธีที่เร็วที่สุดคือหาชื่อที่รู้แน่ว่าว่าง กับชื่อที่รู้แน่ว่าไม่ว่าง แล้วดูว่าระบบตอบต่างกันไหม
ผลคือตอบเหมือนกันทั้งคู่ นั่นแปลว่าโค้ดไม่เคยได้คำตอบจากปลายทางเลยสักครั้ง มันแค่ตกลงมาที่ค่าตั้งต้น ซึ่งในระบบนี้ค่าตั้งต้นคือ "ไม่ว่าง" เพราะคนออกแบบคิดว่าปลอดภัยกว่าที่จะบอกว่าไม่ว่างแล้วลูกค้าไม่ได้สั่ง มากกว่าบอกว่าว่างแล้วลูกค้าสั่งไปโดยที่จดไม่ได้จริง
การเช็คชื่อโดเมนวิ่งพอร์ตอะไร และวิ่งในนามใคร
คำถามสองข้อนี้ต้องตอบให้ได้ก่อนจะไปแตะอะไร เพราะถ้าตอบผิดข้อใดข้อหนึ่ง การทดสอบทั้งหมดหลังจากนั้นจะให้ผลลวง
ข้อแรก การเช็คว่าชื่อโดเมนว่างหรือไม่ ในระบบสั่งซื้อส่วนใหญ่ทำผ่านโพรโทคอลเก่าแก่ที่วิ่งบน TCP พอร์ต 43 ไม่ใช่ 80 ไม่ใช่ 443 คนส่วนใหญ่คิดว่ามันเป็น API ผ่าน HTTPS เพราะสมัยนี้อะไรก็วิ่ง 443 กันหมด เลยไม่มีใครไปตรวจพอร์ตนั้น
ข้อสอง โค้ดที่เปิดซ็อกเก็ตนั้นรันด้วยผู้ใช้ของ php-fpm pool ไม่ใช่ root ไม่ใช่ผู้ใช้ที่เรา SSH เข้าไป
# หาให้แน่ว่าใครเป็นเจ้าของ process ที่รันโค้ดนี้จริง
ps -o user:16,pid,cmd -C php-fpm8.2 | head
# หรือถามจาก pool config ตรงๆ
grep -E '^(user|group)' /etc/php/8.2/fpm/pool.d/app.conf
กับดัก: ทดสอบด้วย root แล้วผ่าน
นี่คือหัวใจของบทความ ถ้าจะอ่านหัวข้อเดียวให้อ่านอันนี้
ไฟร์วอลล์บนลินุกซ์แยกกฎตามเจ้าของ process ได้ ผ่านโมดูลที่จับ uid ของคนที่เปิดซ็อกเก็ต และชุดกฎสำเร็จรูปส่วนใหญ่จะใส่ทางลัดให้ root ไว้ในเชนย่อยต่างหาก เหตุผลก็เข้าใจได้ เพราะเครื่องมือของระบบเองต้องออกไปดึงของได้ตลอด ไม่งั้นอัปเดตไม่ได้
ผลคือเวลาเรา SSH เข้าไปแล้วทดสอบด้วย root ทุกอย่างจะผ่านหมด ต่อพอร์ตอะไรก็ได้ ซึ่งไม่ได้พิสูจน์อะไรเลยเกี่ยวกับ process ที่รันแอปจริง
# ผ่าน — และไม่มีความหมายอะไรทั้งสิ้น
exec 3<>/dev/tcp/203.0.113.43/43 && echo OK && exec 3<&-
ทุกครั้งที่ตรวจเรื่องการเชื่อมต่อขาออก ให้ถามตัวเองก่อนว่าโค้ดที่พังอยู่นั้นรันด้วยผู้ใช้ชื่ออะไร แล้วทดสอบในนามคนนั้นเท่านั้น การทดสอบด้วย root ในเรื่องนี้อันตรายกว่าการไม่ทดสอบเลย เพราะมันให้ผล OK ที่ทำให้เราตัดสาเหตุที่ถูกต้องทิ้งไปจากรายการ แล้วเสียเวลาไปไล่หาที่อื่นอีกหลายชั่วโมง กับดักตัวเดียวกันนี้เจอได้ในเรื่องสิทธิ์ไฟล์เหมือนกัน root ข้าม ACL ทุกอย่างเช่นกัน
การทดสอบที่ตัดสินได้
วิธีที่ผมใช้คือทดสอบสองขาเทียบกัน ในนามผู้ใช้เดียวกัน ไปยังปลายทางที่ต่างกันแค่หมายเลขพอร์ต ถ้าขาหนึ่งผ่านและอีกขาไม่ผ่าน แปลว่าไม่ใช่เรื่องเส้นทางเครือข่ายหรือ DNS แต่เป็นเรื่องกฎที่คัดพอร์ต
# ขาอ้างอิง — พอร์ตที่รู้ว่าอนุญาตอยู่แล้ว
sudo -u appuser bash -c 'exec 3<>/dev/tcp/203.0.113.43/443 && echo "[OK] 443"'
# ขาที่สงสัย — พอร์ตเดียวกับที่แอปใช้ ปลายทางเดียวกันเป๊ะ
sudo -u appuser bash -c 'exec 3<>/dev/tcp/203.0.113.43/43 && echo "[OK] 43"'
# bash: connect: Connection refused
พอเห็นแบบนี้ก็จบเรื่องแล้ว ปลายทางเดียวกัน ผู้ใช้เดียวกัน เวลาเดียวกัน ต่างกันแค่หมายเลขพอร์ต ตัวแปรเหลือตัวเดียวคือกฎที่คัดพอร์ต
ถ้าอยากยืนยันถึงชั้นแอปจริงๆ ว่าไม่ใช่เรื่องของ shell ก็ยิงจากตัวแปลภาษาเดียวกับที่แอปใช้ไปเลย
<?php
// รันด้วย: sudo -u appuser php probe.php
$h = @fsockopen('whois.registry.example', 43, $errno, $errstr, 5);
if (! $h) {
fwrite(STDERR, "[ERR] 43 -> $errno $errstr\n");
exit(1);
}
fwrite($h, "example-name-that-nobody-owns.test\r\n");
echo substr(stream_get_contents($h), 0, 200), "\n";
fclose($h);
ผมเคยเสียเวลาเพราะยืนยันด้วยเครื่องมือบรรทัดคำสั่งตัวหนึ่งที่ติดตั้งมากับระบบ แล้วมันดันมี fallback ไปวิ่งผ่านเว็บให้อัตโนมัติเมื่อต่อพอร์ตตรงไม่ได้ ผลเลยออกมาว่าใช้ได้ ทั้งที่พอร์ต 43 ตันสนิท เครื่องมือที่ฉลาดเกินไปคือเครื่องมือที่ใช้ยืนยันไม่ได้ ให้ยิงซ็อกเก็ตดิบ หรือยิงจากตัวแปลภาษาเดียวกับแอปเท่านั้น
อ่านข้อความ error ให้ออกว่าใครเป็นคนปฏิเสธ
ข้อความที่ได้ตอนต่อไม่ติดบอกอะไรได้เยอะกว่าที่คิด ผมสรุปที่เจอบ่อยไว้แบบนี้
จุดที่หลอกคนมากที่สุดคือความต่างระหว่าง IPv4 กับ IPv6 บนเครื่องเดียวกัน ถ้าลิสต์พอร์ตขาออกมีสองชุดแยกกันตามตระกูลที่อยู่ แล้วเราไปเพิ่มพอร์ตในชุดเดียว อาการจะกลายเป็นว่าบางครั้งผ่านบางครั้งไม่ผ่าน ขึ้นอยู่กับว่าตัวแปลชื่อคืนที่อยู่ตระกูลไหนมาให้ก่อนในรอบนั้น
# บังคับทดสอบทีละตระกูลไปเลย จะได้ไม่ต้องเดา
sudo -u appuser bash -c 'exec 3<>/dev/tcp/203.0.113.43/43 && echo "[OK] v4"'
sudo -u appuser bash -c 'exec 3<>/dev/tcp/2001:db8::2b/43 && echo "[OK] v6"'
# v4: Connection refused v6: Network is unreachable
ข้อความสองอันนี้มาจากคนละสาเหตุกัน อันแรกคือมีกฎปฏิเสธ อันหลังคือเครื่องไม่มีเส้นทางออกสำหรับตระกูลนั้นเลย ถ้าอ่านผ่านๆ แล้วเหมาว่าเป็นอย่างเดียวกัน จะแก้ผิดที่
แก้ที่ลิสต์พอร์ตขาออก ทั้งสองตระกูล
พอรู้แล้วว่าเป็นเรื่องกฎขาออก ที่เหลือคือไปเพิ่มพอร์ตในลิสต์ ไฟล์ตั้งค่าของชุดกฎสำเร็จรูปที่ผมใช้มีสองบรรทัดที่เกี่ยวข้อง แยกตามตระกูลที่อยู่
# /etc/csf/csf.conf — ลิสต์พอร์ตขาออกที่อนุญาต
TCP_OUT = "20,21,22,25,43,53,80,110,113,143,443,465,587,853,993,995,2222"
TCP6_OUT = "20,21,22,25,43,53,80,110,113,143,443,465,587,853,993,995,2222"
ก่อนแก้ต้องสำรองไว้ก่อนเสมอ เพราะไฟล์นี้พลาดแล้วล็อกตัวเองออกจากเครื่องได้
ts=$(date +%Y%m%d-%H%M%S)
sudo install -d -m 0700 "/root/csf-backup-$ts"
sudo cp -a /etc/csf/csf.conf "/root/csf-backup-$ts/csf.conf"
sudo sed -ri 's/^(TCP6?_OUT\s*=\s*")([^"]*)"/\1\2,43"/' /etc/csf/csf.conf
grep -E '^TCP6?_OUT' /etc/csf/csf.conf # อ่านด้วยตาก่อน อย่าเพิ่งรีโหลด
sudo csf -r
# ย้อนกลับถ้าพลาด
# sudo cp -a "/root/csf-backup-$ts/csf.conf" /etc/csf/csf.conf && sudo csf -r
หลังรีโหลดแล้วให้กลับไปรันการทดสอบชุดเดิมซ้ำ ในนามผู้ใช้เดิม ต้องได้ OK ทั้งสองขา แล้วค่อยไปกดใช้งานผ่านหน้าเว็บจริงเป็นขั้นสุดท้าย
ทำไมมันหายไปตอนย้ายเครื่อง
คำถามที่ตามมาคือของแบบนี้เคยทำงานได้มาหลายปี แล้วมันหายไปตอนไหน คำตอบคือตอนย้ายระบบปฏิบัติการครับ
ตอนย้ายเครื่อง คนย้ายจะโฟกัสกับสิ่งที่เห็นได้ทันทีว่าพัง คือเว็บขึ้นไหม เมลเข้าออกไหม ฐานข้อมูลต่อติดไหม ส่วนไฟล์ตั้งค่าของไฟร์วอลล์มักถูกติดตั้งใหม่จากค่าเริ่มต้นของเวอร์ชันใหม่ แล้วค่อยเติมกฎขาเข้าที่จำเป็นกลับไป เพราะกฎขาเข้าเป็นของที่มีคนร้องทันทีถ้าขาด
ส่วนลิสต์พอร์ตขาออก ค่าเริ่มต้นของแต่ละเวอร์ชันไม่เท่ากัน และพอร์ตแปลกๆ ที่เราเคยเติมเข้าไปเองเมื่อสามปีก่อนก็หายไปเงียบๆ โดยไม่มีใครรู้ จนกว่าจะมีลูกค้ามาบอกว่าฟีเจอร์หนึ่งใช้ไม่ได้
ของที่พังตอนย้ายเครื่องแล้วไม่มีใครร้องทันที มักเป็นฟีเจอร์ที่คนใช้นานๆ ครั้ง หรือฟีเจอร์ที่พังแบบยังตอบอะไรกลับไปได้อยู่ ในเคสนี้คือตอบว่า "ไม่ว่าง" ซึ่งเป็นคำตอบที่ดูสมเหตุสมผลจนไม่มีใครสงสัย กว่าจะรู้ตัวก็ผ่านไปหลายวัน ทุกจุดที่ผลลัพธ์ของการพังหน้าตาเหมือนผลลัพธ์ปกติ ต้องมีตัวเฝ้าแยกต่างหาก
รายการตรวจขาออกหลังย้ายเครื่องทุกครั้ง
ผมเลยทำรายการนี้ไว้ให้รันหลังย้ายเครื่องเสร็จ ก่อนจะประกาศว่าย้ายเรียบร้อย
- ลิสต์ทุกฟีเจอร์ในระบบที่ต้องต่อออกไปข้างนอก แล้วเขียนคู่กับหมายเลขพอร์ตที่มันใช้จริง ไม่ใช่พอร์ตที่เราคิดว่ามันใช้ — ข้อนี้ต้องเปิดโค้ดหรือดักดูของจริง
- เทียบลิสต์นั้นกับลิสต์พอร์ตขาออกในไฟล์ตั้งค่า ทั้งตระกูล v4 และ v6 ต้องตรงกันทั้งคู่
- ทดสอบทุกพอร์ตในรายการ ในนามผู้ใช้ที่รันแอปจริง ห้ามทดสอบด้วย root ห้ามทดสอบด้วยผู้ใช้ที่เรา SSH เข้ามา
- ตรวจว่าโค้ดฝั่งแอปแยก "ต่อไม่ติด" ออกจาก "ปลายทางตอบว่าไม่มี" ได้ ถ้าแยกไม่ได้ให้ไปแก้ตรงนั้นก่อนเป็นอันดับแรก
- ตั้งตัวเฝ้าที่ยิงทดสอบจริงเป็นรอบ แล้วเตือนเมื่อผลเปลี่ยน ไม่ใช่แค่เฝ้าว่าเว็บตอบ 200
อย่าให้ "ต่อไม่ติด" แปลว่า "ไม่ว่าง"
ปิดท้ายด้วยเรื่องที่ผมคิดว่าสำคัญกว่าตัวพอร์ตเสียอีก เพราะต่อให้วันนี้แก้พอร์ตเสร็จ วันหน้าปลายทางล่มเองก็จะเจออาการเดิมอีก
รากของปัญหาคือโค้ดเอาความล้มเหลวของการเชื่อมต่อไปรวมกับผลลัพธ์ทางธุรกิจ ซึ่งเป็นคนละเรื่องกันโดยสิ้นเชิง ผลลัพธ์ของการเช็คชื่อควรมีสามค่า ไม่ใช่สอง
enum NameStatus {
case Available; // ปลายทางตอบว่าไม่มีข้อมูล
case Taken; // ปลายทางตอบว่ามีข้อมูลแล้ว
case Unknown; // เช็คไม่สำเร็จ — ห้ามแปลเป็น Taken เด็ดขาด
}
// ฝั่งหน้าเว็บ
match ($status) {
NameStatus::Available => 'ชื่อนี้ว่าง สั่งซื้อได้',
NameStatus::Taken => 'ชื่อนี้มีผู้ใช้แล้ว',
NameStatus::Unknown => 'ตรวจสอบไม่สำเร็จ กรุณาลองใหม่อีกครั้ง',
};
ค่าที่สามนี่แหละที่ทำให้ปัญหาโผล่ขึ้นมาให้เห็นภายในนาทีแรกแทนที่จะซ่อนอยู่เป็นวัน และมันยังช่วยฝั่งธุรกิจด้วย เพราะการบอกลูกค้าว่า "ตรวจไม่สำเร็จ ลองใหม่" ยังพอรับได้ แต่การบอกว่าชื่อไม่ว่างทั้งที่จริงๆ ว่าง คือการไล่ลูกค้าออกจากหน้าจ่ายเงินไปเฉยๆ โดยที่เราไม่มีวันรู้ว่าเสียไปกี่ราย
ส่วนตัวผมมองว่านโยบายขาออกแบบปิดหมดแล้วเปิดเป็นรายพอร์ตยังเป็นค่าที่ถูกต้องอยู่ ไม่ควรเปลี่ยนไปเปิดหมดเพราะเจอเคสนี้ครั้งเดียว แค่ต้องยอมรับว่ามันมีต้นทุน คือทุกครั้งที่เพิ่มฟีเจอร์ที่ต่อออกนอก หรือทุกครั้งที่ย้ายเครื่อง ต้องมีคนไปอัปเดตลิสต์ ใครจะเลือกทางไหนก็ตามสะดวกครับ แต่ถ้าเลือกทางปิด ขอให้มีรายการตรวจตามที่เล่าไว้ข้างบนด้วย ไม่งั้นจะเจอแบบเดียวกันนี้อีกแน่นอน ตรงไหนผมอธิบายตกหล่นหรือพิมพ์ผิด เดี๋ยวกลับมาแก้เพิ่มให้ภายหลังครับ
อยากให้เราทำให้ไหม?
ให้เราช่วยติดตั้ง ตั้งค่า ย้ายระบบ หรือแก้ปัญหาเซิร์ฟเวอร์ เพื่อให้คุณมีเวลาไปทำงานที่สำคัญกว่า
เคยเจองานแบบนี้ไหม? ติดตรงไหน?
มาแลกวิธีกันต่อในบอร์ด LinuxThai



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