Docker bypass UFW: nguyên nhân và cách chặn port
Docker publish port qua iptables nên bỏ qua UFW: dù UFW báo deny, port 8080 vẫn mở Internet. Xem cơ chế và 2 cách sửa hoạt động.
Vì sao Docker bypass UFW
Docker bypass UFW vì các port của container được publish không đi qua các rule firewall mà UFW quản lý. Khi chạy docker run -p 8080:80, Docker ghi một rule DNAT (destination network address translation) vào chain PREROUTING của table nat trong kernel. Rule đó đổi destination của từng packet sang địa chỉ private của container trước khi kernel quyết định packet sẽ đi đâu. Sau đó, packet đã được đổi đích được forward vào container qua chain FORWARD do Docker kiểm soát. Các rule của UFW nằm trong chain INPUT, còn packet không bao giờ đi vào chain này. Vì vậy, ufw status hiển thị default deny, sudo ufw deny 8080 báo thành công, nhưng port 8080 vẫn nhận kết nối từ toàn bộ Internet.
Đây không phải là bug của Docker, và UFW cũng không bị hỏng. Cả hai tool đều cấu hình cùng một firewall trong kernel. Các rule của Docker chỉ được áp dụng ở một bước sớm hơn trên đường đi của packet, nên UFW không bao giờ được gọi đến. Guide này minh họa việc bypass, giải thích cơ chế, rồi trình bày 2 cách sửa hoạt động: publish port trên 127.0.0.1 và lọc trong chain DOCKER-USER. Nếu bạn chưa quen với UFW, trước tiên hãy thiết lập UFW theo guide cơ bản về firewall UFW, vì firewall default-deny vẫn là nền tảng phù hợp cho mọi thành phần khác trên server.
Tự kiểm tra bypass trên server của bạn
Bắt đầu với một VPS đang bật UFW và có chính sách mặc định là deny traffic vào. Chạy một web container với port được publish:
sudo ufw status verbose
docker run -d --name web -p 8080:80 nginx:1.29-alpineufw status verbose hiển thị Default: deny (incoming), allow (outgoing) và không có rule nào cho port 8080. Theo chính output của firewall, port này đang bị đóng. Bây giờ kiểm tra từ một máy khác, không kiểm tra trên chính server:
curl -I http://your-vps-ip:8080/HTTP/1.1 200 OKContainer vẫn phản hồi. Thêm một deny rule rõ ràng rồi kiểm tra lại:
sudo ufw deny 8080/tcpPort vẫn phản hồi vì deny rule nằm trong một chain mà packet không bao giờ đi qua. UFW không bị lỗi. Nó chưa từng được kiểm tra. Đây cũng là lý do vấn đề này khó bị phát hiện: không có lỗi nào được in ra, quá trình deploy vẫn thành công, và output trạng thái của firewall trông hoàn toàn giống một server đang được khóa chặt đúng cách.
Cơ chế: PREROUTING chạy trước INPUT
Kernel xử lý packet đến theo một thứ tự cố định, và toàn bộ vấn đề nằm ở thứ tự đó.
PREROUTINGchạy trước. Các rule tại đây có thể ghi lại destination của packet. Rule của Docker cho port được publish thực hiện đúng việc này.- Tiếp theo là quyết định định tuyến. Packet có địa chỉ đích là chính host sẽ đi vào chain
INPUT. Packet có địa chỉ đích là máy khác sẽ đi vào chainFORWARD. - Các rule của UFW nằm trong
INPUT. Các rule của Docker nằm trongFORWARD.
Xem rule của Docker cho container bạn vừa khởi động:
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:80Dòng DNAT giải thích toàn bộ vấn đề. Mọi packet đến port 8080 đều bị ghi lại destination thành 172.17.0.2:80, tức địa chỉ của container trên private bridge network của Docker. Sau khi được ghi lại destination, packet không còn có địa chỉ đích là host nữa. Vì vậy quyết định định tuyến sẽ đưa packet theo đường FORWARD, nơi Docker đã thêm các rule cho phép traffic đi vào các network riêng của nó. Rule deny 8080/tcp của bạn nằm chờ trong INPUT một packet sẽ không bao giờ đến đó.
Trên Ubuntu 24.04, command iptables là front end của nftables, nhưng thứ tự chain và kết quả vẫn giống hệt. UFW và Docker cùng ghi rule vào một pipeline xử lý packet của kernel, nhưng entry point của Docker nằm trước. Điều này không chỉ xảy ra với UFW: firewalld trên VPS Rocky hoặc AlmaLinux lọc traffic tại cùng vị trí trong pipeline đó và cũng bị bypass bởi cùng một rule DNAT. Vì vậy các cách khắc phục bên dưới cũng áp dụng cho trường hợp này.
Cách xử lý thường dùng: publish port trên 127.0.0.1
Ngay từ đầu, hầu hết container không cần public. Database, app server nằm sau reverse proxy, admin panel và metrics endpoint không nên trả lời trực tiếp từ Internet. Hãy publish chúng trên địa chỉ loopback:
docker run -d --name web -p 127.0.0.1:8080:80 nginx:1.29-alpineHoặc trong file Compose:
services:
web:
image: nginx:1.29-alpine
ports:
- "127.0.0.1:8080:80"Cách này hoạt động vì rule DNAT của Docker lúc này chỉ khớp với các packet gửi đến 127.0.0.1. Packet từ Internet không thể mang destination đó một cách hợp lệ, nên kernel drop packet trước khi bất kỳ rule firewall nào được áp dụng. Port vẫn truy cập được từ host, nhưng không truy cập được từ nơi nào khác. Xác nhận binding:
sudo ss -tlnp | grep 8080Output phải có 127.0.0.1:8080, không phải 0.0.0.0:8080 hoặc [::]:8080. Sau đó, từ một máy khác, xác nhận rằng curl http://your-vps-ip:8080/ bị từ chối.
Với các service cần public trên Internet, hãy chạy một reverse proxy duy nhất quản lý port 80 và 443, định tuyến theo hostname, và không publish thêm port nào khác. Đây là mô hình được dùng trong hướng dẫn reverse proxy Traefik, đồng thời giúp một app self-host như Nextcloud trên VPS không thể truy cập được trừ khi đi qua proxy. Cách khai báo các entry ports: và phần còn lại của workflow Compose được trình bày trong hướng dẫn cơ bản về Docker Compose.
Khi mọi container nội bộ đều chạy trên loopback, UFW có thể quay lại nhiệm vụ thông thường: bảo vệ các port do chính host cung cấp. Hãy tạo bộ rule tại đây, rồi chạy các command theo đúng thứ tự:
Lọc thực sự: chain DOCKER-USER
Đôi khi một cổng container phải tiếp tục được publish ra network nhưng cần giới hạn truy cập. Ví dụ, chỉ một địa chỉ văn phòng được phép truy cập cổng của database replica. Docker cung cấp chain DOCKER-USER cho trường hợp này. Mọi packet đi đến bất kỳ container nào đều đi qua DOCKER-USER trước các rule accept riêng của Docker, và Docker không bao giờ ghi rule vào chain này. Chain này dành cho bạn, còn Docker giữ nguyên nội dung của nó qua các lần restart daemon.
Có một điểm dễ nhầm trước khi chạy command: khi packet đến DOCKER-USER, việc rewrite DNAT đã hoàn tất. Destination port của packet là cổng của container, tức 80 trong ví dụ này, không phải published port là 8080. Vì vậy, rule khớp --dport 8080 sẽ không khớp packet nào. Cách đáng tin cậy là khớp cổng mà client đã gọi ban đầu; kernel connection tracker lưu thông tin này:
sudo iptables -I DOCKER-USER -i eth0 -p tcp -m conntrack --ctorigdstport 8080 --ctdir ORIGINAL ! -s 10.0.0.10 -j DROPCó thể hiểu như sau: với các packet đi vào qua eth0 và thuộc một connection có original destination port là 8080, drop mọi packet không được gửi từ 10.0.0.10. Match --ctdir ORIGINAL giới hạn rule này cho hướng từ client đến container, nhờ đó reply packet không bị bắt nhầm. Thay eth0 bằng public interface của bạn; ip route | grep default hiển thị tên interface đó. Kiểm tra theo cách như trước: curl từ địa chỉ được phép sẽ thành công, còn từ mọi nơi khác connection sẽ timeout. Trạng thái treo đó là dấu hiệu của rule DROP đang hoạt động, thay vì cho thấy phía sau không có gì trên cổng. Sự khác nhau giữa connection bị từ chối và connection bị timeout là cách nhanh nhất để phân biệt một cổng bị filter với một service đơn giản là không listening.
Các rule được thêm bằng command iptables sẽ biến mất sau khi reboot. Vì UFW đã quản lý firewall này, nơi phù hợp để lưu chúng là /etc/ufw/after.rules. Thêm một block vào cuối file:
*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
COMMITSau đó chạy sudo ufw reload. UFW sẽ replay file đó sau mỗi lần reload và mỗi lần boot. Nhờ vậy, phần lọc container nằm cùng chỗ với các cấu hình firewall khác và vẫn tồn tại sau cả reboot lẫn upgrade Docker.
Vì sao không nên tắt tích hợp iptables của Docker
Các hướng dẫn cũ cho vấn đề này thường đề xuất đặt { "iptables": false } trong /etc/docker/daemon.json. Không nên làm vậy. Các rule firewall của Docker làm nhiều việc hơn việc publish port. Rule masquerade cho phép container truy cập Internet qua địa chỉ của host. Khi tắt tích hợp này, container không thể pull image, truy cập package mirror hoặc gọi API bên ngoài. Các rule DNAT là thành phần giúp -p hoạt động. Vì vậy, các port đã publish sẽ ngừng hoạt động hoàn toàn. Các rule isolation giữ cho những network Compose riêng biệt không truy cập lẫn nhau cũng bị mất. Bạn sẽ xử lý việc bypass bằng cách phá vỡ network của container, rồi phải tự viết và bảo trì thủ công từng rule. Tài liệu chính thức của Docker mô tả setting này dành cho những người thực sự định tự quản lý toàn bộ việc đó. Chain DOCKER-USER tồn tại chính xác để không ai cần dùng switch này.
Mặt IPv6 của cùng vấn đề
Trước tiên, kiểm tra cổng đã publish trên IPv6 trông như thế nào:
sudo ss -tlnp | grep 8080Từ Docker Engine 27, Docker mặc định quản lý ip6tables. Trên Docker network đã bật IPv6, cổng được publish cũng nhận xử lý DNAT tương tự trong các bảng IPv6. Vì vậy, lỗ hổng bypass tương tự cũng tồn tại ở đây và áp dụng cùng cách khắc phục: chain DOCKER-USER cũng tồn tại trong ip6tables, nên hãy tạo rule tương ứng bằng sudo ip6tables -I DOCKER-USER ... và kiểm tra từ bên ngoài bằng curl tới địa chỉ IPv6 public của server, chẳng hạn curl -6 http://[2001:db8:2a::1]:8080/.
Trên network không có IPv6, các client IPv6 được xử lý bởi docker-proxy, một tiến trình user-space thông thường lắng nghe trên [::]:8080 rồi chuyển tiếp traffic vào container qua IPv4. Traffic tới một host process đi qua INPUT, nên UFW có thể filter đường đi này, nhưng chỉ khi UFW thực sự quản lý IPv6. Việc UFW có quản lý IPv6 hay không, cùng các cách khác khiến VPS phát sinh khoảng trống bảo mật IPv6, được trình bày trong hướng dẫn UFW và IPv6.
Publish trên loopback sẽ loại bỏ toàn bộ vấn đề này: -p 127.0.0.1:8080:80 chỉ bind vào IPv4 loopback, nên không có IPv6 listener và không có gì để truy cập từ bên ngoài trên cả hai stack.
Mẫu thiết kế ổn định
- Publish mọi port nội bộ trên
127.0.0.1để chúng không bị expose ngay từ đầu. - Giao phần public cho một reverse proxy duy nhất quản lý các port 80 và 443.
- Giữ UFW ở chế độ mặc định deny cho host, chỉ allow SSH và các port của proxy.
- Filter các port container thực sự cần public trong
DOCKER-USER, đối chiếu theo port đích ban đầu và lưu persistent trong/etc/ufw/after.rules. - Để nguyên tích hợp iptables của Docker.
Sau khi setup một lần, cách này loại bỏ các bất ngờ: ufw status mô tả host, còn DOCKER-USER mô tả các container. Không có gì được publish ngoài ý muốn, và docker run -p tiếp theo bạn nhập sẽ expose đúng những gì bạn muốn.
FAQ
Vì sao tôi vẫn truy cập được Docker container khi UFW chặn cổng?
Vì Docker publish cổng bằng một DNAT rule trong chain PREROUTING. Rule này đổi đích của packet thành địa chỉ của container trước khi quá trình filtering diễn ra. Sau đó packet đi theo path FORWARD, còn các rule của UFW nằm trong INPUT, là chain mà packet không đi qua. Firewall không được kiểm tra, nên các deny rule của nó không có tác dụng với những cổng được publish bởi container.
Làm cách nào để UFW chặn các cổng được Docker publish?
UFW không thể tự làm việc đó vì các rule của nó nằm sai chain. Bạn có thể ngừng expose cổng bằng cách publish cổng dưới dạng 127.0.0.1:8080:80 để chỉ host truy cập được. Hoặc filter trong chain DOCKER-USER bằng một iptables rule khớp với cổng đích ban đầu thông qua conntrack. Lưu rule đó trong /etc/ufw/after.rules để rule vẫn còn sau reboot và ufw reload.
Tôi có nên đặt "iptables": false trong Docker daemon.json không?
Không. Thiết lập này xóa toàn bộ firewall rule và NAT rule của Docker, gây hỏng nhiều chức năng hơn việc bypass UFW. Container sẽ mất quyền truy cập Internet outbound vì masquerade rule bị xóa. Các cổng được publish cũng ngừng hoạt động vì DNAT rule đã bị xóa. Thay vào đó, hãy dùng loopback publishing và chain DOCKER-USER. Hai cách này xử lý việc expose cổng mà không làm hỏng networking của container.
Docker có bypass UFW trên IPv6 không?
Trên Docker Engine 27 trở lên, việc quản lý ip6tables được bật mặc định. Vì vậy, cổng được publish trên Docker network hỗ trợ IPv6 sẽ được rewrite để đi vòng qua UFW giống như trên IPv4 và cần cùng một DOCKER-USER rule, được mirror bằng ip6tables. Trên các network không có IPv6, tiến trình docker-proxy sẽ listen trên [::]. Lưu lượng đó có đi qua INPUT, nơi UFW có thể filter nếu UFW quản lý IPv6. Publish trên 127.0.0.1 sẽ tránh cả hai trường hợp vì không có tiến trình nào listen trên IPv6.