SSD Nodes Learn Hosting plans →
Hướng dẫn Matt ConnorBởi Matt Connor

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 do 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 DOCKER riêng của nó trong table nat của kernel. Chain PREROUTING sẽ jump đến chain này. Rule đó đổi destination của từng packet thành địa chỉ private của container trước khi kernel quyết định packet sẽ đi đâu. Sau đó packet đã được rewrite đượ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, nhưng 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 của kernel. Các rule của Docker chỉ được xử lý sớm hơn trên đường đi của packet, nên UFW không được áp dụng. Hướng dẫn này minh họa cơ chế bypass, giải thích cách nó hoạt động, rồi trình bày 2 cách khắc phục hiệu quả: publish port trên 127.0.0.1 và filter trong chain DOCKER-USER. Nếu bạn chưa quen với UFW, trước tiên hãy thiết lập nó theo hướng dẫn 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-alpine

ufw 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 OK

Container 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/tcp

Port 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 đi vào theo một thứ tự cố định, và toàn bộ vấn đề nằm ở thứ tự đó.

  1. PREROUTING chạy trước. Các rule được xử lý từ đây có thể ghi lại đích của packet. Rule của Docker cho published port, trong chain DOCKER mà PREROUTING chuyển tiếp đến, làm đúng việc đó.
  2. 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 chain FORWARD.
  3. Rule của UFW nằm trong INPUT. Rule của Docker nằm trong FORWARD.

Hãy xem rule của Docker dành cho container bạn vừa khởi động:

sudo iptables -t nat -L DOCKER -n
Chain 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

Dòng DNAT giải thích toàn bộ vấn đề. Mọi packet đến port 8080 đều bị ghi lại địa chỉ đích thành 172.17.0.2:80, là địa chỉ của container trên Docker private bridge network. Sau khi ghi lại địa chỉ, packet không còn có đích là host nữa. Vì vậy, quyết định định tuyến đưa packet theo đường FORWARD, nơi Docker đã thêm sẵn các rule cho phép traffic đi vào các network của chính nó. Rule deny 8080/tcp của bạn chờ trong INPUT một packet không bao giờ đến.

Trên Ubuntu 24.04, lệnh 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 đều ghi rule vào cùng một pipeline xử lý packet của kernel, còn 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ị bỏ qua bởi cùng rule DNAT. Vì vậy, các cách sửa dưới đây cũng là những cách bạn cần dùng trong trường hợp đó.

Cách xử lý thường dùng: publish port trên 127.0.0.1

Hầu hết container ngay từ đầu không cần public. Database, app server đứng sau reverse proxy, admin panel và metrics endpoint không nên trực tiếp nhận kết nối 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-alpine

Hoặc khai báo 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. Trên Docker Engine 28.0.0 trở lên, Docker cũng thêm một rule vào chain PREROUTING của table raw để drop mọi packet gửi đến port loopback đó nếu packet đi vào từ interface không phải loopback. Trên các bản này, port chỉ truy cập được từ host và không thể truy cập từ nơi khác. Với các bản cũ hơn 28.0.0, Docker ghi rõ rằng các host trong cùng một segment L2, chẳng hạn các máy cắm chung một network switch, vẫn có thể truy cập những port được publish vào localhost. Vì vậy, hãy kiểm tra docker version và upgrade trước khi dựa vào cơ chế này. Xác minh binding:

sudo ss -tlnp | grep 8080

Output 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/ không nhận được phản hồi. Packet gửi đến địa chỉ public của bạn không còn bị Docker rewrite, nên sẽ đi đến INPUT. Tại đó, default deny policy của UFW và rule deny 8080/tcp ở phần trước sẽ âm thầm drop packet thay vì reject packet. Vì vậy, curl sẽ chờ rồi kết thúc bằng connection timeout, không phải lỗi "connection refused".

Với những 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 triển khai 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 theo cách nào khác ngoài thông 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 trở lại đúng vai trò thông thường: bảo vệ các port do chính host cung cấp. Hãy xây dựng bộ rule tại đây, sau đó chạy các lệnh theo đúng thứ tự:

ToolUFW rule generator

Lọc thực tế: chain DOCKER-USER

Đôi khi một cổng của container phải tiếp tục được publish ra network nhưng cần bị giới hạn. Ví dụ, chỉ một địa chỉ văn phòng được phép truy cập vào 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. Chain này dành cho các rule của bạn, và Docker không xóa hoặc sửa chúng, kể cả khi daemon restart.

Chain này thuộc backend firewall iptables mặc định của Docker. Docker Engine 29 bổ sung backend nftables thử nghiệm. Bạn phải bật backend này bằng tùy chọn daemon firewall-backend. Với backend đó, không có chain DOCKER-USER. Bạn đặt rule lọc tùy chỉnh trong một nftables table riêng của mình, với một base chain trên hook forward. Nếu bạn chưa thay đổi tùy chọn đó, bạn đang dùng iptables và toàn bộ phần bên dưới áp dụng được. Sự khác nhau giữa hai công cụ được trình bày trong hướng dẫn về iptables và nftables.

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à port của container (80 trong ví dụ này), không phải port được publish (8080). Vì vậy, rule khớp với --dport 8080 sẽ không khớp packet nào. Cách đáng tin cậy là khớp port mà client đã gọi ban đầu. Kernel lưu thông tin này trong connection tracker:

sudo iptables -I DOCKER-USER -i eth0 -p tcp -m conntrack --ctorigdstport 8080 --ctdir ORIGINAL ! -s 10.0.0.10 -j DROP

