How to configure self-hosted VPN kill switch using PF firewall on macOS

How to configure self-hosted VPN kill switch using PF firewall on macOS - YouTube

Heads-up: when following this guide, IKEv2/IPsec VPNs will likely be unresponsive for about 60 seconds at boot and wake. Not sure what causes this issue. Please submit a PR if you know how to fix it!

Requirements

  • Self-hosted virtual private network (VPN) with public IPv4 address
  • Computer running macOS Mojave or Catalina

Caveats

  • When copy/pasting commands that start with $, strip out $ as this character is not part of the command
  • When copy/pasting commands that start with cat << "EOF", select all lines at once (from cat << "EOF" to EOF inclusively) as they are part of the same (single) command

Guide

Step 1: enable PF

Open “System Preferences”, click “Security & Privacy”, then “Firewall” and enable “Turn On Firewall”.

firewall

Then, click “Firewall Options…”, disable all options except “Enable stealth mode”.

firewall-options

Step 2: confirm PF is enabled

$ sudo pfctl -s info | grep "Status"
No ALTQ support in kernel
ALTQ related functions disabled
Status: Enabled for 0 days 13:02:35           Debug: Urgent

Status: Enabled

👍

Step 3: backup and override /etc/pf.conf

Heads-up: software updates will likely restore /etc/pf.conf to default. Remember to check /etc/pf.conf using cat /etc/pf.conf after updates and test kill switch.

sudo cp /etc/pf.conf /etc/pf.conf.backup
cat << "EOF" | sudo tee /etc/pf.conf
anchor "local.pf"
load anchor local.pf from "/etc/pf.anchors/local.pf"
EOF

Step 4: list hardware network interfaces

$ networksetup -listallhardwareports

Hardware Port: Wi-Fi
Device: en0
Ethernet Address: Redacted

Hardware Port: Thunderbolt 1
Device: en1
Ethernet Address: Redacted

Hardware Port: Thunderbolt 2
Device: en2
Ethernet Address: Redacted

Hardware Port: Bluetooth PAN
Device: en3
Ethernet Address: Redacted

Hardware Port: iPhone USB
Device: en4
Ethernet Address: Redacted

Hardware Port: Thunderbolt Ethernet
Device: en5
Ethernet Address: Redacted

Hardware Port: Thunderbolt Bridge
Device: bridge0
Ethernet Address: Redacted

VLAN Configurations
===================

Step 4: find hardware network interface subnet prefix (example bellow is for Wi-Fi interface)

$ networksetup -getinfo "Wi-Fi"
DHCP Configuration
IP address: 10.0.1.140
Subnet mask: 255.255.255.0
Router: 10.0.1.1
Client ID:
IPv6: Off
Wi-Fi ID: Redacted

Use following table to find bitmask using subnet mask.

For example, if subnet mask is 255.255.255.0, bitmask is /24 and subnet prefix is 10.0.1.0/24.

| Subnet mask | Bitmask | | --------------- | ------- | | 0.0.0.0 | /0 | | 128.0.0.0 | /1 | | 192.0.0.0 | /2 | | 224.0.0.0 | /3 | | 240.0.0.0 | /4 | | 248.0.0.0 | /5 | | 252.0.0.0 | /6 | | 254.0.0.0 | /7 | | 255.0.0.0 | /8 | | 255.128.0.0 | /9 | | 255.192.0.0 | /10 | | 255.224.0.0 | /11 | | 255.240.0.0 | /12 | | 255.248.0.0 | /13 | | 255.252.0.0 | /14 | | 255.254.0.0 | /15 | | 255.255.0.0 | /16 | | 255.255.128.0 | /17 | | 255.255.192.0 | /18 | | 255.255.224.0 | /19 | | 255.255.240.0 | /20 | | 255.255.248.0 | /21 | | 255.255.252.0 | /22 | | 255.255.254.0 | /23 | | 255.255.255.0 | /24 | | 255.255.255.128 | /25 | | 255.255.255.192 | /26 | | 255.255.255.224 | /27 | | 255.255.255.240 | /28 | | 255.255.255.248 | /29 | | 255.255.255.252 | /30 | | 255.255.255.254 | /31 | | 255.255.255.255 | /32 |

Step 5: set temporary environment variables

KILLSWITCH_HARDWARE_INTERFACES should include all used hardware network interfaces.

KILLSWITCH_VPN_INTERFACE should be set to VPN interface (use ifconfig to find interface).

KILLSWITCH_TRUSTED_SUBNET_PREFIXES should include all trusted subnet prefixes such as a home or office subnet prefixes (if trusted).

KILLSWITCH_VPN_ENDPOINT_IPS should include all VPN endpoint IPs.

