Notes on MacOS pfctl

11 minutes read Oct 2, 2023 Sep 11, 2023

pfctl is used to manage the firewall on MacOS and BSD systems. The MacOS version has a different syntax and feature set than the BSD one.

Rule sets are organized into anchors. Anchors are hierarchical (they may be organized under other anchors). An anchor path is composed of anchor names seperated by a / (similar to directory paths).

Filtering

  • “For each packet processed by the packet filter, the filter rules are evaluated in sequential order, from first to last. The last matching rule decides what action is taken. If no rule matches the packet, the default action is to pass the packet.” (man pf.conf)

Showing Rules

Show the main rule set (including anchors)

pfctl -s rules
pfctl -sr # short hand

Show the top-level rule set for an anchor.

pfctl -sr -a com.apple

Show all rule sets under an anchor.

pfctl -sr -a 'com.apple/*'

When listing the rule sets (-s) ending a path with a * will recursively print all rule sets under the anchor path.

Rule Sets

See man pf.conf for details.

Application Based Rules

While it is not possible to write a rule to directly filter based on an application or PID it is possible to filter based on a group. So by creating a special group for this purpose and running the application of interest under this group, the application can be indirectly filtered. However there are some significant limitations to this approach.

Limitations

Translation rules (binat, nat, rdr) cannot match on user and group (see Translating below for more details).

Filtering traffic based on users or groups likely will not route DNS packets as expected. This is because on MacOS getaddrinfo (part of libc) relies on mDNSResponder to make DNS lookups on the application’s behalf. This means that DNS traffic for domain lookups will occur under the user _mdnsresponder and not the user or group of the calling process. mDNSResponder is largely unconfigurable.

