Avoiding Tunnel Vision on Linux

Malicious DHCP servers can subvert WireGuard and other VPN technologies by pushing unwanted DNS severs (DHCP option 6) or routes (DHCP option 121) to computers and other networked devices on the same subnet as the malicious server. The latter is the basis for the TunnelVision vulnerability (CVE-2024-3661) publicized by the Leviathan Security Group last week.

This is especially a concern on public Wi-Fi networks (like you’d typically find at a coffee shop, hotel, or airport), to which an adversary could easily add a rouge DHCP server without anyone noticing. This could also happen on any wired or wireless network segment into which an adversary could plug a device (or take over an existing device); but most professionally-maintained networks use techniques such as DHCP Snooping to protect against unauthorized DHCP servers.

From a security perspective, the impact of this vulnerability is that an adversary could direct a victim’s computer to send traffic outside a VPN tunnel when the victim believes it is being sent inside the tunnel — including, at worst, sending it to a server controlled by the adversary; or, at least, onto the victim’s LAN subnet where it might be sniffed by other devices on the subnet. In the worst case, this could allow the adversary to capture and manipulate the traffic, if the traffic is in plaintext or protected only by opportunistic encryption (such as STARTTLS, or TLS without certificate verification).

Unfortunately, option 6 (for DNS) and option 121 (for routes) is enabled by default on the DHCP client of most devices, and can be difficult to disable. If you use a Linux laptop that may occasionally connect to public Wi-Fi (or other untrusted networks), this is what you should do:

Enable Trusted Public DNS Servers

The first thing to do is configure your global DNS settings to use a trusted set of public DNS servers that you can safely use from untrusted networks — and enable the use of DoT (DNS over TLS). If using systemd-resolved for DNS, update your /etc/systemd/resolved.conf file to use the Quad9 DNS servers over TLS:

# /etc/systemd/resolved.conf
DNS=9.9.9.9#dns.quad9.net 149.112.112.112#dns.quad9.net 2620:fe::fe#dns.quad9.net 2620:fe::9#dns.quad9.net
DNSOverTLS=opportunistic

Then restart the systemd-resolved daemon:

$ sudo systemctl restart systemd-resolved

The DNS servers you specify in your /etc/systemd/resolved.conf file will be used by default — unless they are overridden by the DHCP settings from the connection that provides your system’s default route.

Ignore DNS From Untrusted Connections

Next, configure your untrusted connections to not accept DNS settings from DHCP. If using the NetworkManager suite, you can list all your previously-used connections with the nmcli connection show command (which can be abbreviated nmcli c):

$ nmcli connection show
NAME                UUID                                  TYPE       DEVICE
Corporate Wi-Fi     f89ae809-c1fb-41e2-ad70-9f2d54ed8227  wifi       wlan0
wg0                 30ce4b50-72ea-46a9-b6f6-e10a10a2028c  wireguard  wg0
tap0                9d86e5fb-bd2f-4d75-bb21-49893243953c  vpn        --
Wired connection 1  ba56c17e-13ff-4724-829c-fe601586b277  ethernet   --
Rando Coffee Shop   e4d9ac06-6b15-4543-a68e-6bf784998b9c  wifi       --
Big Hotel Chain     31d2b107-457b-44d1-a5a6-ce47668121c2  wifi       --

For each connection which you don’t want to override your global DNS settings, run the following command (using the name or UUID of the connection):

$ nmcli connection modify 'Rando Coffee Shop' ipv4.ignore-auto-dns yes ipv6.ignore-auto-dns yes

Unfortunately, NetworkManager’s default is to allow your global DNS settings to be overridden, so you have to do this explicitly every time you connect to a new, untrusted Wi-Fi access point. Also, these settings won’t take effect until the next time you connect — so after running the above command, disconnect from the access point, and then re-connect.

Use a Custom Routing Table for WireGuard Connections

Next, adjust each of your WireGuard config files to use a custom routing table for each route added by wg-quick when it starts up a WireGuard tunnel; then add a policy routing rule (for both IPv4 and IPv6) to look up routes in this table first, before the system’s main routing table.

To use a custom table for a WireGuard interface, add a Table entry to the [Interface] section of its config file, specifying the number (or name) of the custom table; for example, table 10:

[Interface]
Table = 10

Before adding a custom policy routing rule, first check your current IPv4 policy rules with the ip rule list command (which can be abbreviated ip rule):

$ ip rule list
0:	from all lookup local
32766:	from all lookup main
32767:	from all lookup default

By default, the policy rule that looks up routes in the system’s main routing table has a very high priority value (32766).

Add a new policy routing rule with a lower priority value (like 100) to look up routes in your custom table (table 10) — a lower priority value means this rule will be evaluated first, before the rule for the main routing table:

$ sudo ip rule add from all lookup 10 priority 100

Run the same commands with the -6 flag to create an IPv6 version of this policy rule:

$ ip -6 rule list
0:	from all lookup local
32766:	from all lookup main
$ sudo ip -6 rule add from all lookup 10 priority 100

Policy rules are not persisted across system reboots, so add these commands to each WireGuard config file, as PreUp (or PostUp) scripts — that way, they’ll will be run every time the WireGuard interface is started up. For example, like this WireGuard config file:

# /etc/wireguard/wg0.conf
[Interface]
PrivateKey = AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAEE=
Address = 10.0.0.123
Address = fd00::123

Table = 10
PreUp = ip rule list | grep 'from all lookup 10' || ip rule add from all lookup 10 priority 100
PreUp = ip -6 rule list | grep 'from all lookup 10' || ip -6 rule add from all lookup 10 priority 100

[Peer]
PublicKey = fE/wdxzl0klVp/IR8UcaoGUMjqaWi3jAd7KzHKFS6Ds=
Endpoint = 203.0.113.2:51820
AllowedIPs = 10.0.0.0/24
AllowedIPs = fd00::/64

