Cách cấu hình UFW và IPv6 trên VPS an toàn
Kiểm tra xem dịch vụ của bạn có đang bị lộ qua IPv6 hay không. Tránh lỗi chỉ chặn IPv4 bằng UFW trong khi dịch vụ vẫn đang listen công khai trên IPv6.
Bẫy firewall IPv6 trong một câu
Firewall của bạn đang bảo vệ IPv4. VPS của bạn gần như chắc chắn cũng có một địa chỉ IPv6 công khai, và nhiều dịch vụ sẽ lắng nghe (listen) trên đó theo mặc định. Nếu firewall của bạn chỉ bao phủ IPv4, hoặc nếu bạn dựa vào một cloud firewall chỉ lọc IPv4, thì mọi dịch vụ đó đều có thể truy cập được từ toàn bộ internet qua IPv6 trong khi phía IPv4 của bạn trông có vẻ đã được khóa chặt. Bạn kiểm tra một port bằng curl, thấy kết nối bị từ chối (refused connection) và cảm thấy an tâm. Một kẻ tấn công kết nối tới cùng port đó qua IPv6 và xâm nhập vào bên trong.
Hướng dẫn này chỉ ra lỗ hổng đó đến từ đâu trên một VPS Ubuntu 24.04 thông thường, cách để xem chính xác bạn đang để lộ những gì, và cách để đóng nó lại. UFW không phải là thủ phạm ở đây. Trên một bản cài đặt Ubuntu hiện đại, UFW đã xử lý IPv6. Sự lộ lọt đến từ các lớp xung quanh nó, và từ các dịch vụ mà bạn không biết là chúng đang lắng nghe.
Tại sao VPS của bạn lại có IPv6 ngay từ đầu
Hầu hết mọi VPS ngày nay đều đi kèm với một địa chỉ IPv6 công khai, thường là cả một /64, cùng với địa chỉ IPv4 của nó. Hãy kiểm tra của bạn:
ip -6 addr show scope global2: eth0: <BROADCAST,MULTICAST,UP,LOWER_UP> ...
inet6 2001:db8:2a::1/64 scope global2001:db8:2a::1 đó có thể định tuyến (routable) từ bất kỳ đâu trên internet, chính xác như địa chỉ IPv4 của bạn. Bây giờ hãy xem cái gì đang lắng nghe:
sudo ss -tlnpState Recv-Q Local Address:Port Process
LISTEN 0 0.0.0.0:22 sshd
LISTEN 0 [::]:22 sshd
LISTEN 0 127.0.0.1:5432 postgres
LISTEN 0 [::]:8080 docker-proxyHãy đọc kỹ cột Local Address. 0.0.0.0:22 nghĩa là "lắng nghe trên mọi địa chỉ IPv4." [::]:22 nghĩa là "lắng nghe trên mọi địa chỉ IPv6." 127.0.0.1:5432 được bind vào loopback và hoàn toàn không phải là public, nên dòng Postgres là an toàn. Hai dòng [::] đang trả lời toàn bộ internet qua IPv6, và dòng docker-proxy là loại mà bạn thường quên mất là mình đã khởi chạy nó.
Hầu hết các daemon bind vào :: theo mặc định, vì trên Linux, một socket :: thường chấp nhận cả IPv4. Vì vậy, trạng thái mặc định của một server mới là "trả lời trên cả hai stack, ở mọi nơi." Firewall của bạn là thứ duy nhất đứng trước đó, đó là lý do tại sao một firewall chỉ nhìn thấy một stack là một vấn đề thực sự.
Lỗ hổng IPv6 thực sự đến từ đâu
Có bốn nguồn phổ biến. Trên một máy cụ thể, bạn có thể gặp một trong số chúng hoặc nhiều cái cùng lúc.
1. Một cloud firewall chỉ lọc IPv4. Nhiều firewall của nhà cung cấp và các sản phẩm security-group được xây dựng xoay quanh IPv4 nên họ bỏ qua IPv6 hoặc cần các rule IPv6 riêng biệt mà bạn phải thêm thủ công. Nếu firewall duy nhất của bạn là cái nằm trong dashboard của nhà cung cấp và nó không bao phủ IPv6, thì các dịch vụ [::] của bạn sẽ bị hở bất kể nó nói gì về port 22 trên IPv4. Hãy đọc tài liệu firewall của nhà cung cấp và tìm cụ thể từ khóa IPv6.
2. iptables tự viết mà không có ip6tables. Lệnh iptables chỉ tác động đến các bảng IPv4. IPv6 có một lệnh hoàn toàn riêng biệt, ip6tables, với các rule riêng biệt của nó. Nếu bạn viết một script firewall đầy các dòng iptables -A INPUT ... mà không bao giờ viết các rule ip6tables tương ứng, thì firewall IPv6 của bạn đang trống rỗng, và một INPUT chain trống với chính sách default ACCEPT sẽ cho phép mọi thứ:
sudo ip6tables -L INPUT -nChain INPUT (policy ACCEPT)
target prot opt source destinationKết quả đó chính là toàn bộ cái bẫy trên một màn hình. IPv4 được lọc, còn IPv6 chấp nhận cả thế giới.
3. Docker publish port đi xuyên qua firewall. Khi bạn chạy docker run -p 8080:80, Docker chèn các rule riêng của nó lên trước UFW, vì vậy một port đã được publish sẽ có thể truy cập được ngay cả khi ufw status nói rằng port đó bị chặn, và trên Docker hiện đại, điều tương tự cũng áp dụng cho IPv6. Tại sao Docker bypass UFW, và cách lọc các port container đúng cách giải thích cơ chế và cách khắc phục. Xem các kiến thức cơ bản về Docker Compose trên VPS để biết các port được publish này được khai báo như thế nào.
4. UFW bị tắt IPv6. UFW có xử lý IPv6, nhưng chỉ khi được yêu cầu. Kiểm tra switch:
grep IPV6 /etc/default/ufwUbuntu hiện đại đi kèm với IPV6=yes, vì vậy UFW áp dụng mỗi rule cho cả hai stack. Nếu bạn thấy IPV6=no, từ một image cũ hoặc hướng dẫn cũ, thì mọi rule UFW bạn đã viết chỉ dành cho IPv4, và IPv6 bị bỏ mặc không quản lý.
Xem chính xác những gì bạn đang để lộ
Đừng đoán mò. Hãy đo lường nó từ bên ngoài. Đầu tiên, hãy liệt kê các listener của bạn và ghi lại mọi cái được bind vào :::
sudo ss -tlnp | grep '::'Sau đó, từ một máy khác, hãy kết nối tới địa chỉ IPv6 công khai của server và thử một port mà bạn tin là đã đóng:
curl -6 -v http://[2001:db8:2a::1]:8080/Nếu nó trả về một trang web hoặc một banner, port đó đang mở trên IPv6. Một port đóng sẽ trả về Connection refused hoặc timeout. Để có cái nhìn đầy đủ, hãy scan địa chỉ IPv6 bằng nmap từ bên ngoài server:
nmap -6 2001:db8:2a::1Mọi port mà nmap báo là open qua IPv6 là một port mà toàn bộ internet có thể truy cập, bất kể kết quả scan IPv4 của bạn cho thấy gì. So sánh các bản scan IPv4 và IPv6 cạnh nhau là cách nhanh nhất để tìm ra lỗ hổng: bất cứ thứ gì mở trên -6 nhưng đóng trên IPv4 là một dịch vụ mà firewall của bạn đang bỏ sót.
Đóng lỗ hổng
Hãy để UFW bao phủ cả hai stack, và để mặc định là deny. Xác nhận switch, sau đó thiết lập chính sách inbound default-deny và chỉ cho phép những gì bạn cần:
sudo sed -i 's/^IPV6=no/IPV6=yes/' /etc/default/ufw
sudo ufw default deny incoming
sudo ufw allow 22/tcp
sudo ufw enable
sudo ufw status verboseNếu UFW đã đang hoạt động khi bạn lật switch IPV6=yes, thay đổi sẽ không có hiệu lực cho đến khi bạn chạy sudo ufw reload.
ufw status liệt kê mỗi rule hai lần, một lần bình thường và một lần với hậu tố (v6). Khi bạn thấy các dòng (v6), UFW đang lọc IPv6:
22/tcp ALLOW IN Anywhere
22/tcp (v6) ALLOW IN Anywhere (v6)Nếu bạn quản lý iptables bằng tay, hãy mirror mọi rule sang ip6tables, hoặc chuyển sang nftables, nơi các bảng inet bao phủ cả IPv4 và IPv6 tại một nơi và loại bỏ hoàn toàn loại sai lầm này. Một bảng filter nftables inet duy nhất là cách sửa lỗi sạch sẽ nhất khi bạn tự viết các rule.
Bind các dịch vụ mà bạn không muốn công khai vào loopback. Một database, một bảng quản trị, hoặc một endpoint metrics hiếm khi cần một địa chỉ công khai. Hãy bind nó vào 127.0.0.1 và ::1 để nó không bao giờ lắng nghe trên một địa chỉ có thể định tuyến ngay từ đầu. Đối với Postgres, hãy thiết lập listen_addresses = 'localhost'. Đối với một app server, hãy bind nó vào 127.0.0.1 và đặt một reverse proxy ở phía trước. Đóng listener sẽ hiệu quả hơn là dùng firewall, vì khi đó sẽ không có gì để mà kết nối tới.
Đừng tin tưởng UFW để bảo vệ các port đã publish của Docker. Hãy publish các port container tới một địa chỉ cụ thể thay vì mọi interface, ví dụ như -p 127.0.0.1:8080:80, để port đó chỉ có thể truy cập được từ host và bất kỳ thứ gì bạn chủ động proxy tới nó. Khi một container thực sự cần phải là public, hãy đặt nó sau một reverse proxy Traefik và chỉ publish duy nhất proxy, không phải từng app.
Thêm các rule IPv6 vào firewall của nhà cung cấp, hoặc chấp nhận rằng đó không phải là firewall cho IPv6 của bạn và hãy để UFW hoặc nftables trên host làm việc đó thay thế.
Xác minh bạn thực sự đã đóng lỗ hổng
Chạy lại bài test bên ngoài tương tự sau khi thực hiện các thay đổi:
curl -6 -v http://[2001:db8:2a::1]:8080/
nmap -6 2001:db8:2a::1Port đã trả lời trước đó bây giờ sẽ bị từ chối hoặc timeout, và nmap sẽ báo cáo nó là filtered hoặc closed. Nếu một port vẫn còn mở, hãy kiểm tra lại bốn nguồn trên: một dịch vụ vẫn đang bind vào :: mà không có rule phía trước, một rule Docker nằm trước UFW, hoặc một firewall của nhà cung cấp hoàn toàn không thấy IPv6.
Giữ các dịch vụ nhạy cảm hoàn toàn tách biệt khỏi internet công khai sẽ an toàn hơn nữa. Đặt SSH và các bảng quản trị sau một WireGuard VPN và firewall các port của chúng để chúng chỉ trả lời trên tunnel, khi đó vấn đề lộ lọt IPv6 sẽ không còn áp dụng cho chúng nữa. Để làm chậm các đợt brute-force scan đánh vào những gì vẫn còn public, hãy thêm Fail2ban phía trước SSH lên trên một firewall default-deny.
Nếu các port còn quá mới mẻ với bạn, port là gì và các dịch vụ lắng nghe như thế nào là tài liệu cơ bản cần đọc trước.
FAQ
UFW có chặn IPv6 theo mặc định không?
Trên bản cài đặt Ubuntu 24.04 hiện đại, có. UFW đọc IPV6=yes từ /etc/default/ufw và áp dụng mỗi rule cho cả IPv4 và IPv6, và ufw status hiển thị các rule IPv6 với hậu tố (v6). Bẫy xuất hiện khi IPV6=no (từ một image cũ hoặc hướng dẫn cũ), khi bạn dựa vào một firewall của nhà cung cấp chỉ lọc IPv4, hoặc khi Docker publish một port vượt qua UFW. Kiểm tra switch bằng grep IPV6 /etc/default/ufw.
Làm thế nào để tôi kiểm tra VPS của mình đang để lộ gì trên IPv6?
Chạy sudo ss -tlnp và ghi lại mọi listener có địa chỉ local bắt đầu bằng [::], nghĩa là nó trả lời trên mọi interface IPv6. Sau đó, từ một máy khác, hãy test trực tiếp địa chỉ IPv6 công khai của server bằng curl -6 -v http://[YOUR:IPV6::ADDR]:PORT/, hoặc scan nó bằng nmap -6 YOUR:IPV6::ADDR. Bất kỳ port nào mở trên bản scan IPv6 nhưng đóng trên IPv4 chính là lỗ hổng của bạn.
Tại sao tôi có thể truy cập port của Docker container khi UFW nói nó đã bị chặn?
Docker chèn các rule firewall riêng của nó lên trước UFW khi bạn publish một port bằng -p, vì vậy port đã publish vẫn có thể truy cập được mặc dù ufw status liệt kê nó là bị chặn. Điều này xảy ra trên IPv4, và cả trên IPv6 khi hỗ trợ IPv6 của Docker đang bật. Hãy publish tới một địa chỉ cụ thể như -p 127.0.0.1:8080:80, hoặc đặt container sau một reverse proxy và chỉ publish duy nhất proxy.
Tôi có còn cần firewall IPv6 nếu firewall IPv4 của tôi đã vững chắc không?
Có. IPv4 và IPv6 là các stack mạng riêng biệt với các rule firewall riêng biệt. Một bộ rule IPv4 hoàn hảo không có tác dụng gì đối với traffic IPv6. Nếu VPS của bạn có một địa chỉ IPv6 công khai, và hầu hết các VPS đều có, thì bất kỳ dịch vụ nào đang lắng nghe trên :: vẫn sẽ có thể truy cập được qua IPv6 cho đến khi một rule firewall IPv6 hoặc một loopback binding ngăn nó lại.
Làm thế nào để tôi khiến một dịch vụ chỉ lắng nghe trên IPv4, hoặc chỉ trên localhost?
Hãy thiết lập địa chỉ bind của dịch vụ trong cấu hình riêng của nó. Bind vào 127.0.0.1 để chỉ dùng IPv4 loopback, hoặc 0.0.0.0 cho tất cả các địa chỉ IPv4 mà không có listener IPv6. Postgres sử dụng listen_addresses, SSH sử dụng ListenAddress, và hầu hết các app server đều có flag host hoặc bind. Xác nhận kết quả bằng sudo ss -tlnp và kiểm tra xem Local Address không còn hiển thị [::] nữa.