Notes on MacOS pfctl
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 toLD_PRELOAD
) to replacegetaddrinfo
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.
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
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/