The tunnel drops, the kill switch reports that it engaged, and a download keeps running. Nothing is broken in the IPv4 rules. The application simply moved to a path those rules were never written for.

Dual-stack devices carry two independent ways out. A block on one of them is half a block.

Two Rule Sets, Two Defaults

On Linux, iptables and ip6tables are separate tables with separate policies. Setting the INPUT and OUTPUT chains of the IPv4 table to DROP changes nothing in the IPv6 table, where the default is usually ACCEPT. The same split exists in nftables, where you can inspect a family and find that only one of them carries your rules. That asymmetry is normal, and it is the entire problem.

This is why a kill switch can look thorough and behave partially. The script that wrote the IPv4 rules ran, the counters increment, the log line appears, and an IPv6 packet leaves through the physical interface entirely untouched.

Read what your client actually installs rather than what the marketing page promises. Some clients route IPv6 into the tunnel, some deliberately block it, and some leave it alone while a platform-level lockdown enforces a different rule. The vendor documentation is the only source worth trusting here.

What the Interface Configuration Tells You

wg-quick's manual page includes IPv6 rules in its example configuration, and the reason is exactly this gap. The example is not decoration. It exists because the IPv4 rules alone would leave the second family open.

Copying half an example produces half a policy. That is the most common origin of this leak. A blog post trimmed the example for length. The trimmed version then became the template everyone reused.

# what is actually installed, per family?
sudo iptables -S OUTPUT | head -20
sudo ip6tables -S OUTPUT | head -20

# nftables: inspect each family separately
sudo nft list ruleset inet | head -40

# does this host even have a usable IPv6 path?
ip -6 route show default

If the second command prints a permissive policy while the first prints DROP, you have found the answer without touching the client. That asymmetry is the bug.

Why One Lookup Is Not the Whole Story

A single name can return both an A record and an AAAA record, and applications do not all choose the same way. A browser may prefer IPv6 when the network offers it and fall back on failure, while a background sync or an updater uses its own resolver and its own preference. One successful test over IPv4 therefore proves one path, not the policy.

Network transitions change the answer as well. Joining a different Wi-Fi network, resuming from sleep or switching to cellular can hand the device a new IPv6 prefix, and the policy that looked adequate on the office LAN may not cover it.

Test the State Where the Tunnel Is Down

Testing while connected tells you the tunnel works. The interesting test is the unavailable state, because that is when the kill switch is supposed to hold. Stop the tunnel on purpose, then try the destinations that matter from the app you are protecting.

# with the tunnel stopped, does anything still leave?
ip -6 route get 2606:4700:4700::1111
curl -6 -sS -o /dev/null -w '%{http_code}\n' --max-time 5 https://ipv6.example.net/
curl -4 -sS -o /dev/null -w '%{http_code}\n' --max-time 5 https://ipv4.example.net/

Both families should fail closed. If the IPv4 request times out and the IPv6 request returns a status code, the policy is incomplete, and no amount of retesting the tunnel will change that.

Keep the documented exceptions in that test as well. Local network discovery, a DHCP renewal or the bootstrap path to the VPN endpoint may need to stay reachable, and those exceptions should be named in the policy rather than discovered during an outage. A leak test that fails because the tunnel cannot come back up is not a security finding, it is a connectivity finding.

ተዛማጅ ጽሑፎች