One of Apple’s goals is to make their operating systems (macOS, iOS…) as secure as possible with little, if any, intervention from the user. Many of the new features introduced in the latest versions of macOS are focused on security. For example, starting from macOS “Catalina”, by default, all Mac apps need to be notarized by Apple in order to launch. It is also not possible for programs or services, even if they are executed as root, to access certain directories in your home folder.
Some of these features and security policies designed to protect the final user are also sources of frustration for administrators and experienced users. Simple and straightforward tasks in other OS such as GNU/Linux, OpenBSD or even Windows, turn into a real nightmare in recent versions of macOS. Setting up a firewall is one of them.
In this article, I will show you how to configure correctly the powerful built-in firewall Packet Filter (
pf) in any macOS version from macOS Sierra (10.12) to macOS Big Sur (11.x), so that it automatically starts on boot and it continues working after a system upgrade without manual intervention. There are already some articles about this topic, for example this one. However, either they fail to explain how to enable the firewall on boot (without using a MDM tool) or they suggest modifying files that will get overwritten by Apple during system updates.
The main configuration file of
/etc/pf.conf. This file can include macros (user-defined variables), tables, options and filter rules. For a complete understanding of this file I recommend that you read the pf user’s guide from OpenBSD. As a simple approach, we could include our rules here and just ensure that
pf is enabled on boot. However, this file is overwritten during system updates, even during minor upgrades like from 10.15.6 to 10.15.7. So if we add our rules here, we would need to add them manually after each system upgrade. The solution is to include our rules in a different file. In particular, I will write them in
/etc/pf.anchors/medizin.uni-ulm.de. But feel free to modify the name of the file to use your own domain, or a generic one like
# Network interfaces
ether=en0# Don't filter on local loopback
set skip on lo0# Table allow IPs
table <local> persist file "/etc/auth_ips"# Block all traffic on LAN interface en0 by default
block drop on $ether all# Allow all traffic in/out in the local subnet
pass on $ether from <local># Allow SSH, VNC and echoreq ICMP type from Uni's IPs
pass on $ether proto tcp from 184.108.40.206/16 to port 22
pass on $ether proto tcp from 220.127.116.11/16 to port 5900
pass inet proto icmp from 18.104.22.168/16 icmp-type echoreq
This is just a simple example where we block incoming traffic on the
en0 interface by default, we allow all traffic from specific hosts and subnets defined in
/etc/auth_ips, and we allow incoming tcp traffic to ports 22 and 5900, as well as ICMP packets (so we can
ping the device), only if it comes from any of the IPs belonging to the Ulm University.
Modify the rules and the interfaces according to your own needs. Avoid defining tables in the anchor file. That works perfectly on OpenBSD, but for some reasons it sometimes causes
pf not to start correctly on boot on macOS. It took me a while to debug this issue because the error messages were quite cryptic. Instead, save all your authorized IPs in a file and load it as in the previous example.
To learn more about how to properly configure your rules, read carefully the documentation from OpenBSD. I also recommend reading chapters 2 and 3 of The Book of PF: A No-Nonsense Guide to the OpenBSD Firewall by Peter N.M. Hansteen. Chapter 3 is available for free here.
The launch daemon
launchd to start, stop and manage daemons, applications, processes, and scripts. This software, released under a Apache License, was written by David Zarzycki and introduced back with Mac OS X Tiger. It’s been a while, but even Big Sur still uses it. If you are a GNU/Linux administrator think of
launchd as the “
systemd” of macOS, although they are big differences between the two.
Here is what you need to know about
1.It differentiates between agents and daemons.
(…) an agent is run on behalf of the logged in user while a daemon runs on behalf of the root user or any user you specify with the
2.Jobs are specified in a special XML file called a property list.
3.Jobs will behave as an agent or as a daemon based on where you save the property list file.
Apple stores all the macOS agents and daemons in
/System/Library/LaunchDaemons, respectively. These paths are protected by the System Integrity Protection (SIP). Meaning that you cannot write new files or modify existing ones, even if your try to do it as
Apple actually includes a system daemon invoking
/System/Library/LaunchDaemons/com.apple.pfctl.plist. This is the content of the property list.
<?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">
However, as you can see,
pf is not enabled because it is not called with the enable flag (
-E). Some articles (and Stack Overflow answers) suggest disabling SIP, modifying this system daemon to include the enable flag, and then enabling back SIP. This is not a good idea because this file will be overwritten next time you update your system. At that point you will have to repeat all these steps again. And remember that to disable and enable SIP you have to restart your computer in Recovery Mode.
The solution is to create a new property list and store it in
/Library/LaunchDaemons so it becomes a global daemon that will run on boot. Save it with the name you prefer, in my case
de.uni-ulm.medizin.pfctl.plist. Just be consistent, and use the file name as the value for the
Label key below.
<?xml version="1.0" encoding="UTF-8" ?>
<!DOCTYPE plist PUBLIC "-//Apple Computer/DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
This global daemon will execute the script
/usr/local/bin/firewall.sh once at boot. It will also save the
stderr messages to log files in the home directory of the user localadmin.
The firewall script
We are almost done. If you have been paying attention, you may be wondering why we don’t just copy the system daemon defined in
/Library/LaunchDaemons with the enable flag an a different name. You are right, that’s a good idea! However, it will not work. Or at least, it will not work all the times. Let me save you some time and introduce you the firewall script. You can save this script in
/usr/local/bin/firewall.sh or in any other place, just remember to edit the previous property list accordingly.
/sbin/pfctl -E -f /etc/pf.medizin.uni-ulm.de.conf
The reason why we need this script instead of letting the global daemon call directly
pfctl, is the philosophy behind
launchd. Keeping it simple,
launchd expects daemons to know best what they need, and if the requirements are still not met during the boot process, they should simply exit gracefully as quickly as possible. So, during boot, if
launchd reads our property list file and runs it (since we asked for it with the
RunAtLoad key) before the network interfaces are up and ready,
pf will fail to start. The
firewall.sh script avoids this by waiting 5 seconds and then calling
ipconfig waitall, which blocks until all network services have completed configuring. You may argue that the
sleep 5 is totally random and hard-coded, and that
ipconfig waitall should be enough. And you are right, but after testing this on tens of iMacs without the
sleep call, I found that once in a while some devices would fail randomly to start
pf correctly on boot. Feel free to improve this script, adapt it to your needs, test it with wireless interfaces (I was only worried about ethernet interfaces), VPNs, etc. And if you find a better solution, please let me know in the comments.
Done? Almost! If you checked previous script carefully, you must have realized that we told
pfctl to load the rules contained in
/etc/pf.medizin.uni-ulm.de.conf. However, I still didn’t defined this file! Don’t worry, this is just a little trick so we don’t flush all the anchors and rules defined by Apple in
/etc/pf.conf when we load our own. The contents of this file are the following:
load anchor "de.uni-ulm.medizin.pf" from "/etc/pf.anchors/medizin.uni-ulm.de"
Time to try (and debug)
Give it a try and restart your computer. You can check that
pf is running by executing
sudo pfctl -s info | grep Status in a terminal. If you get something like this:
No ALTQ support in kernel
ALTQ related functions disabled
Status: Enabled for 7 days 07:51:35 Debug: Urgent
Then, you are done! If it’s still disabled, check the log files, they may give you a clue of what’s going on. Also, double check that you have been consistent with the names of the files. Maybe you have a typo in the rules? Try to start
pfctl manually with
sudo pfctl -E -f /etc/pf.anchors/medizin.uni-ulm.de. If you can’t figure it out, leave a comment and maybe I can help you.
Here’s a list of all the files you need:
/etc/pf.anchors/<rules>: our custom rules for pf.
/Library/LaunchDaemons/<custom pfcl>.plist: the property list file defining the global daemon.
/usr/local/bin/firewall.sh: the script actually enabling
/etc/<custom anchor>.conf: the conf file defining and loading our rules.