SSD Nodes Learn
Hướng dẫn Matt ConnorBởi Matt Connor · Cập nhật ngày 2026-07-23

Docker bypass UFW và cách khắc phục

Docker tự động chèn rule DNAT vào iptables khiến UFW không thể chặn port. Xem ngay cơ chế và 2 cách fix lỗi lộ port container ra internet.

Tại sao Docker bypass UFW

Docker bypass UFW vì các port của container được publish không bao giờ đi qua các rule firewall mà UFW quản lý. Khi bạn chạy docker run -p 8080:80, Docker viết một rule DNAT (destination network address translation) vào chain PREROUTING của table nat trong kernel. Rule đó sẽ rewrite destination của mỗi packet thành địa chỉ private của container trước khi kernel quyết định packet sẽ đi đâu. Packet sau khi được rewrite sẽ được forward vào container thông qua chain FORWARD do Docker kiểm soát. Các rule của UFW nằm trong chain INPUT, và packet không bao giờ đi vào đó. Vì vậy ufw status hiển thị default deny, sudo ufw deny 8080 báo success, nhưng port 8080 vẫn trả lời toàn bộ internet.

Đây không phải là bug của Docker, và UFW cũng không bị lỗi. Cả hai công cụ đều lập trình trên cùng một kernel firewall. Các rule của Docker đơn giản là hoạt động tại một điểm sớm hơn trên đường đi của packet, nên UFW không bao giờ được hỏi ý kiến. Hướng dẫn này sẽ trình bày việc bypass, giải thích cơ chế, và sau đó đề cập đến hai cách fix 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, hãy thiết lập nó bằng hướng dẫn cơ bản về UFW firewall trước, vì một firewall default-deny vẫn là nền tảng đúng đắn cho mọi thứ khác trên server.

Tự kiểm tra việc bypass trên server của bạn

Bắt đầu với một VPS đang chạy UFW với policy default deny cho incoming traffic. Chạy một web container với một port đã được publish:

sudo ufw status verbose
docker run -d --name web -p 8080:80 nginx:1.29-alpine

ufw status verbose cho thấy Default: deny (incoming), allow (outgoing) và không có rule nào cho port 8080. Theo báo cáo của chính firewall, port này đang đóng. Bây giờ hãy test từ một máy khác, không phải từ chính server:

curl -I http://your-vps-ip:8080/
HTTP/1.1 200 OK

Container vẫn trả lời. Thêm một rule deny tường minh và test lại:

sudo ufw deny 8080/tcp

Port vẫn trả lời, vì rule deny nằm trong một chain mà packet không bao giờ đi qua. UFW không hề thất bại. Nó không bao giờ được hỏi ý kiến. Đây cũng là lý do tại sao vấn đề này rất khó bị phát hiện: không có lỗi nào được in ra, việc deploy vẫn hoạt động, và output status của firewall trông hoàn toàn giống như một server được bảo mật tốt.

Cơ chế: PREROUTING chạy trước INPUT

Kernel xử lý một incoming packet 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 ở đây có thể rewrite destination của packet, và rule của Docker cho một port đã publish làm chính xác điều đó.
  2. Quyết định routing (routing decision) diễn ra tiếp theo. Một packet có địa chỉ đích là chính host sẽ đi vào chain INPUT. Một packet có địa chỉ đích là bất kỳ máy nào khác sẽ đi vào chain FORWARD.
  3. Các rule của UFW nằm trong INPUT. Các rule của Docker nằm trong FORWARD.

Xem rule của Docker cho container bạn vừa khởi chạy:

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 là toàn bộ câu chuyện. Bất kỳ packet nào đến cho port 8080 đều bị rewrite destination thành 172.17.0.2:80, địa chỉ của container trên Docker private bridge network. Sau khi rewrite, packet không còn hướng tới host nữa, nên quyết định routing sẽ gửi nó theo đường FORWARD, nơi Docker đã thêm các rule chấp nhận traffic vào các network riêng của nó. Rule deny 8080/tcp của bạn nằm chờ ở INPUT cho một packet không bao giờ tới.

Trên Ubuntu 24.04, lệnh iptables là một front end cho nftables, nhưng thứ tự chain và kết quả là như nhau. UFW và Docker đều viết vào cùng một kernel packet pipeline, và điểm bắt đầu của Docker nằm sớm hơn.

Cách fix thông dụng: publish port trên 127.0.0.1