So how can this be worked around? Some possiblities to explore are:

  • Use an application which doesn’t use or can be configured not to use the MacOS provided getaddrinfo to resolve host names (ex: statically linked app, set http proxy)
  • Use an application which can be set to bind non-listenting sockets to an interface (ex: curl --interface $interface http://test.com, ping -b $interface 1.1.1.1)
  • Use DYLD_INSERT_LIBRARIES (similar to LD_PRELOAD) to replace getaddrinfo with one that resolves DNS itself
    • Limitations: traffic must be TCP only, the app must dynamically load the c lib for networking, the app may not be a system binary, etc.
  • Route all local DNS traffic to a local DNS forwarding resolver and filter requests by domain (possibly dynamically)
  • write an program to do split tunneling (see “Bonus: A Note on Split Tunneling”)

For my specific issue I resolved this by configuring the application to use squid as a HTTP proxy server and set the proxy server to use bind, configured as a forwarding DNS server, for DNS. Both servers are configured to forward traffic out of a non-default interface. (The DNS server is needed since even though the application is no longer making DNS requests for HTTP(S) traffic the proxy server will be and this will still be using the MacOS provided getaddrinfo.)

# squid.conf
http_port $proxy_port
http_access allow localhost
tcp_outgoing_address $interface_ip
dns_nameservers 127.0.0.1
# named.conf
options {
    listen-on { 127.0.0.1; };
    allow-query-on { 127.0.0.1; };

    query-source address $interface_ip;

    allow-query { any; };
    recursion yes;

    forwarders {
        $dns_server; #ex: 1.1.1.1
    };

    forward only;
};

Use curl to test the setup.

curl -x $proxy_ip:$proxy_port http://test.com # app specfic flag
http_proxy=$proxy_ip:$proxy_port http://test.com # common env var

Note: Filtering application traffic based on users or groups likely will not route DNS lookups as expected.

User and Group Rules

The rule parameter can specify the user or group by either the id or name.

For example this anchor allows all outbound tcp and udp traffic for a given group id.

anchor "allowGroup" all {
  pass out proto tcp all group = 333 no state
  pass out proto udp all user = root no state

}

Some important details from man pf.conf:

  • “For outgoing connections initiated from the firewall, this is the user [or group] that opened the connection. For incoming connections to the firewall itself, this is the user [or group] that listens on the destination port.”
  • “Only TCP and UDP packets can be associated with users; for other protocols these parameters are ignored.”
  • (more details in the man page)

Finding Users and Groups

Users and groups can be queried with dscl

List users or groups with their names and ids.

dscl . -list /Groups PrimaryGroupID
dscl . -list /Users UniqueID

Search users or groups by ids

dscl . -search /Groups PrimaryGroupID $GID
dscl . -search /Users UniqueID $UID

Finding Processes Running under a Group

Note: A process group is different than a group ID

Listing all processes with their GID.

ps ax -o user,gid,pid,command # specify each field
ps aux -o gid # add gid field to standard fields

List processes running under a specific GID.

ps ax -o user,pid,gid,command | awk '$3==GID'

Running an Application under a Group

sudo -g $GROUP_NAME $COMMAND

Note: ncat can be a useful command here to generate TCP and UDP test traffic.

# using pipe
{ printf 'GET / HTTP/1.1\nHost: 1.1.1.1\n\n'; sleep 1; } | ncat --ssl 1.1.1.1 443;

# using here-doc
ncat captive.apple.com 80 << EOF
GET / HTTP/1.1
Host: captive.apple.com

EOF

Bonus: A Note on Split Tunneling

Split tunneling is a feature of some VPNs which allows user launched applications to be selectively routed over a VPN tunnel. This cannot be done with firewall rules on their own so instead a more advanced technique is used to create the firewall rules dynamically.

The general idea is to monitor the ports in use by the target applications and then apply firewall rules to conditionally route the traffic using those ports. To do this the VPN program creates a tunnel, routes all traffic to that tunnel by setting it as the default gateway, and then binds to the tunnel so it can manage the traffic. Next when a packet is received on the tunnel it updates its list of open network sockets for the applications of interest (think sudo lsof -ni | grep -v '\*:'), searches for the packet’s source port in the list of sockets, creates a new firewall rule for that port, and finally retransmits the packet so it can be routed by the new firewall rules. The new rule will either route traffic from that port over the VPN interface or over the primary network interface to bypass the VPN (Ex: pass out route-to (IF-NAME GATEWAY-IP) inet proto tcp from any port = PORT to any no state). New rules only need to be added if the source port has not been seen before.

See also the Private Internet Access source code for implementation details: mac_splittunnel.cpp, port_finder.cpp, rule_updater.cpp, posix_firewall_pf.cpp

(Note: In the current implementation there may be a potential for an application to route incorrectly between the time a target application has stopped using a port and traffic from an unseen source port is sent. During this time the filter rule for the unused source port is still active and if a non-target application were to be assigned this port number it would continue to be selectively routed until traffic from an unseen source port occurred.)

Translating

  • “Since translation occurs before filtering the filter engine will see packets as they look after any addresses and ports have been translated. Filter rules will therefore have to filter based on the translated subject to block and pass rules.” (man pf.conf)
  • “Evaluation order of the translation rules is dependent on the type of the translation rules and of the direction of a packet. binat rules are always evaluated first. Then either the rdr rules are evaluated on an matching rule decides what action is taken.” (man pf.conf)
  • “The no option prefixed to a translation rule causes packets to remain untranslated, much in the same way as drop quick works in the packet filter” (man pf.conf)

Note: Translation actions have a far more restricted matching ability when compared to filtering rules. They cannot be written to match on properties like user or group and since translation rules are evaluated before filtering rules tagging cannot be used as a way around this.

route-to

The route-to action specifies the interface and next-hop address to use for the matching packets.

pass out route-to ($if $gateway) group = $group_name

However it will not rewrite the source address so if the packet was originally destined to go out a different interface replies may not be routed back at all (if the original interface uses an internal IP) or may come back on the original interface (if the interface IP is externally routable).

Logging

Logging can be enabled for packets which match a filter rule.

pass in log all # log all packets to default interface /dev/pflog0

It’s important to note that the interface it not automatically created. It needs to be created first.

ifconfig pflog0 create # add if
ifconfig pflog0 destroy # del if

Then the tool of choice can listen on the interface for the packets matching the filter rules.

tcpdump -i pflog0

The packets on the logging interface will include an additonal PF Log layer containing debug info such as the anchor, rule number, action, etc. For stateful packets which occur after the state is triggered, the Ruleset field will be blank.

Wireshark - PF Log Layer example

Note: The User and PID fields will be present even when these values are not being logged. It looks like the unset value for PID may be 1000 and UID may be 2147483647.

Logging Options

To log a rule to a specific pflog interface use the to option.

pass in log (to pflog1) all

By default, for stateful connections (the default for rules), only the first packet which initiates the state will be logged. To log all packets in a stateful connection use the all option.

pass in log (all) all

To log the user ID (UID) and process ID (PID) of the packet in the PF Log layer of the logged packet use the user option.

pass log (user) all

Wireshark - PF Log Layer with user data example

And these options can be combined.

pass log (user all to pflog1) all

Note: Logging may be buggy on MacOS. I ran into issues with rules which matched a packet and routed it but it did not appear on the logging network interface. However I have not thoroughly confirmed this.

See also https://www.openbsdhandbook.com/pf/logging/.

Errors

If you are getting a stdin:LINE-NUM: syntax error with pfctl when loading rules from stdin and the syntax is correct, make sure the input ends with a new line.

Resources

  • https://www.openbsd.org/faq/pf/filter.html
  • https://www.openbsd.org/faq/pf/anchors.html
  • https://www.openbsd.org/faq/pf/macros.html
  • https://www.openbsd.org/faq/pf/