# macOS pf DNS Interception — Technical Reference ## Overview ctrld uses macOS's built-in packet filter (pf) to intercept all DNS traffic at the kernel level, redirecting it to ctrld's local listeners at `127.0.0.1:53` (IPv4) and `[::1]:53` (IPv6). This operates below interface DNS settings, making it immune to VPN software (F5, Cisco, GlobalProtect, etc.) that overwrites DNS on network interfaces. ## How pf Works (Relevant Basics) pf is a stateful packet filter built into macOS (and BSD). It processes packets through a pipeline with **strict rule ordering**: ``` options (set) → normalization (scrub) → queueing → translation (nat/rdr) → filtering (pass/block) ``` **Anchors** are named rule containers that allow programs to manage their own rules without modifying the global ruleset. Each anchor type must appear in the correct section: | Anchor Type | Section | Purpose | |-------------|---------|---------| | `scrub-anchor` | Normalization | Packet normalization | | `nat-anchor` | Translation | NAT rules (not used by ctrld) | | `rdr-anchor` | Translation | Redirect rules | | `anchor` | Filtering | Pass/block rules | **Critical constraint:** If you place a `rdr-anchor` line after an `anchor` line, pf rejects the entire config with "Rules must be in order." ## Why We Can't Just Use `rdr on ! lo0` The obvious approach: ``` rdr pass on ! lo0 proto udp from any to any port 53 -> 127.0.0.1 port 53 ``` **This doesn't work.** macOS pf `rdr` rules only apply to *forwarded/routed* traffic — packets passing through the machine to another destination. DNS queries originating from the machine itself (locally-originated) are never matched by `rdr` on non-loopback interfaces. This is a well-known pf limitation on macOS/BSD. It means the VPN client's DNS queries would be redirected (if routed through the machine), but the user's own applications querying DNS directly would not. ## Our Approach: route-to + rdr (Two-Step) We use a two-step technique to intercept locally-originated DNS: ``` Step 1: Force outbound DNS through loopback pass out quick on ! lo0 route-to lo0 inet proto udp from any to ! 127.0.0.1 port 53 Step 2: Pass the packet outbound on lo0 (needed when VPN firewalls have "block drop all") pass out quick on lo0 inet proto udp from any to ! 127.0.0.1 port 53 no state Step 3: Redirect it on loopback to ctrld's listener rdr on lo0 inet proto udp from any to ! 127.0.0.1 port 53 -> 127.0.0.1 port 53 Step 4: Accept and create state for response routing pass in quick on lo0 reply-to lo0 inet proto { udp, tcp } from any to 127.0.0.1 port 53 ``` > **State handling is critical for VPN firewall coexistence:** > - **route-to**: `keep state` (default). State is interface-bound on macOS — doesn't match on lo0. > - **pass out lo0**: `no state`. If this created state, it would match inbound on lo0 and bypass rdr. > - **rdr**: no `pass` keyword. Packet must go through filter so `pass in` can create response state. > - **pass in lo0**: `keep state` (default). Creates the ONLY state on lo0 — handles response routing. ### Packet Flow ``` Application queries 10.255.255.3:53 (e.g., VPN DNS server) ↓ Kernel: outbound on en0 (or utun420 for VPN) ↓ pf filter: "pass out route-to lo0 ... port 53" → redirects to lo0, creates state on en0 ↓ pf filter (outbound lo0): "pass out on lo0 ... no state" → passes, NO state created ↓ Loopback reflects packet inbound on lo0 ↓ pf rdr (inbound lo0): "rdr on lo0 ... port 53 -> 127.0.0.1:53" → rewrites destination ↓ pf filter (inbound lo0): "pass in reply-to lo0 ... to 127.0.0.1:53" → creates state + reply route ↓ ctrld receives query on 127.0.0.1:53 ↓ ctrld resolves via DoH (port 443, exempted by group _ctrld) ↓ Response from ctrld: 127.0.0.1:53 → 100.94.163.168:54851 ↓ reply-to lo0: forces response through lo0 (without this, kernel routes via utun420 → lost in VPN tunnel) ↓ pf applies rdr reverse NAT: src 127.0.0.1 → 10.255.255.3 ↓ Application receives response from 10.255.255.3:53 ✓ ``` ### Why This Works 1. `route-to lo0` forces the packet onto loopback at the filter stage 2. `pass out on lo0 no state` gets past VPN "block drop all" without creating state 3. No state on lo0 means rdr gets fresh evaluation on the inbound pass 4. `reply-to lo0` on `pass in` forces the response through lo0 — without it, the kernel routes the response to VPN tunnel IPs via the VPN interface and it's lost 4. `rdr` (without `pass`) redirects then hands off to filter rules 5. `pass in keep state` creates the response state — the only state on the lo0 path 6. Traffic already destined for `127.0.0.1` is excluded (`to ! 127.0.0.1`) to prevent loops 7. ctrld's own upstream queries use DoH (port 443), bypassing port 53 rules entirely ### Why Each State Decision Matters | Rule | State | Why | |------|-------|-----| | route-to on en0/utun | keep state | Needed for return routing. Interface-bound, won't match on lo0. | | pass out on lo0 | **no state** | If stateful, it would match inbound lo0 → bypass rdr → DNS broken | | rdr on lo0 | N/A (no pass) | Must go through filter so pass-in creates response state | | pass in on lo0 | keep state + reply-to lo0 | Creates lo0 state. `reply-to` forces response through lo0 (not VPN tunnel). | ## IPv6 DNS Interception macOS systems with IPv6 nameservers (common — `scutil --dns` often shows an IPv6 nameserver at index 0) send DNS queries over IPv6. Without IPv6 interception, these queries bypass ctrld, causing ~1s delays (the IPv6 query times out, then the app falls back to IPv4). ### Why IPv6 Needs Special Handling Three problems prevent a simple "mirror the IPv4 rules" approach: 1. **Cross-AF redirect is impossible**: pf cannot `rdr on lo0 inet6 ... -> 127.0.0.1` (redirecting IPv6 to IPv4). ctrld must listen on `[::1]` to handle IPv6 DNS. 2. **`block return` is ineffective for IPv6 DNS**: BSD doesn't deliver ICMPv6 unreachable errors to unconnected UDP sockets (which `dig` and most resolvers use). So `block return out inet6 ... port 53` generates the ICMP error, but the application never receives it — it waits for the full timeout (~1s). 3. **sendmsg from `[::1]` to global unicast fails**: Unlike IPv4 where the kernel allows `sendmsg` from `127.0.0.1` to local private IPs (e.g., `10.x.x.x`), macOS/BSD rejects `sendmsg` from `[::1]` to a global unicast IPv6 address with `EINVAL`. Since pf's `rdr` preserves the original source IP (the machine's global IPv6 address), ctrld's reply would fail. ### Solution: Block IPv6 DNS, Fallback to IPv4 After extensive testing (#507), IPv6 DNS interception on macOS is not feasible with current pf capabilities. The solution is to block all outbound IPv6 DNS: ``` block out quick on ! lo0 inet6 proto { udp, tcp } from any to any port 53 ``` macOS automatically retries DNS over IPv4 when the IPv6 path is blocked. The IPv4 path is fully intercepted via the normal route-to + rdr mechanism. Impact is minimal — at most ~1s latency on the very first DNS query while the IPv6 attempt is blocked. ### What Was Tried and Why It Failed | Approach | Result | |----------|--------| | `nat on lo0 inet6` to rewrite source to `::1` | pf skips translation on second interface pass — nat doesn't fire for route-to'd packets arriving on lo0 | | ULA address on lo0 (`fd00:53::1`) | Kernel rejects: `EHOSTUNREACH` — lo0's routing table is segregated from global unicast | | Raw IPv6 socket (`SOCK_RAW` + `IPPROTO_UDP`) | Bypasses sendmsg validation, but pf doesn't match raw socket packets against rdr state — response arrives from `::1` not the original server | | `DIOCNATLOOK` to get original dest + raw socket from that addr | Can't `bind()` to a non-local address (`EADDRNOTAVAIL`) — macOS has no `IPV6_HDRINCL` for source spoofing | | BPF packet injection on lo0 | Theoretically possible but extremely complex — not justified for the marginal benefit | ### IPv6 Listener The `[::1]` listener is used on: - **Windows**: Always (if IPv6 is available) — Windows can't easily suppress IPv6 DNS resolvers - **macOS**: **Not used** — IPv6 DNS is blocked at pf, no listener needed ## Rule Ordering Within the Anchor pf requires translation rules before filter rules, even within an anchor: ```pf # === Translation rules (MUST come first) === rdr on lo0 inet proto udp from any to ! 127.0.0.1 port 53 -> 127.0.0.1 port 53 rdr on lo0 inet proto tcp from any to ! 127.0.0.1 port 53 -> 127.0.0.1 port 53 # === Exemptions (filter phase, scoped to _ctrld group) === pass out quick on ! lo0 inet proto { udp, tcp } from any to port 53 group _ctrld pass out quick on ! lo0 inet proto { udp, tcp } from any to port 53 group _ctrld # === Main intercept (filter phase) === pass out quick on ! lo0 route-to lo0 inet proto udp from any to ! 127.0.0.1 port 53 pass out quick on ! lo0 route-to lo0 inet proto tcp from any to ! 127.0.0.1 port 53 # === Allow redirected traffic on loopback === pass in quick on lo0 reply-to lo0 inet proto { udp, tcp } from any to 127.0.0.1 port 53 ``` ### Exemption Mechanism (Group-Scoped) Some IPs must bypass the redirect: - **OS resolver nameservers** (e.g., DHCP-assigned DNS): ctrld's recovery/bootstrap path may query these on port 53. Without exemption, these queries loop back to ctrld. - **VPN DNS servers**: When ctrld forwards VPN-specific domains (split DNS) to the VPN's internal DNS, those queries must reach the VPN DNS server directly. Exemptions use `pass out quick` with `group _ctrld` **before** the `route-to` rule. The `group _ctrld` constraint ensures that **only ctrld's own process** can bypass the redirect — other applications cannot circumvent DNS interception by querying the exempted IPs directly. Because pf evaluates filter rules in order and `quick` terminates evaluation, the exempted packet goes directly out the real interface and never hits the `route-to` or `rdr`. ### The `_ctrld` Group To scope pf exemptions to ctrld's process only, we use a dedicated macOS system group: 1. **Creation**: On startup, `ensureCtrldGroup()` creates a `_ctrld` system group via `dscl` (macOS Directory Services) if it doesn't already exist. The GID is chosen from the 350-450 range to avoid conflicts with Apple's reserved ranges. The function is idempotent. 2. **Process GID**: Before loading pf rules, ctrld sets its effective GID to `_ctrld` via `syscall.Setegid()`. All sockets created by ctrld after this point are tagged with this GID. 3. **pf matching**: Exemption rules include `group _ctrld`, so pf only allows bypass for packets from processes with this effective GID. Other processes querying the same exempt IPs are still redirected to ctrld. 4. **Lifecycle**: The group is **never removed** on shutdown or uninstall. It's a harmless system group, and leaving it avoids race conditions during rapid restart cycles. It is recreated (no-op if exists) on every start. ## Anchor Injection into pf.conf The trickiest part. macOS only processes anchors declared in the active pf ruleset. We must inject our anchor references into the running config. ### What We Do 1. Read `/etc/pf.conf` 2. If our anchor reference already exists, reload as-is 3. Otherwise, inject `rdr-anchor "com.controld.ctrld"` in the translation section and `anchor "com.controld.ctrld"` in the filter section 4. Write to a **temp file** and load with `pfctl -f ` 5. **We never modify `/etc/pf.conf` on disk** — changes are runtime-only and don't survive reboot (ctrld re-injects on every start) ### Injection Logic Finding the right insertion point requires understanding the existing pf.conf structure. The algorithm: 1. **Scan** for existing `rdr-anchor`/`nat-anchor`/`binat-anchor` lines (translation section) and `anchor` lines (filter section) 2. **Insert `rdr-anchor`**: - Before the first existing `rdr-anchor` line (if any exist) - Else before the first `anchor` line (translation must come before filtering) - Else before the first `pass`/`block` line - Last resort: append (but this should never happen with a valid pf.conf) 3. **Insert `anchor`**: - Before the first existing `anchor` line (if any) - Else before the first `pass`/`block` line - Last resort: append ### Real-World pf.conf Scenarios We test against these configurations: #### Default macOS (Sequoia/Sonoma) ``` scrub-anchor "com.apple/*" nat-anchor "com.apple/*" rdr-anchor "com.apple/*" anchor "com.apple/*" load anchor "com.apple" from "/etc/pf.anchors/com.apple" ``` Our `rdr-anchor` goes before `rdr-anchor "com.apple/*"`, our `anchor` goes before `anchor "com.apple/*"`. #### Little Snitch Adds `rdr-anchor "com.obdev.littlesnitch"` and `anchor "com.obdev.littlesnitch"` in the appropriate sections. Our anchors coexist — pf processes multiple anchors in order. #### Lulu Firewall (Objective-See) Adds `anchor "com.objective-see.lulu"`. We insert `rdr-anchor` before it (translation before filtering) and `anchor` before it. #### Cisco AnyConnect Adds `nat-anchor "com.cisco.anyconnect"`, `rdr-anchor "com.cisco.anyconnect"`, `anchor "com.cisco.anyconnect"`. Our anchors insert alongside Cisco's in their respective sections. #### Minimal pf.conf (no anchors) Just `set skip on lo0` and `pass all`. We insert `rdr-anchor` and `anchor` before the `pass` line. #### Empty pf.conf Both anchors appended. This is a degenerate case that shouldn't occur in practice. ## Failure Modes and Safety ### What happens if our injection fails? - `ensurePFAnchorReference` returns an error, logged as a warning - ctrld continues running but DNS interception may not work - The anchor file and rules are cleaned up on shutdown - **No damage to existing pf config** — we never modify files on disk ### What happens if ctrld crashes (SIGKILL)? - pf anchor rules persist in kernel memory - DNS is redirected to 127.0.0.1:53 but nothing is listening → DNS breaks - On next `ctrld start`, we detect the stale anchor file, flush the anchor, and start fresh - Without ctrld restart: `sudo pfctl -a com.controld.ctrld -F all` manually clears it ### What if another program flushes all pf rules? - Our anchor references are removed from the running config - DNS interception stops (traffic goes direct again — fails open, not closed) - The periodic watchdog (30s) detects missing rules and restores them - ctrld continues working for queries sent to 127.0.0.1 directly ### What if another program reloads pf.conf (corrupting translation state)? Programs like Parallels Desktop reload `/etc/pf.conf` when creating or destroying virtual network interfaces (bridge100, vmenet0). This can corrupt pf's internal translation engine — **rdr rules survive in text form but stop evaluating**. The watchdog's rule-text checks say "intact" while DNS is silently broken. **Detection:** ctrld detects interface appearance/disappearance in the network change handler and spawns an asynchronous interception probe monitor: 1. A subprocess sends a DNS query WITHOUT the `_ctrld` group GID, so pf intercept rules apply to it 2. If ctrld receives the query → pf interception is working 3. A successful UDP write without local receipt within 1s gives `not_intercepted`. 4. Only this meaningful failure permits probe-triggered repair: `forceReloadPFMainRuleset()` does `pfctl -f -` with the current running ruleset, resetting pf's translation engine A missing target or helper failure gives `indeterminate` and does not cause a reload. The probe uses IPv4 only. See [target selection and diagnostics](network-recovery-diagnostics.md#pf-probes-macos). The monitor probes with exponential backoff (0, 0.5, 1, 2, 4s) to win the race against async pf reloads. Only one monitor runs at a time (singleton). The watchdog also runs the probe every 30s as a safety net. A network delta that touches AirDrop or virtual adapters alone is noise. The handler returns at once for it, so no probe monitor starts and no pf read runs. A delta that moves the default route, that flips the v4 or the v6 family, or that carries a time jump is not noise, whatever its interfaces are. The watchdog keeps its own 30 s cadence, so it still tests the rules during a delta storm. The full pf reload is VPN-safe: it reassembles from `pfctl -sr` + `pfctl -sn` (the current running state), preserving all existing anchors and rules. ### What if another program adds conflicting rdr rules? - pf processes anchors in declaration order - If another program redirects port 53 before our anchor, their redirect wins - If after, ours wins (first match with `quick` or `rdr pass`) - Our maximum-weight sublayer approach on Windows (WFP) doesn't apply to pf — pf uses rule ordering, not weights ### What about `set skip on lo0`? Some pf.conf files include `set skip on lo0` which tells pf to skip ALL processing on loopback. **This would break our approach** since both the `rdr on lo0` and `pass in on lo0` rules would be skipped. **Mitigation:** the interception probe. `probePFIntercept()` sends a real query from outside the `_ctrld` group and confirms the listener received the redirect, which cannot succeed while pf bypasses loopback. A confirmed send without receipt permits a full reload. An unsent or unavailable probe is indeterminate and does not trigger repair. **Not implemented, contrary to earlier versions of this document:** ctrld does *not* strip `lo0` from `set skip on` directives, and the watchdog does *not* inspect skip state. Apple's `pfctl` offers no way to read it — `pfctl(8)` accepts `-s` nat, queue, rules, Anchors, states, Sources, info, References, labels, timeouts, memory, Tables, osfp, Interfaces, all, with no options or skip modifier — so text-based detection is not available on macOS. Adding an explicit check is tracked as follow-up: `pfctl(8)` documents `-s Interfaces -v` as additionally listing which interfaces have skip rules activated, which is the query to build on once its output shape is confirmed on a host that has a skip configured. ## Cleanup On shutdown (`stopDNSIntercept`): 1. `pfctl -a com.controld.ctrld -F all` — flush all rules from our anchor 2. Remove `/etc/pf.anchors/com.controld.ctrld` anchor file 3. `pfctl -f /etc/pf.conf` — reload original pf.conf, removing our injected anchor references from the running config This is clean: no files modified on disk, no residual rules. ## Comparison with Other Approaches | Approach | Intercepts local DNS? | Survives VPN DNS override? | Risk of loops? | Complexity | |----------|----------------------|---------------------------|----------------|------------| | `rdr on ! lo0` | ❌ No | Yes | Low | Low | | `route-to lo0` + `rdr on lo0` | ✅ Yes | Yes | Medium (need exemptions) | Medium | | `/etc/resolver/` | Partial (per-domain only) | No (VPN can overwrite) | Low | Low | | `NEDNSProxyProvider` | ✅ Yes | Yes | Low | High (needs app bundle) | | NRPT (Windows only) | N/A | Partial | Low | Medium | We chose `route-to + rdr` as the best balance of effectiveness and deployability (no app bundle needed, no kernel extension, works with existing ctrld binary). ## Key pf Nuances Learned 1. **`rdr` doesn't match locally-originated traffic** — this is the biggest gotcha 2. **Rule ordering is enforced** — translation before filtering, always 3. **Anchors must be declared in the main ruleset** — just loading an anchor file isn't enough 4. **`rdr` without `pass`** — redirected packets must go through filter rules so `pass in keep state` can create response state. `rdr pass` alone is insufficient for response delivery. 5. **State handling is nuanced** — route-to uses `keep state` (state is floating). `pass out on lo0` must use `no state` (prevents rdr bypass). `pass in on lo0` uses `keep state` + `reply-to lo0` (creates response state AND forces response through loopback instead of VPN tunnel). Getting any of these wrong breaks either the forward or return path. 6. **`quick` terminates evaluation** — exemption rules must use `quick` and appear before the route-to rule 7. **Piping to `pfctl -f -` can fail** — special characters in pf.conf content cause issues; use temp files 8. **`set skip on lo0` would break us** — but it's not in default macOS pf.conf 9. **`pass out quick` exemptions work with route-to** — they fire in the same phase (filter), so `quick` + rule ordering means exempted packets never hit the route-to rule 10. **pf cannot cross-AF redirect** — `rdr on lo0 inet6 ... -> 127.0.0.1` is invalid. IPv6 DNS must be handled by an `[::1]` listener. 11. **`block return` doesn't work for IPv6 DNS** — BSD doesn't deliver ICMPv6 unreachable to unconnected UDP sockets (`sendto`). Apps timeout waiting for a response that never comes. 12. **sendmsg from `::1` to global unicast fails on macOS** — unlike IPv4 where `127.0.0.1` can send to any local address, `::1` cannot send to the machine's own global IPv6 address (`EINVAL`). This is the fundamental asymmetry that makes IPv6 DNS interception infeasible. 13. **`nat on lo0` doesn't fire for `route-to`'d packets** — pf runs translation on the original outbound interface (en0), then skips it on lo0's outbound pass. `rdr` works because lo0 inbound is a genuinely new direction. Any lo0 address (including ULAs) can't route to global unicast — the kernel segregates lo0's routing table. 14. **Raw IPv6 sockets bypass routing validation but pf doesn't match them** — `SOCK_RAW` can send from `::1` to global unicast, but pf treats raw socket packets as new connections (not matching rdr state), so reverse-translation doesn't happen. The client sees `::1` as the source, not the original DNS server. 15. **`DIOCNATLOOK` can find the original dest but you can't use it** — The ioctl returns the pre-rdr destination, but `bind()` fails with `EADDRNOTAVAIL` because it's not a local address. macOS IPv6 raw sockets don't support `IPV6_HDRINCL` for source spoofing. 16. **Blocking IPv6 DNS is the pragmatic solution** — macOS automatically retries over IPv4. The ~1s penalty on the first blocked query is negligible compared to the complexity of working around the kernel's IPv6 loopback restrictions.