Hầu hết các container ban đầu không cần phải public. Một database, một app server đứng sau reverse proxy, một admin panel, một metrics endpoint: không có cái nào trong số này nên trả lời internet trực tiếp. 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 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 bây giờ chỉ khớp với các packet có địa chỉ đích là 127.0.0.1, và một packet từ internet không bao giờ có thể mang địa chỉ đích hợp lệ đó, nên kernel sẽ drop nó trước khi bất kỳ rule firewall nào chạy. Port này chỉ có thể truy cập được từ host, và không từ đâu khác. Kiểm tra binding:

sudo ss -tlnp | grep 8080

Bạn sẽ thấy 127.0.0.1:8080 trong output, chứ không phải 0.0.0.0:8080 hay [::]:8080. Sau đó xác nhận từ một máy khác rằng curl http://your-vps-ip:8080/ bị refused.

Đối với các service cần tiếp xúc với internet, hãy chạy một reverse proxy duy nhất nắm giữ các port 80 và 443 và route theo hostname, và không publish gì khác. Đó là pattern mà hướng dẫn reverse proxy Traefik xây dựng, và đó là cách một app self-hosted như Nextcloud trên VPS không thể bị truy cập ngoại trừ thông qua proxy của nó. Cách khai báo các entry ports:, và phần còn lại của workflow Compose, được đề cập 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 sẽ quay lại làm công việc bình thường của nó: bảo vệ các port mà chính host đang phục vụ. Hãy xây dựng rule set ở đây, sau đó chạy các lệnh theo thứ tự:

ToolUFW rule generator

Filter thực sự: chain DOCKER-USER

Đôi khi một port của container phải được publish ra network nhưng cần bị giới hạn, ví dụ như một port database replica mà chỉ một địa chỉ văn phòng mới được phép truy cập. Để làm việc đó, Docker cung cấp chain DOCKER-USER. Mọi packet hướng tới bất kỳ container nào đều đi qua DOCKER-USER trước các rule accept của Docker, và Docker không bao giờ viết rule vào chain này. Chain này tồn tại để dành cho bạn, và Docker sẽ giữ nguyên nội dung của nó ngay cả khi daemon restart.

Một bẫy cần lưu ý trước khi dùng lệnh: tại thời điểm packet đến DOCKER-USER, việc rewrite DNAT đã hoàn tất. Port đích của packet là port của container (80 trong ví dụ của chúng ta), không phải port đã publish (8080). Do đó, một rule khớp với --dport 8080 sẽ không khớp với gì cả. Cách đáng tin cậy là khớp với port mà client đã gọi ban đầu, thứ mà connection tracker của kernel ghi nhớ:

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

Có thể hiểu là: đối với các packet đi vào qua eth0 và thuộc về một connection có port đích ban đầu là 8080, hãy drop mọi thứ không được gửi từ 10.0.0.10. Việc khớp --ctdir ORIGINAL giới hạn rule cho hướng client-to-container, để các packet phản hồi không bị bắt nhầm. Thay thế eth0 bằng interface public của bạn; ip route | grep default là tên của nó. Test nó tương tự như trước: curl từ địa chỉ được cho phép sẽ thành công, và từ bất kỳ đâu khác kết nối sẽ bị timeout.

Các rule được thêm bằng lệnh iptables sẽ mất khi reboot. Vì UFW đã quản lý firewall này, nơi sạch sẽ nhất để lưu trữ 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ẽ chạy lại file đó mỗi khi reload hoặc boot, vì vậy việc filter container của bạn giờ đây nằm cùng nơi với phần còn lại của firewall, và nó tồn tại qua cả khi reboot và khi upgrade Docker.

Tại sao bạn không nên disable Docker's iptables integration

Các câu trả lời cũ cho vấn đề này gợi ý thiết lập { "iptables": false } trong /etc/docker/daemon.json. Đừng làm vậy. Các rule firewall của Docker làm nhiều việc hơn là chỉ publish port. Rule masquerade là thứ giúp các container có quyền truy cập internet outbound thông qua địa chỉ của host, nên nếu tắt integration, các container không thể pull images, không thể kết nối tới các package mirrors, hoặc gọi bất kỳ external API nào. Các rule DNAT là thứ giúp -p hoạt động, nên nếu tắt, các port đã publish sẽ ngừng hoạt động hoàn toàn. Các rule isolation để giữ các network Compose riêng biệt cũng sẽ biến mất. Bạn sẽ sửa lỗi bypass bằng cách làm hỏng networking của container, và mọi rule đó sẽ trở thành việc của bạn phải tự viết và duy trì bằng tay. Tài liệu chính thức của Docker mô tả setting này dành cho những người có ý định làm chính xác điều đó. Chain DOCKER-USER tồn tại chính xác để không ai cần đến switch này.

