Docker ข้าม UFW แก้ไขอย่างไร
สาเหตุที่ Docker publish ports แล้วข้ามกฎ UFW เนื่องจากมีการเขียนกฎ DNAT ลงใน iptables โดยตรง เรียนรู้วิธีแก้ไขด้วยการตั้งค่าผ่าน Docker network หรือ iptables
ทำไม Docker ถึงข้ามการทำงานของ UFW
Docker ข้ามการทำงานของ UFW เนื่องจากพอร์ตของ container ที่ถูก publish จะไม่ผ่านกฎของ firewall ที่ UFW จัดการ เมื่อคุณรัน docker run -p 8080:80 Docker จะเขียนกฎ DNAT (destination network address translation) ลงใน PREROUTING chain ของ nat table ใน kernel กฎดังกล่าวจะเปลี่ยนปลายทางของแต่ละ packet ให้เป็น private address ของ container ก่อนที่ kernel จะตัดสินใจเส้นทางของ packet นั้น จากนั้น packet ที่ถูกเปลี่ยนปลายทางจะถูกส่งต่อไปยัง container ผ่านทาง FORWARD chain ซึ่ง Docker เป็นผู้ควบคุม กฎของ UFW จะอยู่ใน INPUT chain และ packet จะไม่ถูกส่งเข้าไปใน chain นั้น ผลที่ตามมาคือ ufw status จะแสดงสถานะ deny ตามค่าเริ่มต้น แต่ sudo ufw deny 8080 จะรายงานว่าสำเร็จ และพอร์ต 8080 ยังคงตอบสนองต่ออินเทอร์เน็ตทั้งหมด
นี่ไม่ใช่ข้อผิดพลาดของ Docker และ UFW ก็ไม่ได้ทำงานผิดปกติ เครื่องมือทั้งสองทำงานบน kernel firewall ตัวเดียวกัน กฎของ Docker ทำงานในขั้นตอนที่เร็วกว่าในเส้นทางของ packet ทำให้ UFW ไม่ถูกเรียกใช้งาน คู่มือนี้จะแสดงให้เห็นถึงการข้ามการทำงานนี้ อธิบายกลไกการทำงาน และแนะนำ 2 วิธีแก้ไขที่ใช้งานได้จริง ได้แก่ การ publish ports บน 127.0.0.1 และการกรองข้อมูลใน DOCKER-USER chain หากคุณยังไม่คุ้นเคยกับ UFW ให้เริ่มจากการตั้งค่าด้วย คู่มือพื้นฐานการใช้งาน UFW firewall เนื่องจาก firewall ที่ตั้งค่าแบบ default-deny ยังคงเป็นพื้นฐานที่ถูกต้องสำหรับการตั้งค่าอื่นๆ บน server
ทดสอบการ bypass บน server ของคุณเอง
เริ่มต้นด้วย VPS ที่เปิดใช้งาน UFW โดยตั้งค่า default deny สำหรับ incoming traffic จากนั้นรัน web container โดยเปิดใช้งาน port:
sudo ufw status verbose
docker run -d --name web -p 8080:80 nginx:1.29-alpineufw status verbose แสดงผล Default: deny (incoming), allow (outgoing) และไม่มีกฎสำหรับ port 8080 ตามรายงานของ firewall port นี้ถูกปิดอยู่ ให้ทดสอบจากเครื่องอื่นที่ไม่ใช่เครื่อง server เอง:
curl -I http://your-vps-ip:8080/HTTP/1.1 200 OKcontainer ตอบสนองต่อการเชื่อมต่อ ให้เพิ่มกฎ deny แบบระบุเจาะจงแล้วทดสอบอีกครั้ง:
sudo ufw deny 8080/tcpport ยังคงตอบสนอง เนื่องจากกฎ deny อยู่ใน chain ที่ packet ไม่ได้วิ่งผ่าน UFW ไม่ได้ทำงานผิดพลาด แต่ไม่ได้ถูกเรียกใช้งานเลย นี่คือสาเหตุที่ปัญหานี้ตรวจพบได้ยาก เพราะไม่มี error ปรากฏขึ้น การ deploy ทำงานได้ปกติ และผลลัพธ์ของสถานะ firewall ดูเหมือน server ที่ถูกป้องกันไว้อย่างสมบูรณ์แบบ
กลไกการทำงาน: PREROUTING ทำงานก่อน INPUT
Kernel ประมวลผลแพ็กเก็ตขาเข้าตามลำดับที่กำหนดไว้ ซึ่งลำดับนี้คือสาเหตุของปัญหาทั้งหมด
PREROUTINGทำงานเป็นลำดับแรก กฎในส่วนนี้สามารถเขียนปลายทางของแพ็กเก็ตใหม่ได้ ซึ่งกฎของ Docker สำหรับพอร์ตที่ถูก publish จะทำหน้าที่นี้- การตัดสินใจเลือกเส้นทาง (routing decision) จะเกิดขึ้นเป็นลำดับถัดไป แพ็กเก็ตที่มีปลายทางเป็นตัวโฮสต์เองจะถูกส่งไปยัง
INPUTchain ส่วนแพ็กเก็ตที่มีปลายทางเป็นเครื่องอื่นจะถูกส่งไปยังFORWARDchain - กฎของ UFW อยู่ใน
INPUTส่วนกฎของ Docker อยู่ในFORWARD
พิจารณากฎของ Docker สำหรับคอนเทนเนอร์ที่คุณเพิ่งเริ่มทำงาน:
sudo iptables -t nat -L DOCKER -nChain DOCKER (2 references)
target prot opt source destination
RETURN 0 -- 0.0.0.0/0 0.0.0.0/0
DNAT 6 -- 0.0.0.0/0 0.0.0.0/0 tcp dpt:8080 to:172.17.0.2:80บรรทัด DNAT คือข้อมูลทั้งหมด แพ็กเก็ตใดก็ตามที่ส่งมายังพอร์ต 8080 จะถูกเขียนปลายทางใหม่เป็น 172.17.0.2:80 ซึ่งเป็นที่อยู่ของคอนเทนเนอร์บน Docker's private bridge network หลังจากถูกเขียนใหม่แล้ว แพ็กเก็ตจะไม่ได้ระบุปลายทางเป็นโฮสต์อีกต่อไป การตัดสินใจเลือกเส้นทางจึงส่งแพ็กเก็ตไปยังเส้นทาง FORWARD ซึ่ง Docker ได้เพิ่มกฎเพื่อยอมรับ traffic เข้าสู่เครือข่ายของตนเองไว้แล้ว ส่วนกฎ deny 8080/tcp ของคุณจะรออยู่ใน INPUT เพื่อรับแพ็กเก็ตที่ไม่มีวันมาถึง
บน Ubuntu 24.04 คำสั่ง iptables เป็นส่วนติดต่อผู้ใช้ (front end) ของ nftables แต่ลำดับของ chain และผลลัพธ์ที่ได้นั้นเหมือนกัน ทั้ง UFW และ Docker ต่างเขียนข้อมูลลงใน kernel packet pipeline ชุดเดียวกัน โดยจุดเริ่มต้นของ Docker จะเกิดขึ้นก่อน
วิธีแก้ไขทั่วไป: การ publish ports บน 127.0.0.1
Container ส่วนใหญ่ไม่จำเป็นต้องเปิดเป็นสาธารณะตั้งแต่แรก ไม่ว่าจะเป็น database, app server ที่อยู่หลัง reverse proxy, admin panel หรือ metrics endpoint สิ่งเหล่านี้ไม่ควรตอบสนองต่อ internet โดยตรง ให้ publish สิ่งเหล่านี้บน loopback address:
docker run -d --name web -p 127.0.0.1:8080:80 nginx:1.29-alpineหรือใน Compose file:
services:
web:
image: nginx:1.29-alpine
ports:
- "127.0.0.1:8080:80"วิธีนี้ใช้งานได้เนื่องจากกฎ DNAT ของ Docker จะจับคู่เฉพาะ packet ที่ระบุปลายทางเป็น 127.0.0.1 เท่านั้น และ packet จาก internet ไม่สามารถระบุปลายทางดังกล่าวได้อย่างถูกต้อง ดังนั้น kernel จึงทำการ drop packet นั้นก่อนที่กฎ firewall จะทำงาน พอร์ตดังกล่าวจะเข้าถึงได้จาก host เท่านั้น และไม่สามารถเข้าถึงจากที่อื่นได้ ตรวจสอบการ binding:
sudo ss -tlnp | grep 8080ผลลัพธ์ต้องแสดงเป็น 127.0.0.1:8080 ไม่ใช่ 0.0.0.0:8080 หรือ [::]:8080 จากนั้นให้ตรวจสอบจากเครื่องอื่นว่า curl http://your-vps-ip:8080/ ถูกปฏิเสธการเชื่อมต่อ
สำหรับ service ที่ต้องเผชิญกับ internet ให้รัน reverse proxy เพียงตัวเดียวที่ถือครองพอร์ต 80 และ 443 เพื่อทำหน้าที่ route ตาม hostname และไม่ต้อง publish พอร์ตอื่นใด นี่คือรูปแบบที่ คู่มือ Traefik reverse proxy นำเสนอ และเป็นวิธีที่แอปพลิเคชันแบบ self-hosted เช่น Nextcloud on a VPS ใช้เพื่อให้ไม่สามารถเข้าถึงได้ ยกเว้นผ่าน proxy เท่านั้น สำหรับวิธีการประกาศ ports: และขั้นตอนการทำงานของ Compose ส่วนที่เหลือ สามารถศึกษาได้ใน คู่มือ Docker Compose basics
เมื่อ container ภายในทั้งหมดทำงานบน loopback แล้ว UFW จะกลับมาทำหน้าที่ปกติ คือการป้องกันพอร์ตที่ตัว host เองให้บริการ สามารถสร้างชุดกฎได้ที่นี่ และรันคำสั่งตามลำดับดังนี้:
การกรองข้อมูลจริง: DOCKER-USER chain
ในบางกรณี พอร์ตของ container จำเป็นต้องเปิดเผยสู่เครือข่ายแต่ต้องมีการจำกัดสิทธิ์ เช่น พอร์ตของ database replica ที่อนุญาตให้เข้าถึงได้เฉพาะบาง IP address เท่านั้น Docker จึงมี chain DOCKER-USER ไว้รองรับ ทุกแพ็กเก็ตที่มุ่งหน้าไปยัง container จะต้องผ่าน DOCKER-USER ก่อนจะถึงกฎ accept ของ Docker เอง และ Docker จะไม่เขียนกฎใดๆ ลงใน chain นี้โดยตรง Chain นี้มีไว้สำหรับผู้ใช้งาน และ Docker จะไม่ลบข้อมูลในนี้แม้จะมีการ restart daemon
ข้อควรระวังก่อนใช้คำสั่ง: เมื่อแพ็กเก็ตมาถึง DOCKER-USER กระบวนการ DNAT rewrite จะเกิดขึ้นไปแล้ว พอร์ตปลายทางของแพ็กเก็ตจะเป็นพอร์ตของ container (ในตัวอย่างคือ 80) ไม่ใช่พอร์ตที่เปิดเผยไว้ (8080) ดังนั้น กฎที่อ้างอิงถึง --dport 8080 จะไม่พบข้อมูลใดๆ วิธีที่เชื่อถือได้คือการอ้างอิงถึงพอร์ตที่ client เรียกใช้งานในตอนแรก ซึ่ง kernel's connection tracker จะจดจำไว้:
sudo iptables -I DOCKER-USER -i eth0 -p tcp -m conntrack --ctorigdstport 8080 --ctdir ORIGINAL ! -s 10.0.0.10 -j DROPความหมายคือ: สำหรับแพ็กเก็ตที่เข้ามาทาง eth0 และเป็นส่วนหนึ่งของการเชื่อมต่อที่มีพอร์ตปลายทางเดิมคือ 8080 ให้ทำการ drop ทุกอย่างที่ไม่ได้ส่งมาจาก 10.0.0.10 การใช้ match --ctdir ORIGINAL จะจำกัดกฎให้ใช้เฉพาะทิศทางจาก client ไปยัง container เพื่อป้องกันไม่ให้แพ็กเก็ตตอบกลับถูกบล็อกโดยไม่ตั้งใจ ให้แทนที่ eth0 ด้วย public interface ของคุณ และ ip route | grep default คือชื่อของ interface นั้น ทดสอบด้วยวิธีเดิม: curl จาก address ที่อนุญาตจะสำเร็จ แต่จากที่อื่นการเชื่อมต่อจะเกิด timeout
กฎที่เพิ่มด้วยคำสั่ง iptables จะหายไปเมื่อ reboot เนื่องจาก UFW ทำหน้าที่จัดการ firewall นี้อยู่แล้ว สถานที่ที่เหมาะสมในการเก็บกฎเหล่านี้ให้คงอยู่คือ /etc/ufw/after.rules ให้เพิ่ม block ต่อท้ายไฟล์ดังนี้:
*filter
:DOCKER-USER - [0:0]
-A DOCKER-USER -i eth0 -p tcp -m conntrack --ctorigdstport 8080 --ctdir ORIGINAL ! -s 10.0.0.10 -j DROP
COMMITจากนั้นรันคำสั่ง sudo ufw reload UFW จะโหลดไฟล์นั้นใหม่ทุกครั้งที่มีการ reload หรือ boot ดังนั้นการกรองข้อมูล container ของคุณจะถูกเก็บไว้ในที่เดียวกับกฎ firewall ส่วนอื่นๆ และจะยังคงอยู่แม้จะมีการ reboot หรืออัปเกรด Docker
ทำไมคุณจึงไม่ควรปิดการทำงานของ Docker's iptables integration
คำตอบเก่าๆ สำหรับปัญหานี้แนะนำให้ตั้งค่า { "iptables": false } ใน /etc/docker/daemon.json ห้ามทำเช่นนั้น กฎของ firewall ใน Docker ทำหน้าที่มากกว่าแค่การ publish ports กฎ masquerade เป็นส่วนที่ทำให้ container สามารถเข้าถึงอินเทอร์เน็ตขาออกผ่าน address ของ host ได้ หากปิดการทำงานนี้ container จะไม่สามารถ pull images, เข้าถึง package mirrors หรือเรียกใช้ external API ใดๆ ได้ กฎ DNAT เป็นส่วนสำคัญที่ทำให้ -p ทำงานได้ หากปิดการทำงานนี้ published ports จะใช้งานไม่ได้โดยสิ้นเชิง นอกจากนี้ กฎ isolation ที่ใช้แยก network ของ Compose ออกจากกันจะหายไปด้วย การแก้ไขปัญหาการ bypass ด้วยวิธีนี้จะทำให้ระบบเครือข่ายของ container เสียหาย และคุณจะต้องเขียนและดูแลกฎทั้งหมดเหล่านั้นด้วยตนเอง เอกสารของ Docker ระบุว่าการตั้งค่านี้มีไว้สำหรับผู้ที่ต้องการทำเช่นนั้นโดยเฉพาะ การมีอยู่ของ DOCKER-USER chain มีไว้เพื่อไม่ให้ใครจำเป็นต้องใช้ switch นี้
ปัญหาเดียวกันในฝั่ง IPv6
ตรวจสอบลักษณะของ port ที่ถูก publish บน IPv6:
sudo ss -tlnp | grep 8080ตั้งแต่ Docker Engine 27 เป็นต้นไป Docker จะจัดการ ip6tables โดยค่าเริ่มต้น ใน Docker network ที่เปิดใช้งาน IPv6 พอร์ตที่ถูก publish จะได้รับการจัดการแบบ DNAT ใน IPv6 tables เช่นเดียวกัน ดังนั้นจึงเกิดช่องโหว่แบบเดียวกันและต้องใช้วิธีแก้ไขแบบเดียวกัน: เนื่องจากมี chain DOCKER-USER ใน ip6tables เช่นกัน ให้ทำการ mirror rule ของคุณด้วย sudo ip6tables -I DOCKER-USER ... และทดสอบจากภายนอกด้วย curl ไปยัง public IPv6 address ของ server เช่น curl -6 http://[2001:db8:2a::1]:8080/
ใน network ที่ไม่ได้ใช้งาน IPv6 client จะถูกจัดการโดย docker-proxy ซึ่งเป็น user-space process ปกติที่ listen บน [::]:8080 และส่งต่อ traffic เข้าไปยัง container ผ่าน IPv4 Traffic ที่ส่งไปยัง host process จะผ่าน INPUT ดังนั้น UFW จึงสามารถ filter path นั้นได้ แต่ต้องเป็นกรณีที่ UFW กำลังจัดการ IPv6 อยู่เท่านั้น ส่วนรายละเอียดว่า UFW จัดการ IPv6 หรือไม่ และช่องโหว่ IPv6 อื่นๆ บน VPS เกิดขึ้นได้อย่างไร สามารถอ่านเพิ่มเติมได้ที่ คู่มือ UFW และ IPv6
การ publish บน loopback จะช่วยข้ามปัญหานี้ไป: เนื่องจาก -p 127.0.0.1:8080:80 bind เฉพาะ IPv4 loopback เท่านั้น จึงไม่มี IPv6 listener และไม่มีช่องทางให้เข้าถึงจากภายนอกใน stack ใดๆ
รูปแบบที่ช่วยสร้างความมั่นคง
- Publish ทุก internal port บน
127.0.0.1เพื่อป้องกันไม่ให้มีการเปิดเผยพอร์ตโดยไม่ตั้งใจ - กำหนดให้ฝั่ง public เชื่อมต่อผ่าน reverse proxy เพียงตัวเดียว ซึ่งถือครองพอร์ต 80 และ 443
- ตั้งค่า UFW ให้เป็น default deny สำหรับ host โดยอนุญาตเฉพาะพอร์ต SSH และพอร์ตของ proxy
- กรอง container ports ที่เป็น public จริงๆ ใน
DOCKER-USERโดยตรวจสอบจาก original destination port ที่บันทึกไว้ใน/etc/ufw/after.rules - เปิดการใช้งาน Docker's iptables integration ไว้
เมื่อตั้งค่าเสร็จสิ้น วิธีนี้จะช่วยลดความผิดพลาด: ufw status อธิบายถึง host และ DOCKER-USER อธิบายถึง containers จะไม่มีการ publish อะไรโดยไม่ตั้งใจ และในครั้ง docker run -p ที่คุณพิมพ์คำสั่ง ระบบจะเปิดเผยเฉพาะสิ่งที่ตั้งใจไว้เท่านั้น
FAQ
ทำไมฉันถึงเข้าถึง Docker container ได้ ทั้งที่ UFW บล็อกพอร์ตนั้นอยู่?
เนื่องจาก Docker ทำการ publish พอร์ตด้วยกฎ DNAT ใน chain PREROUTING ซึ่งจะเปลี่ยน destination address ของ packet ให้เป็น address ของ container ก่อนที่จะมีการกรองข้อมูลเกิดขึ้น จากนั้น packet จะเดินทางผ่านเส้นทาง FORWARD ในขณะที่กฎของ UFW อยู่ใน chain INPUT ซึ่งเป็น chain ที่ packet ไม่ได้วิ่งผ่าน กฎการ deny ของ firewall จึงไม่มีผลต่อพอร์ตของ container ที่ถูก publish ไว้
ฉันจะทำให้ UFW บล็อกพอร์ตที่ Docker publish ไว้ได้อย่างไร?
UFW ไม่สามารถทำได้โดยตรง เนื่องจากกฎของ UFW อยู่ใน chain ที่ไม่ถูกต้อง คุณสามารถแก้ไขได้โดยการหยุด expose พอร์ตนั้น โดยการ publish พอร์ตเป็น 127.0.0.1:8080:80 เพื่อให้เข้าถึงได้เฉพาะจาก host เท่านั้น หรือทำการกรองข้อมูลใน chain DOCKER-USER ด้วยกฎ iptables ที่ใช้ conntrack เพื่อตรวจสอบ original destination port และต้องบันทึกกฎดังกล่าวไว้ใน /etc/ufw/after.rules เพื่อให้กฎยังคงอยู่หลังจาก reboot และ ufw reload
ฉันควรตั้งค่า "iptables": false ใน Docker's daemon.json หรือไม่?
ไม่ควร เนื่องจากค่านี้จะลบกฎ firewall และ NAT ทั้งหมดของ Docker ซึ่งส่งผลเสียมากกว่าการ bypass โดย container จะไม่สามารถเชื่อมต่ออินเทอร์เน็ตขาออกได้เนื่องจากขาด masquerade rule และพอร์ตที่ publish ไว้จะใช้งานไม่ได้เนื่องจากขาดกฎ DNAT แนะนำให้ใช้การ publish แบบ loopback และใช้ chain DOCKER-USER แทน วิธีนี้จะช่วยแก้ปัญหาการถูก expose โดยไม่ทำให้ระบบเครือข่ายของ container เสียหาย
Docker bypass UFW ใน IPv6 ด้วยหรือไม่?
สำหรับ Docker Engine เวอร์ชัน 27 ขึ้นไป การจัดการ ip6tables จะถูกเปิดใช้งานโดยค่าเริ่มต้น ดังนั้นพอร์ตที่ถูก publish บน Docker network ที่รองรับ IPv6 จะถูกเปลี่ยนเส้นทางหลบ UFW เช่นเดียวกับ IPv4 และจำเป็นต้องใช้กฎ DOCKER-USER ที่ทำ mirror ร่วมกับ ip6tables สำหรับเครือข่ายที่ไม่มี IPv6 กระบวนการ docker-proxy จะฟังที่ [::] และ traffic นั้นจะวิ่งผ่าน INPUT ซึ่ง UFW สามารถกรองข้อมูลได้หาก UFW จัดการ IPv6 การ publish บน 127.0.0.1 จะช่วยหลีกเลี่ยงทั้งสองกรณี เนื่องจากจะไม่มีการฟังสัญญาณบน IPv6 เลย