After making the above change to each WireGuard interface, restart it (eg run sudo wg-quick down wg0 and then sudo wg-quick up wg0). Where previously you would have seen routes for your WireGuard interface(s) listed in your main routing table, now they’ll be shown in the custom routing table:

$ ip route show table main
default via 192.168.1.1 dev wlan0 proto dhcp metric 100
192.168.1.0/24 dev wlan0 proto kernel scope link src 192.168.1.12 metric 100
$ ip route show table 10
10.0.0.0/24 dev wg0 scope link
$ ip -6 route show table main
::1 dev lo proto kernel metric 256 pref medium
fe80::/64 dev wlan0 proto kernel metric 1024 pref medium
default via fe80::ec2f:12ff:feb1:c347 dev wlan0 proto ra metric 100 pref medium
$ ip -6 route show table 10
fd00::/64 dev wg0 metric 1024 pref medium

The policy routing rules you added for the custom table will direct your system to use these routes in preference to those in the main table:

$ ip rule list
0:	from all lookup local
100:  	from all lookup 10
32766:	from all lookup main
32767:	from all lookup default
$ ip -6 rule list
0:	from all lookup local
100:  	from all lookup 10
32766:	from all lookup main

At this point you’ve successfully mitigated TunnelVision (and related DHCP vulnerabilities) — a malicious DHCP server will no longer be able to divert the traffic that you expect to be sent through WireGuard. However, if you have a WireGuard configuration with a /0 in its AllowedIPs settings, you’ll need to do one more thing in order to actually use the WireGuard tunnel.

Add a Route to Your WireGuard Server

For any WireGuard configuration that has a /0 in its AllowedIPs settings (like 0.0.0.0/0 for IPv4 or ::/0 for IPv6), you need to do one more thing: Add an explicit route (or policy routing rule) for your WireGuard server.

A route with /0 matches all traffic (and is also known as the “default route”); so when you use it in your AllowedIPs settings, you need to set up an exception for the traffic that WireGuard itself generates (so that the system does not try to route this traffic back through the WireGuard tunnel in a recursive loop). Wg-quick will do this automatically when you don’t configure a custom routing table; but when you set up your own custom routing table, you have to set up an exception yourself.

The easiest way to do this is to add another policy rule to route traffic to your WireGuard server via your main routing table. For example, if the IP address of your WireGuard server is 203.0.113.2, add the following rule:

$ sudo ip rule add to 203.0.113.2 lookup main priority 99
Note

If the WireGuard server has an IPv6 address, use the -6 flag:

$ sudo ip -6 rule add to 2001:db8:203:113::2 lookup main priority 99

Make sure this rule has a lower priority value (eg 99) than the rule that looks up routes in your custom routing table (eg 100) — the rule with the lower priority value will be matched first.

And since policy rules are not persisted across system reboots, add this rule as a PreUp script to the WireGuard config file to which it applies (with a corresponding PostDown rule to remove it when not in use):

# /etc/wireguard/wg0.conf
[Interface]
PrivateKey = AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAEE=
Address = 10.0.0.123
Address = fd00::123

Table = 10
PreUp = ip rule | grep 'from all lookup 10' || ip rule add from all lookup 10 priority 100
PreUp = ip -6 rule | grep 'from all lookup 10' || ip -6 rule add from all lookup 10 priority 100
PreUp = ip rule add to 203.0.113.2 lookup main priority 99
PostDown = ip rule del to 203.0.113.2 lookup main priority 99

[Peer]
PublicKey = fE/wdxzl0klVp/IR8UcaoGUMjqaWi3jAd7KzHKFS6Ds=
Endpoint = 203.0.113.2:51820
AllowedIPs = 0.0.0.0/0
AllowedIPs = ::/0
Tip

If you use a DNS name to identify your WireGuard server, use the dig command in your PreUp/PostDown scripts to dynamically look up the IP address of the server. For example, like the following if vpn.example.com is the DNS name of your WireGuard server:

PreUp = ip rule add to $(dig +short vpn.example.com) lookup main priority 99
PostDown = ip rule del to $(dig +short vpn.example.com) lookup main priority 99

Add More Exceptions If Necessary

When combining AllowedIPs = 0.0.0.0/0, ::/0 with a custom routing table, as shown in the section above, you’ve effectively created a “kill switch” that prevents any normal unicast traffic from leaving your machine other than through the WireGuard tunnel.

However, you may need to access some network services outside of the tunnel — for example, to access a printer on the LAN (Local Area Network) to which you’ve connected. In that case, you need to add more exceptions. You can do this either by adding another policy routing rule for each exception (directing the system to use the main routing table for the exception); or by adding an additional route to your custom table for each exception.

For example, say you want to be able to access a printer on your LAN with an IP address of 192.168.1.34. You could add a policy rule like the following to enable this access:

$ sudo ip rule add to 192.168.1.34 lookup main priority 99

Or you could add a route to your custom table (table 10):

$ ip route add 192.168.1.34 via 192.168.1.1 dev wlan0 table 10

When you add a route for LAN traffic, you need to also specify the LAN gateway (eg 192.168.1.1) and LAN interface (eg wlan0). You can find these items by inspecting your main routing table:

$ ip route show table main
default via 192.168.1.1 dev wlan0 proto dhcp metric 100
192.168.1.0/24 dev wlan0 proto kernel scope link src 192.168.1.12 metric 100

The practical difference between using routes for exceptions versus policy routing rules is that routes can be added only while you are actively connected to the LAN (and will be removed automatically when you disconnect); whereas policy rules can be added even when you are not connected to the LAN, and will persist until explicitly removed or the system is rebooted.