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
|
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
|
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.