16 KiB
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 listener at 127.0.0.1:53. 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 |
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
passkeyword. Packet must go through filter sopass incan 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
route-to lo0forces the packet onto loopback at the filter stagepass out on lo0 no stategets past VPN "block drop all" without creating state- No state on lo0 means rdr gets fresh evaluation on the inbound pass
reply-to lo0onpass inforces the response through lo0 — without it, the kernel routes the response to VPN tunnel IPs via the VPN interface and it's lostrdr(withoutpass) redirects then hands off to filter rulespass in keep statecreates the response state — the only state on the lo0 path- Traffic already destined for
127.0.0.1is excluded (to ! 127.0.0.1) to prevent loops - 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). |
Rule Ordering Within the Anchor
pf requires translation rules before filter rules, even within an anchor:
# === 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 <OS_RESOLVER_IP> port 53 group _ctrld
pass out quick on ! lo0 inet proto { udp, tcp } from any to <VPN_DNS_IP> 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:
-
Creation: On startup,
ensureCtrldGroup()creates a_ctrldsystem group viadscl(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. -
Process GID: Before loading pf rules, ctrld sets its effective GID to
_ctrldviasyscall.Setegid(). All sockets created by ctrld after this point are tagged with this GID. -
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. -
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
- Read
/etc/pf.conf - If our anchor reference already exists, reload as-is
- Otherwise, inject
rdr-anchor "com.controld.ctrld"in the translation section andanchor "com.controld.ctrld"in the filter section - Write to a temp file and load with
pfctl -f <tmpfile> - We never modify
/etc/pf.confon 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:
- Scan for existing
rdr-anchor/nat-anchor/binat-anchorlines (translation section) andanchorlines (filter section) - Insert
rdr-anchor:- Before the first existing
rdr-anchorline (if any exist) - Else before the first
anchorline (translation must come before filtering) - Else before the first
pass/blockline - Last resort: append (but this should never happen with a valid pf.conf)
- Before the first existing
- Insert
anchor:- Before the first existing
anchorline (if any) - Else before the first
pass/blockline - Last resort: append
- Before the first existing
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?
ensurePFAnchorReferencereturns 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 allmanually 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:
- A subprocess sends a DNS query WITHOUT the
_ctrldgroup GID, so pf intercept rules apply to it - If ctrld receives the query → pf interception is working
- If the query times out (1s) → pf translation is broken
- On failure:
forceReloadPFMainRuleset()doespfctl -f -with the current running ruleset, resetting pf's translation engine
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.
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
quickorrdr 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: When injecting anchor references via ensurePFAnchorReference(),
we strip lo0 from any set skip on directives before reloading. The watchdog
also checks for set skip on lo0 and triggers a restore if detected. The
interception probe provides an additional safety net — if set skip on lo0 gets
re-applied by another program, the probe will fail and trigger a full reload.
Cleanup
On shutdown (stopDNSIntercept):
pfctl -a com.controld.ctrld -F all— flush all rules from our anchor- Remove
/etc/pf.anchors/com.controld.ctrldanchor file 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
rdrdoesn't match locally-originated traffic — this is the biggest gotcha- Rule ordering is enforced — translation before filtering, always
- Anchors must be declared in the main ruleset — just loading an anchor file isn't enough
rdrwithoutpass— redirected packets must go through filter rules sopass in keep statecan create response state.rdr passalone is insufficient for response delivery.- State handling is nuanced — route-to uses
keep state(state is floating).pass out on lo0must useno state(prevents rdr bypass).pass in on lo0useskeep 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. quickterminates evaluation — exemption rules must usequickand appear before the route-to rule- Piping to
pfctl -f -can fail — special characters in pf.conf content cause issues; use temp files set skip on lo0would break us — but it's not in default macOS pf.confpass out quickexemptions work with route-to — they fire in the same phase (filter), soquick+ rule ordering means exempted packets never hit the route-to rule