跳至主要内容

Split tunneling with Linux (advanced)

Mullvad app Connectivity Linux Desktop Split tunneling 

最近更新时间:

The Mullvad app for Android, Linux and Windows has a split tunneling feature. It allows you to exclude some apps from the VPN so they will use your regular Internet connection. However if you want to exclude specific IP addresses or ports, manual configuration is required. This is possible on Linux.

What this guide covers

Excluding specific IP addresses

Prerequisites

To set up your user defined firewall rules, the host needs to have the nft tool available. The nftables package is available on Fedora, Ubuntu and Debian, and most likely on any other mainstream Linux distribution.

Beware - privacy danger ahead!

netfilter allows for a very expressive language to be used for defining firewall rules, which is great. However, the language has no guard-rails, and having syntactically correct rules that netfilter will load without emitting any error messages doesn't ensure that the rules will achieve your expected behavior, and they can in fact be detrimental to your privacy. As such, exercise extreme caution and preferably test your
rules in a safe environment if you must ensure your privacy at all times.

Example

Applying these rules will also allow the excluded IP to be reached when our app is in its blocked state. Also, excluding some IP addresses and not others can lead to de-anonymization attacks. Consider the following scenario: you exclude IP addresses that example.com points to from the tunnel and then attempt to visit a website on https://example.com in a web browser, and as the page is being loaded, the HTML document requests various scripts and images from https://assets.example.com. Since assets.example.com and example.com can resolve to 2 different addresses, the traffic to assets.example.com can still be routed through our tunnel. The end result is that whoever controls both assets.example.com and example.com can observe that seemingly the same user is sending requests from 2 different IP addresses simultaneously, so the tunnel exit IP address can be linked to user's real IP address.

Allowing specific IPs through our firewall

As our Mullvad app for Linux allows for split tunneling on a per-application basis, we have had to adjust our firewall rules to allow some connections to be routed outside our tunnel. This is achieved by having rules that check if traffic is marked with a connection tracking mark (0x00000f41) to get through the firewall and a meta mark (0x6d6f6c65) to route the traffic outside the tunnel. This means that one only needs to set those two marks to have their traffic be excluded, and to do so with an extra netfilter table and chain is simple.

To allow a specific IP, for example 173.239.79.196, to be reached without going through the tunnel, one would have to apply the following rules.

table inet excludeTraffic {
  chain excludeOutgoing {
    type route hook output priority 0; policy accept;
    ip daddr 173.239.79.196 ct mark set 0x00000f41 meta mark set 0x6d6f6c65;
  }
}

Save the rules to a file excludeTraffic.rules and load them via nft like so:

sudo nft -f excludeTraffic.rules

The command above sets up a new firewall table with a chain named excludeOutgoing that executes in the output hook which means that the rules get executed as soon as the traffic that originates locally is routed. It's a filter chain, even though no filtering will be done, the chain only applies a connection mark to the traffic that is destined to our specified IP address. Given that this is an inet table, (and not an ip4 or an ip6 table), both IPv4 and IPv6 addresses can be used here.

Listing and deleting the rules

To inspect all of the rules applied on your system (including those applied by our app), you can execute sudo nft list ruleset. The rules will be in effect until they are removed or the system is rebooted.

To remove the rules, just delete the table with nft:

sudo nft delete table inet excludeTraffic

If our app works, then these exclusion rules should work too. And since applying marks should have no side effects other than excluding the traffic when our app is connected, it is safe to leave these rules set indefinitely, even when our app is disconnected, as these rules do not change the way traffic is handled unless our app is connected or blocking.

Why this works?

To filter output traffic and ensure that all non-excluded traffic is routed through our tunnel, our app sets up an output chain with a priority of 0 that rejects traffic that isn’t marked and isn’t routed through our tunnel device. Thus, having a chain that marks specific traffic to be excluded will make said traffic pass through our output chain and later be re-routed through the appropriate interface.

Slightly more elaborate examples

Named IP sets

Setting up one rule per IP address to exclude might seem a bit cumbersome, and netfilter developers would agree - hence it's possible to use sets to define the list of IP addresses or subnets that should be excluded from the tunnel.

define EXCLUDED_IPS = {
    173.239.79.196,
    173.239.79.197,
    17.0.0.0/8
}

table inet excludeTraffic {
  chain excludeOutgoing {
    type route hook output priority 0; policy accept;
    ip daddr $EXCLUDED_IPS ct mark set 0x00000f41 meta mark set 0x6d6f6c65;
  }
}

Allowing traffic to a local host

On the other hand, one can add specific rules to only exclude UDP or TCP traffic for specific ports for some addresses and not others. The rules can of course be as expressive as netfilter allows them to be. For instance, one could have stricter filtering by disallowing LAN traffic in our app but still be able to connect to hosts on their local network on specific ports.

table inet excludeTraffic {
  chain excludeOutgoing {
    type filter hook output priority -10; policy accept;
    ip daddr 192.168.1.98 tcp dport {22, 443} ct mark set 0x00000f41;
  }
}

Allowing DNS traffic

To allow for DNS traffic to flow to a specific host outside the tunnel, similar rules can be applied.

define RESOLVER_ADDRS = {
  192.168.1.1,
  1.1.1.1,
  8.8.8.8
}
table inet excludeTraffic {
  chain excludeDns {
    type filter hook output priority -10; policy accept;
    ip daddr $RESOLVER_ADDRS udp dport 53 ct mark set 0x00000f41 meta mark set 0x6d6f6c65;
    ip daddr $RESOLVER_ADDRS tcp dport 53 ct mark set 0x00000f41 meta mark set 0x6d6f6c65;
  }
}

The above rules can be reduced to a single, slightly more complex rule. However, using these rules does will leak DNS queries.  The above rules will also add a packet mark (0x6d6f6c65) to reroute the traffic through an appropriate interface. The packet mark is not only used by the firewall, but also by the routing table. The exclude chain has to have a priority between -200 and 0, otherwise our output chain will block the traffic.

Allowing incoming traffic (for servers)

To allow a process to listen on a specific port whilst our app is active, one must set the connection tracking mark on incoming traffic. Unlike other examples, it’s important that the mark is applied before our input chain is executed.  For instance, to allow a process on a host to listen on port 2010 for both TCP and UDP, the following rules should be applied.

table inet excludeTraffic {
  chain allowIncoming {
    type filter hook input priority -100; policy accept;
    tcp dport 2010 ct mark set 0x00000f41 meta mark set 0x6d6f6c65;
  }
 
  chain allowOutgoing {
    type route hook output priority -100; policy accept;
    tcp sport 2010 ct mark set 0x00000f41 meta mark set 0x6d6f6c65;
  }
}

Bear in mind that the above rules will prohibit the host from receiving connections on the excluded port from the tunnel interface.

Issues

Setting the priority lower than -200 will result in a configuration that doesn't work and leaks your tunnel IP address, e.g. the following rules are syntactically correct and will be successfully loaded but traffic will not flow and your tunnel's IP address will be leaked.

chain excludeOutgoing {
  type route hook output priority -201; policy accept;
  ip daddr ...
}