KILLSWITCH_HARDWARE_INTERFACES="{ en0, en4, en5 }"
KILLSWITCH_TRUSTED_SUBNET_PREFIXES="{ 10.0.1.0/24 }"
KILLSWITCH_VPN_INTERFACE=ipsec0
KILLSWITCH_VPN_ENDPOINT_IPS="{ 185.193.126.203 }"

Step 6: create PF strict anchor

This anchor blocks everything except DHCP and VPN requests.

cat << EOF | sudo tee /etc/pf.anchors/local.pf.strict
# Options
set block-policy drop
set ruleset-optimization basic
set skip on lo0

# Set variables
hardware_interfaces = "$KILLSWITCH_HARDWARE_INTERFACES"
vpn_endpoint_ips = "$KILLSWITCH_VPN_ENDPOINT_IPS"
vpn_interface = "$KILLSWITCH_VPN_INTERFACE"

# Block everything
block all # Use "block log all" to log blocked packets

# Allow DHCP requests (used to establish Wi-Fi connection)
pass on \$hardware_interfaces proto udp from port { 67, 68 } to port { 67, 68 } keep state

# Allow requests to VPN server (used to establish VPN connection)
pass on \$hardware_interfaces proto { tcp, udp } from any to \$vpn_endpoint_ips

# Allow all requests on VPN interface
pass on \$vpn_interface all
EOF
sudo chmod 600 /etc/pf.anchors/local.pf.strict

Step 7: create PF trusted anchor

Same as strict but allows multicast DNS and local network requests.

cat << EOF | sudo tee /etc/pf.anchors/local.pf.trusted
# Options
set block-policy drop
set ruleset-optimization basic
set skip on lo0

# Set variables
hardware_interfaces = "$KILLSWITCH_HARDWARE_INTERFACES"
trusted_subnet_prefixes = "$KILLSWITCH_TRUSTED_SUBNET_PREFIXES"
vpn_endpoint_ips = "$KILLSWITCH_VPN_ENDPOINT_IPS"
vpn_interface = "$KILLSWITCH_VPN_INTERFACE"

# Block everything
block all # Use "block log all" to log blocked packets

# Allow DHCP requests (used to establish Wi-Fi connection)
pass on \$hardware_interfaces proto udp from port { 67, 68 } to port { 67, 68 } keep state

# Allow multicast DNS requests (used to find devices using Bonjour, disable these lines when you don’t trust the network)
pass on \$hardware_interfaces from \$trusted_subnet_prefixes to 255.255.255.255 keep state
pass on \$hardware_interfaces from 255.255.255.255 to \$trusted_subnet_prefixes keep state
pass on \$hardware_interfaces proto udp from \$trusted_subnet_prefixes port 5353 to 224.0.0.251 port 5353 keep state
pass on \$hardware_interfaces proto udp from 224.0.0.251 port 5353 to \$trusted_subnet_prefixes port 5353 keep state

# Allow local network requests (used to access local network, disable this line when you don’t trust the network)
pass on \$hardware_interfaces proto { tcp, udp } from \$trusted_subnet_prefixes to \$trusted_subnet_prefixes

# Allow requests to VPN server (used to establish VPN connection)
pass on \$hardware_interfaces proto { tcp, udp } from any to \$vpn_endpoint_ips

# Allow all requests on VPN interface
pass on \$vpn_interface all
EOF
sudo chmod 600 /etc/pf.anchors/local.pf.trusted

Step 8: create /etc/pf.anchors/local.pf symlink

sudo ln -s /etc/pf.anchors/local.pf.strict /etc/pf.anchors/local.pf

Step 9: restart PF

sudo pfctl -F all -f /etc/pf.conf

Step 10: create /usr/local/sbin folder

sudo mkdir -p /usr/local/sbin
sudo chown $USER:admin /usr/local/sbin

Step 11: source /usr/local/sbin folder

Find which shell is configured using echo $SHELL.

Bash (/bin/bash)

cat << "EOF" >> ~/.bash_profile
export PATH=${PATH}:/usr/local/sbin
EOF
source ~/.bash_profile

Z Shell (/bin/zsh)

cat << "EOF" >> ~/.zshrc
export PATH=${PATH}:/usr/local/sbin
EOF
source ~/.zshrc

Step 12: create /usr/local/sbin/strict.sh convenience script

Use socketfilterfw to block specific apps.

cat << "EOF" > /usr/local/sbin/strict.sh
#! /bin/sh

if [ "$(id -u)" != "0" ]; then
  printf "%s\n" "This script must run as root"
  exit 1
fi

green=
#x27;\e[1;32m' nc=
#x27;\e[0m' # /usr/libexec/ApplicationFirewall/socketfilterfw --blockapp /Applications/1Password\ 7.app # /usr/libexec/ApplicationFirewall/socketfilterfw --blockapp /usr/local/Cellar/squid/4.8/sbin/squid # printf "\n" ln -sfn /etc/pf.anchors/local.pf.strict /etc/pf.anchors/local.pf pfctl -e printf "\n" pfctl -F all -f /etc/pf.conf printf "\n${green}%s${nc}\n" "Strict mode enabled" EOF chmod +x /usr/local/sbin/strict.sh

