คนส่วนใหญ่นึกถึงไฟร์วอลล์เป็นกำแพง มีข้างในกับข้างนอก และมีคนเฝ้าประตู
ภาพนั้นพลาดสิ่งที่ทำให้คนตั้งค่าผิดบ่อยที่สุด — ไฟร์วอลล์คือรายการกฎที่มี ลำดับ และผลลัพธ์ขึ้นอยู่กับสามอย่างที่คนมักไม่ได้อ่าน คือกฎข้อไหนชนะเมื่อ ตรงหลายข้อ อะไรเกิดขึ้นเมื่อไม่มีข้อไหนตรงเลย และตัวกรองจำการเชื่อมต่อได้ไหม
macOS มีตัวกรองแพ็กเก็ตชื่อ pf ติดมาให้ พร้อมหน้าคู่มือเต็ม ๆ และคำตอบของ สามคำถามนั้นอยู่ในนั้นทั้งหมด คำตอบข้อที่สองจะทำให้หลายคนสะดุด
ถ้ายังไม่เคยดู เริ่มตรงนี้
ไฟล์กฎของระบบอยู่ที่เดียว เปิดอ่านได้เลยไม่ต้องใช้สิทธิ์อะไร
$ cat /etc/pf.conf
บนเครื่องที่ใช้เขียนบทความนี้ ไฟล์นั้นไม่มีกฎกรองสักข้อ มีแต่จุดยึดให้ระบบ อื่นเอากฎมาแขวน
scrub-anchor "com.apple/*"
nat-anchor "com.apple/*"
rdr-anchor "com.apple/*"
dummynet-anchor "com.apple/*"
anchor "com.apple/*"
และหน้าคู่มือของมันคือแหล่งอ้างอิงที่ดีที่สุดสำหรับบทความนี้
$ man pf.conf
กฎข้อสุดท้ายที่ตรงเป็นผู้ชนะ
นี่คือประโยคที่ต้องอ่านก่อนเขียนกฎข้อแรก จากหน้าคู่มือบนเครื่องนี้เอง
"Filter rules are evaluated in sequential order, from first to last. The last matching rule decides what action is taken."
ข้อสุดท้าย ไม่ใช่ข้อแรก ซึ่งกลับด้านกับที่คนส่วนใหญ่คาดไว้ และตัว pf เองก็รู้ตัว จึงมีคำสั่ง quick ไว้ให้เปลี่ยนเป็นแบบข้อแรกชนะได้ ตามหัวข้อ ข้างล่าง
ผลคือกฎสองชุดที่มีข้อความเหมือนกันเป๊ะ แต่สลับลำดับ ให้ผลตรงข้ามกัน
ชุด A
block in all
pass in proto tcp to port 22
-> ssh ผ่าน ที่เหลือถูกบล็อก
ชุด B
pass in proto tcp to port 22
block in all
-> ทุกอย่างถูกบล็อก รวมทั้ง ssh
ในชุด B กฎ block in all เป็นข้อสุดท้ายที่ตรงกับแพ็กเก็ต ssh ด้วย มันจึงชนะ และปิดสิ่งที่บรรทัดก่อนหน้าเพิ่งเปิดไป
ค่าตั้งต้นคือผ่าน ไม่ใช่บล็อก
ประโยคถัดมาในหน้าคู่มือเดียวกันคือสิ่งที่ผมคิดว่าน่าตกใจที่สุด
"If no rule matches the packet, the default action is to pass the packet."
ตัวกรองที่ไม่มีกฎเลย ปล่อยผ่านทุกอย่าง และตัวกรองที่มีกฎครอบคลุมไม่ครบ ก็ปล่อยผ่านทุกอย่างที่กฎไม่ได้พูดถึง
หน้าคู่มือบอกวิธีแก้ไว้ตรง ๆ ในย่อหน้าเดียวกัน
"The simplest mechanism to block everything by default and only pass packets that match explicit rules is specify a first filter rule of:
block all"
รวมสองข้อนี้เข้าด้วยกันแล้วได้รูปแบบที่ถูกต้อง — เริ่มด้วย block all เสมอ แล้วค่อยเปิดทีละอย่างในบรรทัดถัดมา เพราะบรรทัดหลังชนะบรรทัดก่อน
ที่อันตรายคือไฟล์กฎที่เขียนถูกอยู่แล้ว แต่มีคนไปเพิ่มบรรทัดใหม่ ต่อท้าย บรรทัดใหม่นั้นจะชนะทุกอย่างที่อยู่ข้างบน โดยที่คนเพิ่มอาจไม่ได้ตั้งใจ
quick — เมื่ออยากให้ข้อแรกที่ตรงชนะ
ถ้าอยากได้พฤติกรรมแบบข้อแรกชนะ มีคำสั่งให้ใช้
"If a packet matches a rule which has the quick option set, this rule is considered the last matching rule, and evaluation of subsequent rules is skipped."
block in quick from 203.0.113.0/24
pass in all
ที่อยู่ในชุดนั้นถูกบล็อกทันทีโดยไม่ต้องอ่านบรรทัดล่าง ส่วนที่เหลือผ่าน
quick ทำให้กฎอ่านง่ายขึ้นมากในไฟล์ยาว ๆ แต่ก็ทำให้ต้องระวังคนละแบบ — บรรทัดที่มี quick อยู่ข้างบน จะบังบรรทัดข้างล่างที่คนคาดว่าจะได้ผล
มีสถานะ กับ ไร้สถานะ
ตัวกรองแบบเก่าดูแพ็กเก็ตทีละใบโดยไม่จำอะไรเลย เรียกว่าไร้สถานะ ส่วนตัวกรอง สมัยใหม่จำว่าใครเปิดการเชื่อมต่ออะไรไว้บ้าง เรียกว่ามีสถานะ
pf เป็นแบบมีสถานะโดยปริยาย หน้าคู่มือเขียนไว้ว่า
"pass — The packet is passed; state is created unless the no state option is specified"
ความต่างสำคัญมาก เพราะการเชื่อมต่อทุกครั้งมีสองทิศ ถ้าเปิดขาออกอย่างเดียว โดยไม่จำอะไร ขากลับจะถูกบล็อก และไม่มีอะไรทำงานเลย
แบบไร้สถานะ ต้องเขียนกฎเปิดขากลับเองทุกครั้ง และกฎข้อนั้นเองคือช่องโหว่ ตามที่หัวข้อถัดไปจะแสดง
แบบมีสถานะ เปิดขาออกอย่างเดียวพอ ขากลับของการเชื่อมต่อนั้นถูกปล่อย อัตโนมัติ ส่วนแพ็กเก็ตที่ไม่เข้าคู่กับสถานะใดเลย ถูกบล็อก
กับดักของกฎไร้สถานะ
วิธีที่คนเคยใช้เปิดขากลับบนตัวกรองไร้สถานะ คือดูบิต ACK ในหัว TCP โดยให้ เหตุผลว่าแพ็กเก็ตขากลับย่อมมีบิตนั้นตั้งอยู่ ส่วนการเปิดการเชื่อมต่อใหม่ มีแต่ SYN
ปัญหาคือแฟล็กเหล่านั้นเป็นแค่บิตในไบต์เดียว ที่ผู้ส่งเป็นคนเขียนเอง
FIN 0x01 00000001
SYN 0x02 00000010
RST 0x04 00000100
PSH 0x08 00001000
ACK 0x10 00010000
URG 0x20 00100000
ผมสร้างเซกเมนต์ TCP ขึ้นมาสองชุด ชุดหนึ่งเป็นการตอบกลับจริง อีกชุดผู้บุกรุก สร้างขึ้นมาเองโดยตั้งบิต ACK ไว้
real reply flags=0x10 ผ่านกฎ
attacker-made packet flags=0x10 ผ่านกฎ
new connection (SYN) flags=0x02 ถูกบล็อก
สองแถวแรกคือไบต์ชุดเดียวกันทุกบิต กฎที่ดูแค่แฟล็กจึงแยกไม่ออกในทางหลักการ ไม่ใช่เพราะเขียนกฎไม่เก่ง
ตัวกรองที่มีสถานะแยกออกได้ เพราะมันไม่ได้ดูบิต มันดูว่า มีการเชื่อมต่อ ที่ตรงกับสี่ค่านี้อยู่ในตารางหรือเปล่า — ที่อยู่ต้นทาง ที่อยู่ปลายทาง พอร์ตต้นทาง พอร์ตปลายทาง แพ็กเก็ตที่ไม่เข้าคู่กับรายการไหนก็ถูกทิ้ง
ตารางสถานะเต็มได้
ความจำนั้นมีขนาดจำกัด และหน้าคู่มือก็เขียนเรื่องนี้ไว้เป็นตัวเลือกของกฎ
"max ⟨number⟩ — Limits the number of concurrent states the rule may create. When this limit is reached, further packets that would create state will not match this rule until existing states time out."
ประโยคนี้อธิบายรูปแบบการล่มที่เจอบ่อย — เมื่อตารางเต็ม การเชื่อมต่อใหม่ถูก ปฏิเสธ ทั้งที่ซีพียูยังว่างและวงจรยังไม่เต็ม
คิดเป็นตัวเลขได้ตรง ๆ ถ้าผู้บุกรุกเปิดการเชื่อมต่อค้างไว้พันครั้งต่อวินาที
timeout 30 s -> 30,000 รายการค้างอยู่พร้อมกัน
timeout 60 s -> 60,000 รายการค้างอยู่พร้อมกัน
timeout 300 s -> 300,000 รายการค้างอยู่พร้อมกัน
ค่า timeout ที่ยาวขึ้นทำให้ต้องใช้ตารางใหญ่ขึ้นเป็นสัดส่วนตรง ๆ นี่คือเหตุผล ที่การปรับ timeout ให้สั้นลงเป็นการป้องกันที่ได้ผล และเป็นสิ่งที่หน้าคู่มือ ให้ตั้งได้รายกฎ
สิ่งที่ไฟร์วอลล์มองไม่เห็น
ตัวกรองที่ชั้น 3 กับ 4 เห็นสิ่งเหล่านี้
source address เห็น
destination address เห็น
port เห็น
flags เห็น
payload เห็นเป็นไบต์ แต่อ่านไม่ออกถ้าเข้ารหัส
บทความ TLS อธิบายว่าเนื้อหาถูกเข้ารหัสตั้งแต่ต้นทางถึง ปลายทาง ซึ่งแปลว่า ไฟร์วอลล์เห็นแค่ว่าใครคุยกับใคร ไม่เห็นว่าคุยอะไร สิ่งที่ยังเห็นคือชื่อปลายทางใน SNI ตามที่บทความนั้นชี้ไว้ และปริมาณกับจังหวะ ของข้อมูล
และมีอีกหลายอย่างที่ไฟร์วอลล์ไม่เกี่ยวเลย — คนในองค์กรที่มีสิทธิ์อยู่แล้ว ไฟล์ที่พกเข้ามาทางอื่น และช่องโหว่ในบริการที่เราตั้งใจเปิดไว้เอง
เมื่อมันโกหก
"มีไฟร์วอลล์แล้วปลอดภัย" ไฟร์วอลล์บังคับสิ่งที่คุณเขียนไว้ ไม่ได้ตัดสินว่า อะไรควรอนุญาต ถ้ากฎเปิดพอร์ตที่ไม่ควรเปิด มันจะเปิดให้อย่างซื่อสัตย์
"ไม่มีกฎ แปลว่าไม่มีอะไรผ่าน" ตรงกันข้ามสำหรับ pf — หน้าคู่มือระบุว่า ถ้าไม่มีกฎไหนตรง ค่าตั้งต้นคือปล่อยผ่าน
"เพิ่มกฎ pass ต่อท้ายเพื่อเปิดเพิ่ม" อาจได้ผลตรงข้าม เพราะบรรทัดที่เพิ่ม เข้าไปกลายเป็นข้อสุดท้ายที่ตรง และไปลบล้างกฎ block ที่อยู่ข้างบนโดยไม่ตั้งใจ
"กฎเปิดขากลับด้วย ACK ก็เหมือนกัน" ไม่เหมือน ตามการทดลองข้างบน แฟล็ก เป็นบิตที่ผู้ส่งเขียนเอง ตัวกรองที่มีสถานะเท่านั้นที่แยกออก
"บล็อก ping แล้วปลอดภัยขึ้น" บทความ ping แสดงแล้วว่า การบล็อก ICMP ทำให้การค้นหา MTU พัง และทำให้แก้ปัญหายากขึ้นมาก โดยแลกมา กับความปลอดภัยที่แทบไม่เพิ่ม
"ไฟร์วอลล์เห็นทุกอย่างที่ผ่าน" เห็นหัว ไม่เห็นเนื้อหาที่เข้ารหัส และไม่ เห็นอะไรเลยที่ไม่ได้วิ่งผ่านมัน
ตัวอย่างจริงจากงานจริง
กรณีที่ 1 — เพิ่มกฎแล้วบริการอื่นล่ม
สถานการณ์ เพิ่มบรรทัดเปิดพอร์ตใหม่ต่อท้ายไฟล์กฎ แล้วบริการที่เคยใช้ได้ หยุดทำงาน
อ่านอย่างไร ถ้าบรรทัดใหม่กว้างกว่าที่ตั้งใจ เช่นมีคำว่า all ปนอยู่ มันจะกลายเป็นข้อสุดท้ายที่ตรงกับแพ็กเก็ตของบริการอื่นด้วย และชนะกฎที่อยู่ ข้างบน วิธีตรวจคือไล่จากล่างขึ้นบน หาบรรทัดสุดท้ายที่ตรงกับทราฟฟิกที่พัง ไม่ใช่ไล่จากบนลงล่าง
สิ่งที่ยังไม่ได้พิสูจน์ ลำดับกฎอธิบายอาการนี้ได้ แต่ยังไม่ได้ตัดเรื่อง quick ที่อยู่ข้างบนออก และไฟล์กฎจริงอาจมี anchor ที่ระบบอื่นแทรกกฎเข้ามา ตอนทำงาน ซึ่งไม่ปรากฏในไฟล์ที่เปิดอ่าน
กรณีที่ 2 — การเชื่อมต่อใหม่ถูกปฏิเสธ ทั้งที่เครื่องยังว่าง
สถานการณ์ ซีพียูไม่ถึงสิบเปอร์เซ็นต์ วงจรขาออกยังเหลือ แต่ผู้ใช้ใหม่ ต่อเข้ามาไม่ได้
อ่านอย่างไร ตรวจตารางสถานะก่อนเป็นอันดับแรก หน้าคู่มืออธิบายไว้แล้วว่า เมื่อถึงขีดจำกัด แพ็กเก็ตที่จะสร้างสถานะใหม่จะไม่ตรงกฎนั้นอีก จนกว่าของเดิม จะหมดอายุ อาการจึงเป็น "ของเดิมใช้ได้ ของใหม่เข้าไม่ได้" ซึ่งเป็นลายเซ็นที่ ต่างจากการที่เครื่องล้น
สิ่งที่ยังไม่ได้พิสูจน์ ตารางเต็มบอกว่ามีของค้างเยอะ ไม่ได้บอกว่าเพราะ ถูกโจมตีหรือเพราะ timeout ตั้งไว้ยาวเกินไปกับทราฟฟิกปกติ ต้องดูอัตราการ สร้างสถานะต่อวินาทีเทียบกับ timeout
กรณีที่ 3 — เขียนกฎชุดแรกให้ถูกตั้งแต่ต้น
สถานการณ์ ต้องวางกฎบนเครื่องใหม่
อ่านอย่างไร ลำดับนี้ปลอดภัยเพราะสอดคล้องกับทั้งสองกฎที่ยกมาข้างบน
1 block all ปิดทุกอย่างก่อน เป็นบรรทัดแรก
2 pass out proto tcp keep state เปิดขาออกและให้จำสถานะ
3 pass in proto tcp to port 22 เปิดเฉพาะบริการที่ตั้งใจ
บรรทัดที่ 1 ทำให้ค่าตั้งต้นกลายเป็นบล็อก บรรทัดถัดมาที่ตรงกว่าจะชนะทีละอย่าง และเพราะจำสถานะไว้ จึงไม่ต้องเขียนกฎเปิดขากลับเลย
สิ่งที่ยังไม่ได้พิสูจน์ ชุดนี้เป็นโครง ไม่ใช่ไฟล์ที่พร้อมใช้ ยังไม่ได้ พูดถึง loopback, ICMP ที่จำเป็นต่อการหา MTU, และ DHCP ที่ต้องผ่านก่อนเครื่อง จะมีเลขไอพีด้วยซ้ำ ทั้งสามอย่างเป็นเหตุผลที่กฎ block all เดี่ยว ๆ มักทำให้ เครื่องใช้งานไม่ได้
เมื่อไฟร์วอลล์ไม่พอ
ไฟร์วอลล์ตอบคำถามว่า ใครคุยกับใครได้ ซึ่งเป็นคำถามที่ดี แต่ไม่ใช่คำถาม เดียว มันตอบไม่ได้ว่าคนที่คุยได้เป็นคนที่อ้างจริงไหม ซึ่งต้องใช้การยืนยัน ตัวตน และตอบไม่ได้ว่าเนื้อหาที่ส่งถูกแก้ระหว่างทางไหม ซึ่งต้องใช้การเข้ารหัส แบบที่ TLS อธิบาย
และไฟร์วอลล์ที่ขอบเครือข่ายไม่เห็นอะไรเลยที่วิ่งอยู่ข้างในโดยไม่ผ่านมัน ซึ่ง เป็นเหตุผลที่การกรองที่ตัวเครื่องเองยังจำเป็นอยู่
อ้างอิง
จากเครื่องที่ใช้เขียน
man pf.conf— ทุกประโยคที่ยกมาอ้างในบทความนี้มาจากหน้าคู่มือนี้บนเครื่อง ที่เขียน ทั้งเรื่องลำดับกฎ ค่าตั้งต้นquickการสร้างสถานะ และตัวเลือกmax/etc/pf.conf— ไฟล์กฎของระบบ ซึ่งบนเครื่องนี้มีแต่จุดยึด ไม่มีกฎกรองman pfctl— คำสั่งที่ใช้เปิดปิดและโหลดกฎ
คำนวณเอง
- ตารางแฟล็กของ TCP และการทดลองสร้างเซกเมนต์สองชุดที่มีบิต ACK เหมือนกัน
- ตัวเลขจำนวนรายการในตารางสถานะ คำนวณจากอัตราการเปิดการเชื่อมต่อคูณกับ ค่า timeout