Digital Naïve

jomo's blog

Using pf to prevent traffic from bypassing VPN

I don’t want any traffic accidentally bypassing my VPN. Fortunately, using the built-in packet filter (pf) it’s quite simple block any non-VPN traffic.

This was done on macOS, but likely works similar on other systems that come with pf.

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 all the interfaces where clear traffic will be blocked. In my experience MacOS uses en0 to en101 for its hardware interfaces, but you can list them with networksetup -listallhardwareports. Keep in mind that new hardware interfaces will be created if you plug in something new.

hw_ifs = "{" en0 en1 en2 en3 en4 en5 en6 en7 en8 en9 en10 "}"

You’ll 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, printers, etc) is still working:

table <allowed> persist {                     \
  10/8, 172.16/12, 192.168/16,                \,                               \,                                \
  fe80::/10,                                  \
  ff00::/13, ff08::/16, ff10::/13, ff18::/16, \
  ff20::/13, ff28::/16, ff30::/13, ff38::/16, \
  ff40::/13, ff48::/16, ff50::/13, ff58::/16, \
  ff60::/13, ff68::/16, ff70::/13, ff78::/16  \

I also like having a host that can be directly reached via ICMP, so you can do some basic network connectivity checks outside the VPN:

table <allowping> persist { \,                  \
  2001:4860:4860::8888      \

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 (more on this later):

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

Now add the actual rules:

# block all outgoing traffic on any hardware interface
block return out on $hw_ifs all no state

# now allow traffic destined for allowed networks and VPN servers
pass out on $hw_ifs from any to { <allowed>, <vpnhosts> } no state

# allow ping to specified hosts
pass out on $hw_ifs inet proto icmp from any to <allowping> icmp-type echoreq no state
pass out on $hw_ifs inet6 proto ipv6-icmp from any to <allowping> icmp6-type echoreq no state

In the /etc/pf.tables/vpnhosts file (make sure the directory exists), the VPN servers are just entered line by line.
Pro tip: put in there, so you can check for captive portals on public WiFi.

Please note that while you can use hostnames instead of IP addresses, DNS resolution could be forged or blocked while disconnected. You should add each of the hostnames and their IP addresses to your /etc/hosts file.

Finally, these two lines need to be added to /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 enable these rules now, run:

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

And to temporarily disable them:

sudo pfctl -a vpnonly -F all

  1. This effectively depends on the number of different network adapters (USB, Thunderbolt, …) you use