Step 13: create /usr/local/sbin/trusted.sh convenience script

Use socketfilterfw to unblock specific apps (useful to allow 1Password’s local sync or Squid proxy for example).

cat << "EOF" > /usr/local/sbin/trusted.sh
#! /bin/sh

if [ "$(id -u)" != "0" ]; then
  printf "%s\n" "This script must run as root"
  exit 1
fi

function disable()
{
  /usr/local/sbin/strict.sh
  exit 0
}

trap disable INT

red=
#x27;\e[1;31m' nc=
#x27;\e[0m' # /usr/libexec/ApplicationFirewall/socketfilterfw --unblockapp /Applications/1Password\ 7.app # /usr/libexec/ApplicationFirewall/socketfilterfw --unblockapp /usr/local/Cellar/squid/4.8/sbin/squid # printf "\n" ln -sfn /etc/pf.anchors/local.pf.trusted /etc/pf.anchors/local.pf pfctl -e printf "\n" pfctl -F all -f /etc/pf.conf printf "\n${red}%s${nc}\n\n" "Trusted mode enabled (press ctrl+c to disable)" while : do sleep 60 done EOF chmod +x /usr/local/sbin/trusted.sh

Step 14: create /usr/local/sbin/disabled.sh convenience script

cat << "EOF" > /usr/local/sbin/disabled.sh
#! /bin/sh

if [ "$(id -u)" != "0" ]; then
  printf "%s\n" "This script must run as root"
  exit 1
fi

function disable()
{
  /usr/local/sbin/strict.sh
  exit 0
}

trap disable INT

red=
#x27;\e[1;31m' nc=
#x27;\e[0m' pfctl -d printf "\n${red}%s${nc}\n\n" "Firewall disabled (press ctrl+c to enable)" while : do sleep 60 done EOF chmod +x /usr/local/sbin/disabled.sh

Step 15: test convenience scripts

$ sudo strict.sh
Password:
No ALTQ support in kernel
ALTQ related functions disabled
pfctl: pf already enabled

pfctl: Use of -f option, could result in flushing of rules
present in the main ruleset added by the system at startup.
See /etc/pf.conf for further details.

No ALTQ support in kernel
ALTQ related functions disabled
rules cleared
nat cleared
dummynet cleared
0 tables deleted.
64 states cleared
source tracking entries cleared
pf: statistics cleared
pf: interface flags reset

Strict mode enabled

$ sudo trusted.sh
No ALTQ support in kernel
ALTQ related functions disabled
pfctl: pf already enabled

pfctl: Use of -f option, could result in flushing of rules
present in the main ruleset added by the system at startup.
See /etc/pf.conf for further details.

No ALTQ support in kernel
ALTQ related functions disabled
rules cleared
nat cleared
dummynet cleared
0 tables deleted.
6 states cleared
source tracking entries cleared
pf: statistics cleared
pf: interface flags reset

Trusted mode enabled (press ctrl+c to disable)

$ sudo disabled.sh
No ALTQ support in kernel
ALTQ related functions disabled
pf disabled

Firewall disabled (press ctrl+c to enable)

Step 16: make sure PF is set to strict at boot

cat << EOF | sudo tee /Library/LaunchDaemons/local.pf.plist
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
  <dict>
    <key>Label</key>
    <string>pf</string>

    <key>ProgramArguments</key>
    <array>
      <string>/usr/local/sbin/strict.sh</string>
    </array>

    <key>RunAtLoad</key>
    <true/>
  </dict>
</plist>
EOF

Want things back the way they were before following this guide? No problem!

Step 1: restore /etc/pf.conf from backup

sudo cp /etc/pf.conf.backup /etc/pf.conf

Step 2: delete anchors, convenience scripts and launch daemon

Delete anchors

sudo rm /etc/pf.anchors/local.pf
sudo rm /etc/pf.anchors/local.pf.strict
sudo rm /etc/pf.anchors/local.pf.trusted

Delete convenience scripts

rm /usr/local/sbin/strict.sh
rm /usr/local/sbin/trusted.sh
rm /usr/local/sbin/disabled.sh

Delete launch daemon

sudo rm /Library/LaunchDaemons/local.pf.plist

Step 3: restart PF

sudo pfctl -F all -f /etc/pf.conf
Contributors:Sun KnudsenSun Knudsen
Reviewers:Be the first

Wish to contribute or need help? Read the docs.
This website is not tracking you. PGP public key fingerprint: C4FB DDC1 6A26 2672 920D  0A0F C132 3A37 7DE1 4C8B