how to fix UFW IPv6 security gap on VPS
Your UFW rules fit only cover IPv4 while IPv6 dey open for attackers. Learn how to check and close that gap for your Ubuntu 24.04 VPS today.
IPv6 firewall trap na one sentence
Firewall dey protect IPv4. Your VPS almost certainly get public IPv6 address too, and many services dey listen for am by default. If your firewall only cover IPv4, or if you dey use cloud firewall wey only filter IPv4, every one of those services go dey open for the whole internet over IPv6, even though your IPv4 side look like say e lock. You test one port with curl, you see connection refused, and you feel safe. An attacker connect to the same port over IPv6 and enter inside.
This guide go show where that gap dey come from for normal Ubuntu 24.04 VPS, how to see exactly wetin you dey expose, and how to close am. UFW no be the problem here. For modern Ubuntu install, UFW already dey handle IPv6. The exposure dey come from the layers wey surround am, and from services wey you no know say dey listen.
Why your VPS dey use IPv6 for first place
Almost every VPS wey dey fly today come with public IPv6 address, often a whole /64, alongside its IPv4 address. Check yours:
ip -6 addr show scope global2: eth0: <BROADCAST,MULTICAST,UP,LOWER_UP> ...
inet6 2001:db8:2a::1/64 scope globalThat 2001:db8:2a::1 dey routable from anywhere for internet, just like your IPv4 address. Now look at wetin dey listen:
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-proxyLook the Local Address column well. 0.0.0.0:22 mean "listen on every IPv4 address." [::]:22 mean "listen on every IPv6 address." 127.0.0.1:5432 dey bound to loopback and e no be public at all, so the Postgres line safe. The two [::] lines dey answer the whole internet over IPv6, and the docker-proxy one na the kind wey you fit forget say you start am.
Most daemons dey bind to :: out of the box, because for Linux, a :: socket usually dey accept IPv4 as well. So the default posture of fresh server na "answer on both stacks, everywhere." Your firewall na the only thing wey dey stand for front of am, na why firewall wey dey see only one stack na big problem.
Wey the IPv6 gap dey come from
Four things dey cause dis problem. For one machine, you fit get one or more of dem at the same time.
1. Cloud firewall wey dey filter IPv4 only. Many provider firewalls and security-group products start since IPv4 days, so dem either ignore IPv6 or dem need separate IPv6 rules wey you must add by hand. If your only firewall na the one for provider dashboard and e no cover IPv6, your [::] services open regardless of wetin e talk about port 22 for IPv4. Read your provider firewall documentation and look for the word IPv6 specifically.
2. Hand-rolled iptables wey no get ip6tables. The iptables command dey touch IPv4 tables only. IPv6 get different command, ip6tables, with its own separate rules. If you write firewall script full of iptables -A INPUT ... lines and you never write the matching ip6tables rules, your IPv6 firewall empty, and empty INPUT chain wey get default ACCEPT policy dey allow everything:
sudo ip6tables -L INPUT -nChain INPUT (policy ACCEPT)
target prot opt source destinationDat output na the whole trap for one screen. IPv4 dey filtered, IPv6 dey accept everybody.
3. Docker dey publish ports straight past your firewall. When you run docker run -p 8080:80, Docker dey insert its own rules before UFW, so published port dey reachable even when ufw status say dat port dey denied, and for modern Docker, de same thing dey happen for IPv6. Why Docker bypasses UFW, and how to filter container ports properly dey explain de mechanism and de fixes. See the basics of Docker Compose on a VPS for how dem dey declare dese published ports.
4. UFW wey IPv6 don switch off. UFW dey handle IPv6, but only when you tell am. Check de switch:
grep IPV6 /etc/default/ufwModern Ubuntu dey ship IPV6=yes, so UFW dey apply every rule for both stacks. If you see IPV6=no, from old image or old guide, every UFW rule wey you write na IPv4 only, and IPv6 dey left without management.
See exactly wetin you dey expose
No guess. Measure am from outside. First, list your listeners and note every one wey bind to :::
sudo ss -tlnp | grep '::'Then, from another machine, connect to the server public IPv6 address and try one port wey you think say dey close:
curl -6 -v http://[2001:db8:2a::1]:8080/If e return one page or one banner, the port open for IPv6. If port dey close, you go see Connection refused or timeout. To see everything well-well, use nmap scan the IPv6 address from outside the server:
nmap -6 2001:db8:2a::1Every port wey nmap say open for IPv6 na port wey whole internet fit reach, no matter wetin your IPv4 scan show. To compare IPv4 and IPv6 scans side by side na the fastest way to find the gap: anything wey open for -6 but dey close for IPv4 na service wey your firewall miss.
Close the gap
Make UFW cover both stacks, and set default to deny. Confirm say the switch don work, then set default-deny inbound policy and allow only wetin you need:
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 verboseIf UFW don already dey active when you flip IPV6=yes, the change no go work until you run sudo ufw reload.
ufw status list every rule twice, once plain and once with (v6) suffix. When you see (v6) lines, UFW dey filter IPv6:
22/tcp ALLOW IN Anywhere
22/tcp (v6) ALLOW IN Anywhere (v6)If you dey manage iptables manually, mirror every rule for ip6tables, or move to nftables, wey inet tables cover IPv4 and IPv6 for one place and stop dis kind mistake. One single nftables inet filter table na di cleanest fix if you dey write rules yourself.
Bind services wey you no want public to loopback. Database, admin panel, or metrics endpoint rarely need public address at all. Bind am to 127.0.0.1 and ::1 so e no go listen on any routable address at all. For Postgres, set listen_addresses = 'localhost'. For app server, bind am to 127.0.0.1 and put reverse proxy for front. To close di listener better pass to firewall am, because if you close am, nothing go dey there for person to reach.
No trust UFW to guard Docker's published ports. Publish container ports to one specific address instead of every interface, for example -p 127.0.0.1:8080:80, so dat port go only dey reachable from host and wetin you choose to proxy to am. When container must be public, put am behind a Traefik reverse proxy and publish only di proxy, no be every app.
Add IPv6 rules to your provider firewall, or accept say dat firewall no be your firewall for IPv6 and let UFW or nftables for host do dat job instead.
Verify say you don close well-well
Run the same outside test again after you change things:
curl -6 -v http://[2001:db8:2a::1]:8080/
nmap -6 2001:db8:2a::1The port wey answer before suppose refuse or time out now, and nmap suppose report say e be filtered or closed. If port still open, check the four sources wey dey above again: service still bound to :: without rule for front, Docker rule wey dey front of UFW, or provider firewall wey no see IPv6 at all.
To keep sensitive services far from public internet entirely na better way. Put SSH and admin panels behind a WireGuard VPN and firewall their ports so dem go answer only for inside tunnel, then the IPv6 exposure problem no go affect dem again. To slow down the brute-force scans wey dey hit wetin still dey public, layer Fail2ban in front of SSH for top of default-deny firewall.
If you be new to ports, what ports are and how services listen na the primer wey you suppose read first.
FAQ
UFW dey block IPv6 by default?
For modern Ubuntu 24.04 install, e dey block am. UFW dey read IPV6=yes from /etc/default/ufw and e dey apply every rule for both IPv4 and IPv6, and ufw status dey show IPv6 rules with (v6) suffix. Problem dey when IPV6=no (from old image or old tutorial), when you dey use provider firewall wey only dey filter IPv4, or when Docker publish port pass UFW. Check the switch with grep IPV6 /etc/default/ufw.
How I fit check wetin my VPS dey expose for IPv6?
Run sudo ss -tlnp and look for every listener wey local address start with [::], wey mean say e dey answer for every IPv6 interface. Then, from another machine, test the server public IPv6 address directly with curl -6 -v http://[YOUR:IPV6::ADDR]:PORT/, or scan am with nmap -6 YOUR:IPV6::ADDR. Any port wey open for IPv6 scan but closed for IPv4 na your gap.
Why I fit reach my Docker container port when UFW say e dey blocked?
Docker dey insert its own firewall rules before UFW rules when you publish port with -p, so the published port go dey reachable even though ufw status list am as denied. Dis one dey happen for IPv4, and e dey happen for IPv6 too if Docker IPv6 support dey on. Publish to specific address like -p 127.0.0.1:8080:80, or put the container behind reverse proxy and publish only the proxy.
I still need IPv6 firewall if my IPv4 firewall strong?
Yes. IPv4 and IPv6 na separate network stacks wey get separate firewall rules. Perfect set of IPv4 rules no dey do anything for IPv6 traffic. If your VPS get public IPv6 address, and almost all VPS get am, then any service wey dey listen on :: go still dey reachable over IPv6 until IPv6 firewall rule or loopback binding stop am.
How I fit make service listen for IPv4 only, or localhost only?
Set the service bind address for inside its own config. Bind to 127.0.0.1 for IPv4 loopback only, or 0.0.0.0 for all IPv4 addresses without IPv6 listener. Postgres dey use listen_addresses, SSH dey use ListenAddress, and most app servers dey get host or bind flag. Confirm the result with sudo ss -tlnp and check say Local Address no dey show [::] again.