Digital Naïve

jomo's blog

Using pf to prevent traffic from bypassing VPN

I want to ensure no traffic is accidentally bypassing my VPN. Some VPN providers sell this as a “kill switch”. Fortunately, using pf (packet filter) on macOS it’s simple to do this yourself.


Although macOS has an option called “Send all traffic over VPN connection”, this only works while the VPN is connected. Whenever it’s disconnected – and it sometimes does this on its own – all your traffic is sent in the clear.

MacOS VPN advanced settings

PF configuration

Create the file /etc/pf.anchors/vpnonly.

In that file, create a macro list (as follows) with the interfaces used by the VPN.
All traffic will be allowed on these interfaces.

vpn_ifs = "{" utun0 utun1 "}"

You’ll also want to allow a small number of subnets to bypass VPN, such as private networks and multicast addresses. This ensures that you can connect to any hosts in your local network and that network service discovery (AirPlay, Chromecast, printers, etc) is still working:

table <allowed> persist {                     \
  10/8, 172.16/12, 192.168/16,                \
  169.254/16,                                 \
  224.0.0.0/4,                                \
  fe80::/10,                                  \
  ff00::/9                                    \
}

I also like having a host that can be pinged when the VPN is not working:

table <allowping> persist { \ 
  1.1.1.1,                  \
  2606:4700:4700::1111      \   
}

We need a list of VPN servers that we allow connecting to. You can define it inline just like the other tables, but I like keeping it in a separate file at /etc/pf.tables/vpnhosts (make sure the directory exists). The VPN servers are just entered line by line.
Pro tip: put http.rip in there, so you can check for captive portals on public WiFi.

Please note that while you can use hostnames instead of IP addresses in the file, DNS resolution could be forged or blocked while disconnected from your VPN. You should add each of the hostnames you use to your /etc/hosts file, so it doesn’t depend on DNS.

Now back to /etc/pf.anchors/vpnonly:

table <vpnhosts> persist file "/etc/pf.tables/vpnhosts"

Then add the actual rules:

# allow internal traffic
set skip on lo0 

# allow all traffic via VPN
set skip on $vpn_ifs
# skip only works when the interfaces exists while rules are loaded
pass out quick on $vpn_ifs no state

# allow traffic to whitelisted networks and to VPN servers
pass out quick from any to { <allowed>, <vpnhosts> } no state

# allow ping to whitelisted IPs
pass out quick inet proto  icmp from any to <allowping> \
  icmp-type echoreq no state
pass out quick inet6 proto ipv6-icmp from any to <allowping> \
  icmp6-type echoreq no state

# block everything else, reply with TCP RST or ICMP Unreachable
# note: 'return' acts like 'drop' on wired connection, see http://openradar.me/46305914
block return out all no state

Finally, these two lines need to be added at the bottom of /etc/pf.conf:

anchor "vpnonly"
load anchor "vpnonly" from "/etc/pf.anchors/vpnonly"

The new rules will be automatically loaded when your system starts.
To check for errors and enable these rules now, run:

sudo pfctl -n -f /etc/pf.conf && sudo pfctl -E -f /etc/pf.conf

And to temporarily disable them:

sudo pfctl -a vpnonly -F all

Note: Apple likes to override /etc/pf.conf during macOS updates,
so watch out for changes after updating!