Vấn đề IPv6 tương tự

Trước tiên hãy kiểm tra port đã publish trông như thế nào trên IPv6:

sudo ss -tlnp | grep 8080

Từ Docker Engine 27, Docker quản lý ip6tables theo mặc định. Trên một Docker network đã bật IPv6, một port được publish sẽ nhận được xử lý DNAT tương tự trong các table IPv6, nên lỗi bypass tương tự cũng tồn tại ở đó và cách fix cũng tương tự: chain DOCKER-USER cũng tồn tại trong ip6tables, vì vậy hãy mirror rule của bạn với sudo ip6tables -I DOCKER-USER ... và test từ bên ngoài bằng curl tới địa chỉ IPv6 public của server, ví dụ curl -6 http://[2001:db8:2a::1]:8080/.

Trên một network không có IPv6, các client IPv6 sẽ được xử lý bởi docker-proxy, một user-space process bình thường lắng nghe trên [::]:8080 và forward traffic vào container qua IPv4. Traffic đến một host process sẽ đi qua INPUT, nên UFW có thể filter đường đó, nhưng chỉ khi UFW có quản lý IPv6. Việc nó có quản lý hay không, và các cách khác khiến lỗ hổng IPv6 xuất hiện trên VPS, là chủ đề của hướng dẫn về UFW và IPv6.

Publish trên loopback sẽ loại bỏ hoàn toàn vấn đề này: -p 127.0.0.1:8080:80 chỉ bind IPv4 loopback, nên không có listener IPv6 và không có gì có thể truy cập từ bên ngoài trên cả hai stack.

Pattern bền vững

  • Publish mọi port nội bộ trên 127.0.0.1, để nó không bao giờ bị lộ ngay từ đầu.
  • Chỉ để một reverse proxy duy nhất tiếp xúc với phía public, nắm giữ các port 80 và 443.
  • Giữ UFW default deny cho host, chỉ cho phép SSH và các port của proxy.
  • Filter các port container thực sự public trong DOCKER-USER, khớp theo port đích ban đầu, và lưu trữ trong /etc/ufw/after.rules.
  • Giữ Docker's iptables integration ở trạng thái ON.

Thiết lập một lần, điều này sẽ loại bỏ sự bất ngờ: ufw status mô tả host, và DOCKER-USER mô tả các container. Không có gì bị publish do vô ý, 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

Tại sao tôi có thể truy cập Docker container khi UFW đang block port?

Vì Docker publish port bằng một rule DNAT trong chain PREROUTING, rule này rewrite destination của packet thành địa chỉ của container trước khi bất kỳ việc filtering nào diễn ra. Sau đó packet đi theo đường FORWARD, trong khi các rule của UFW nằm ở chain INPUT, một chain mà packet không bao giờ đi vào. Firewall không bao giờ được hỏi ý kiến, nên các rule deny của nó không có tác dụng với các port container đã được publish.

Làm thế nào để UFW block các port đã publish của Docker?

Bản thân UFW không thể làm được, vì các rule của nó nằm ở sai chain. Hoặc là ngừng expose port đó bằng cách publish nó dưới dạng 127.0.0.1:8080:80 để chỉ host có thể truy cập, hoặc filter trong chain DOCKER-USER bằng một iptables rule khớp với port đích ban đầu thông qua conntrack. Lưu rule đó vào /etc/ufw/after.rules để nó tồn tại qua các lần reboot và ufw reload.

Tôi có nên đặt "iptables": false trong Docker's daemon.json không?

Không. Setting đó loại bỏ tất cả các rule firewall và NAT của Docker, điều này gây ra nhiều lỗi hơn là chỉ lỗi bypass. Containers sẽ mất quyền truy cập internet outbound vì rule masquerade đã mất, và các port đã publish sẽ ngừng hoạt động vì các rule DNAT đã mất. Hãy dùng loopback publishing và chain DOCKER-USER thay thế; chúng giải quyết việc lộ port mà không làm hỏng networking của container.

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

Trên Docker Engine 27 trở lên, quản lý ip6tables được bật mặc định, nên một port được publish trên Docker network đã bật IPv6 sẽ bị rewrite quanh UFW giống hệt như trên IPv4, và cần một rule DOCKER-USER tương tự được mirror với ip6tables. Trên các network không có IPv6, process docker-proxy lắng nghe trên [::] và traffic đó sẽ đi qua INPUT, nơi UFW có thể filter nếu UFW quản lý IPv6. Publish trên 127.0.0.1 tránh được cả hai trường hợp, vì không có gì lắng nghe trên IPv6 cả.