Hiểu rule này như sau: với các packet đi vào qua eth0 và thuộc một connection có destination port ban đầu là 8080, drop mọi packet không được gửi từ 10.0.0.10. Match --ctdir ORIGINAL giới hạn rule cho chiều client đến container, nên packet phản hồi 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 thì connection sẽ timeout. Trạng thái chờ đó là dấu hiệu cho thấy rule DROP đang hoạt động, thay vì phía sau port không có gì. 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 port bị lọc với 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, vị trí 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
COMMIT

Sau đó chạy sudo ufw reload. UFW sẽ áp dụng lại file đó sau mỗi lần reload và mỗi lần boot. Vì vậy, rule lọc container nằm cùng chỗ với phần còn lại của firewall và vẫn tồn tại sau cả reboot lẫn khi Docker được upgrade.

Vì sao không nên tắt tích hợp iptables của Docker

Các hướng dẫn cũ về 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 firewall rule của Docker không chỉ dùng để publish port. Rule masquerade cho phép container truy cập Internet bên ngoài thông qua địa chỉ của host. Khi tắt tích hợp này, container không thể truy cập các package mirror hoặc gọi API bên ngoài. Các rule DNAT và isolation cũng bị gỡ bỏ. Tài liệu Docker cảnh báo rằng nếu không có rule thay thế, các port của container sẽ trở nên có thể truy cập từ những host trong mạng cục bộ. Bạn sẽ phải khắc phục đường bypass bằng cách phá vỡ kết nối mạng của container. Đồng thời, bạn phải tự viết và bảo trì tất cả các rule đó. Tài liệu chính thức của Docker cho biết thiết lập này không phù hợp với hầu hết người dùng. Tài liệu cũng mô tả việc tạo các rule sau đó là cực kỳ phức tạp. Chain DOCKER-USER tồn tại chính xác để không ai cần dùng switch này.

Phía 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 8080

Kể từ Docker Engine 27, Docker mặc định quản lý ip6tables. Trên Docker network đã bật IPv6, cổng được publish cũng được DNAT trong các bảng IPv6. Vì vậy, lỗ hổng bypass tương tự cũng tồn tại ở đó và cách khắc phục cũng giống nhau: 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 đến địa chỉ IPv6 public của server, ví dụ curl -6 http://[2001:db8:2a::1]:8080/. Điều này áp dụng cho backend iptables mặc định của Docker. Với backend nftables thử nghiệm, không có chain DOCKER-USER trên cả hai stack. Khi đó, việc lọc IPv6 nằm trong nftables table ip6 do bạn tự quản lý.

Trên network không có IPv6, các client IPv6 thay vào đó đượ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 và chuyển tiếp traffic vào container qua IPv4. Traffic đến một host process vẫn đi qua INPUT, nên UFW có thể lọc đườ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 xuất hiện lỗ hổng IPv6, được trình bày trong hướng dẫn về 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 loopback IPv4, nên không có IPv6 listener. Với Docker Engine 28.0.0 trở lên, từ bên ngoài cũng không thể truy cập dịch vụ trên cả hai stack.

Mô hình bền vững

  • Publish mọi port nội bộ trên 127.0.0.1 để chúng không bị expose ngay từ đầu, với Docker Engine 28.0.0 trở lên (các bản cũ cho phép host trong cùng L2 segment truy cập).
  • Giao phần public cho một reverse proxy duy nhất, quản lý các port 80 và 443.
  • Giữ UFW ở chế độ deny mặc định cho host, chỉ allow SSH và các port của proxy.
  • Lọc các port container thực sự public trong DOCKER-USER trên backend iptables mặc định của Docker, đối chiếu theo port đích ban đầu và lưu cấu hình trong /etc/ufw/after.rules.
  • Để Docker bật tích hợp với iptables.

Sau khi thiết lập một lần, cách này loại bỏ phần 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à lệnh docker run -p tiếp theo bạn gõ sẽ expose chính xác 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 rule DNAT trong chain DOCKER của table nat, được truy cập từ PREROUTING. Rule này đổi đích của packet thành địa chỉ của container trước khi packet đi qua bất kỳ bước filtering nào. Sau đó packet đi theo đường FORWARD, còn rule của UFW nằm trong INPUT, là chain mà packet không đi qua. Firewall không bao giờ được kiểm tra, nên các rule deny của firewall không có tác dụng với các cổng đã publish của container.

Làm cách nào để UFW chặn các cổng Docker đã publish?

UFW không thể tự làm việc này vì các rule của UFW nằm trong chain không phù hợp. 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 cổng đó trên Docker Engine 28.0.0 trở lên. Hoặc bạn có thể filter trong chain DOCKER-USER bằng một rule iptables khớp với cổng đích ban đầu thông qua conntrack. Lưu rule đó vào /etc/ufw/after.rules để rule vẫn tồn tại sau khi reboot và ufw reload.

Có nên đặt "iptables": false trong Docker daemon.json không?

Không. Thiết lập này xóa phần lớn các rule firewall và NAT của Docker, gây hỏng nhiều chức năng hơn cả vấn đề bypass. Container mất quyền truy cập Internet outbound vì masquerade rule không còn. Docker cũng cảnh báo rằng nếu không có rule thay thế, các cổng của container sẽ có thể được các host trong mạng cục bộ truy cập. 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 network của container.

Docker có bypass UFW trên IPv6 không?

Trên Docker Engine 27 trở lên, quản lý ip6tables được bật mặc định. Vì vậy, một cổng được publish trên Docker network có bật IPv6 sẽ được rewrite để đi vòng qua UFW giống như trên IPv4. Với iptables backend mặc định của Docker, bạn cần mirror rule DOCKER-USER tương tự bằng ip6tables. Trên nftables backend thử nghiệm từ Engine 29, không có chain DOCKER-USER, nên việc filter phải được thực hiện trong nftables table riêng của bạn. Trên các network không có IPv6, tiến trình docker-proxy sẽ listen trên [::]. Lưu lượng đó đ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.