Skip to content
FreeBSDDeep Dive Published Updated 6 min readViews unavailable

FreeBSD mac_portacl: Grant a Service Narrow Low-Port Bind Rights

Use FreeBSD mac_portacl to authorize a specific UID or GID to bind selected TCP or UDP ports, while accounting for reserved ranges, boot order, and root exemptions.

Running a network daemon as an unprivileged account is a strong default, but a service that must listen on a port below 1024 traditionally needs a privileged bind. FreeBSD’s mac_portacl MAC policy offers a narrower alternative: an administrator can authorize a numeric user or group identity to bind selected local TCP or UDP ports without making the whole daemon root.

The policy is deliberately specific. It mediates explicit port binds on sockets; it is not a packet filter, a jail boundary, or a general rule for every network connection. Its interaction with the kernel’s reserved-port ranges is also easy to miss: an allow rule cannot override a separate system rule that still reserves the same port. Plan the change as a system-wide networking policy, test it outside production first, and verify the identity that actually performs the bind.

What the policy controls and what it does not

The FreeBSD Handbook describes mac_portacl as a policy for limiting binds to local TCP and UDP ports and allowing non-root processes to bind selected privileged ports. Once loaded, the policy applies to sockets, with a configurable highest port number. The manual states that an explicitly requested port is checked for TCP listen or outgoing sockets and for UDP send or receive sockets. When an outgoing connection leaves its local port selection to the IP stack, that automatically chosen ephemeral port is not governed by this policy.

Rules have four fields:

idtype:id:protocol:port

idtype is uid or gid; id must be the numeric ID, not an account or group name; protocol is tcp or udp; and port is the explicitly selected local port. Multiple entries are comma-separated. The default port_high is 1023. Attempts to bind protected ports fail unless a rule allows the caller, except that the default suser_exempt=1 lets root bind without a matching rule.

That root exception matters operationally. A daemon that starts as root, binds port 80, then drops privileges has already made its bind as root; the rule for its eventual worker UID was not what authorized the listener. If the objective is least privilege at bind time, arrange for the service to bind as its dedicated account, or separately design a trusted privileged supervisor and understand that it, not the worker, is the component opening the socket.

Load the module and establish prerequisites

The policy can be compiled into a custom kernel with options MAC and options MAC_PORTACL. The alternative documented by the manual is to have MAC support in the kernel and load the module from /boot/loader.conf:

mac_portacl_load="YES"

For a controlled runtime test, load mac_portacl with kldload mac_portacl if the policy is not already built into the running kernel. Confirm the effective values before changing them:

sysctl security.mac.portacl.enabled
sysctl security.mac.portacl.port_high
sysctl security.mac.portacl.suser_exempt
sysctl net.inet.ip.portrange.reservedlow
sysctl net.inet.ip.portrange.reservedhigh

The policy’s protected ports must not remain inside the ranges selected by net.inet.ip.portrange.reservedlow and net.inet.ip.portrange.reservedhigh. The Handbook’s example sets both values to zero when authorizing non-root binds below 1024. Do not copy that pair of settings as if it affected only one account or one socket: the reserved-port MIBs are system-wide. Record the original values and review what else on the host depends on reserved ports before making this change.

Build one explicit rule

Assume an isolated test host has a dedicated service account named appsvc. Read its UID locally, then create a single TCP rule for port 80:

APP_UID="$(id -u appsvc)"
sysctl security.mac.portacl.enabled=1
sysctl security.mac.portacl.port_high=1023
sysctl net.inet.ip.portrange.reservedlow=0
sysctl net.inet.ip.portrange.reservedhigh=0
sysctl security.mac.portacl.rules="uid:${APP_UID}:tcp:80"

This example is intentionally not a universal production recipe: changing the reserved ranges needs a host-specific review. It also authorizes only TCP port 80 for the selected numeric UID. A separate UDP listener would need a separate udp rule. A GID rule can be useful when a deliberately shared service group is the policy subject, but a group grant is broader than a single service identity if unrelated processes also run with that group.

Rule ordering is not a priority language; each entry is an allow tuple. Keep the list short, avoid broad UID/GID reuse, and do not assume a name in the string will resolve at evaluation time. The number is the authority the kernel compares. If account recreation changes the numeric ID, review and update the rule.

The manual allows policy tunables other than security.mac.portacl.rules to be set as loader tunables. The rule string is the exception, so do not place it in loader.conf. For persistence, use a supported post-load sysctl configuration path and ensure the module and rule are in place before the daemon attempts its bind. Test the actual boot order on the FreeBSD release and service configuration being deployed; a rule applied after the listener starts cannot retroactively authorize that already-failed start.

Verify the bind identity and the resulting listener

Test in a maintenance window or disposable machine with the production service stopped. First confirm the account’s numeric identity and the effective policy values. Start a test listener through the same service manager and privilege transition that production will use. Then inspect listeners with sockstat -l and the service’s own startup log. A successful rule test proves only that the local bind was accepted; it does not prove that PF, IPFW, a jail’s network setup, routing, or a remote client can reach the service.

If the bind still fails, check the failure in a narrow order:

  1. Is the module actually loaded, or was it compiled into the kernel?
  2. Is MAC enforcement enabled for sockets and is security.mac.portacl.enabled nonzero?
  3. Is the requested port at or below port_high, and is the rule exactly the correct uid or gid, numeric ID, protocol, and port?
  4. Does a reserved-port range still include the port? The MAC allow cannot override that separate restriction.
  5. Which effective UID/GID exists at the instant of bind(2)? A service that binds before dropping privileges is not testing the worker identity.
  6. Is the program explicitly selecting the port, or expecting the IP stack to select an ephemeral source port?

Keep root’s exemption in the plan. Turning security.mac.portacl.suser_exempt off changes the behavior of every root process that binds a protected port, not just this daemon. Inventory system listeners and test recovery paths before changing it. Also remember that mac_portacl is not PF: it decides which local identities may bind selected ports, whereas a firewall decides which packets are admitted or filtered.

Roll back without leaving the host in a weaker state

Before removing a rule, stop the service and record its current effective configuration. If the test fails or must be rolled back, first restore the host’s original reservedlow and reservedhigh values. Verify that the host’s expected low-port restrictions are back, then disable or unload the policy only if no other application depends on it. Restore any changed port_high or root-exemption value and remove the test rule through the same configuration source used to persist it. Finally, restart the affected service and verify the listener, process identity, and firewall behavior.

Do not leave the reserved-port ranges at zero while casually disabling mac_portacl: that can remove the assumptions under which the host was originally relying on privileged-port binding. Treat both the allow list and those global range settings as one change set, with a tested reverse procedure.

Related:

Sources:

Comments