From 3f59cdad1a6d2e9909fa064a3a43b1adb0f2c517 Mon Sep 17 00:00:00 2001 From: Codescribe Date: Mon, 30 Mar 2026 20:52:35 -0400 Subject: [PATCH] fix: block IPv6 DNS in intercept mode, remove raw socket approach IPv6 DNS interception on macOS is not feasible with current pf capabilities. The kernel rejects sendmsg from [::1] to global unicast (EINVAL), nat on lo0 doesn't fire for route-to'd packets, raw sockets bypass routing but pf doesn't match them against rdr state, and DIOCNATLOOK can't be used because bind() fails for non-local addresses. Replace all IPv6 interception code with a simple pf block rule: block out quick on ! lo0 inet6 proto { udp, tcp } from any to any port 53 macOS automatically retries DNS over IPv4 when IPv6 is blocked. Changes: - Remove rawipv6_darwin.go and rawipv6_other.go - Remove [::1] listener spawn on macOS (needLocalIPv6Listener returns false) - Remove IPv6 rdr, route-to, pass, and reply-to pf rules - Add block rule for all outbound IPv6 DNS - Update docs/pf-dns-intercept.md with what was tried and why it failed --- cmd/cli/dns_intercept_darwin.go | 26 ++--- cmd/cli/dns_proxy.go | 23 ++--- cmd/cli/rawipv6_darwin.go | 163 -------------------------------- cmd/cli/rawipv6_other.go | 12 --- docs/pf-dns-intercept.md | 75 ++++----------- 5 files changed, 41 insertions(+), 258 deletions(-) delete mode 100644 cmd/cli/rawipv6_darwin.go delete mode 100644 cmd/cli/rawipv6_other.go diff --git a/cmd/cli/dns_intercept_darwin.go b/cmd/cli/dns_intercept_darwin.go index 0854efc..62fc73f 100644 --- a/cmd/cli/dns_intercept_darwin.go +++ b/cmd/cli/dns_intercept_darwin.go @@ -796,11 +796,11 @@ func (p *prog) buildPFAnchorRules(vpnExemptions []vpnDNSExemption) string { // proven by commit 51cf029 where responses were silently dropped. rules.WriteString("# --- Translation rules (rdr) ---\n") - listenerAddr6 := fmt.Sprintf("::1 port %d", listenerPort) rules.WriteString("# Redirect DNS on loopback to ctrld's listener.\n") rules.WriteString(fmt.Sprintf("rdr on lo0 inet proto udp from any to ! %s port 53 -> %s\n", listenerIP, listenerAddr)) rules.WriteString(fmt.Sprintf("rdr on lo0 inet proto tcp from any to ! %s port 53 -> %s\n", listenerIP, listenerAddr)) - rules.WriteString(fmt.Sprintf("rdr on lo0 inet6 proto udp from any to ! ::1 port 53 -> %s\n\n", listenerAddr6)) + // No IPv6 rdr — IPv6 DNS is blocked at the filter level (see below). + rules.WriteString("\n") // --- Filtering rules --- rules.WriteString("# --- Filtering rules (pass) ---\n\n") @@ -962,7 +962,7 @@ func (p *prog) buildPFAnchorRules(vpnExemptions []vpnDNSExemption) string { } rules.WriteString(fmt.Sprintf("pass out quick on %s route-to lo0 inet proto udp from any to ! %s port 53\n", iface, listenerIP)) rules.WriteString(fmt.Sprintf("pass out quick on %s route-to lo0 inet proto tcp from any to ! %s port 53\n", iface, listenerIP)) - rules.WriteString(fmt.Sprintf("pass out quick on %s route-to lo0 inet6 proto udp from any to ! ::1 port 53\n", iface)) + // No IPv6 route-to — IPv6 DNS is blocked, not intercepted. } rules.WriteString("\n") } @@ -982,13 +982,14 @@ func (p *prog) buildPFAnchorRules(vpnExemptions []vpnDNSExemption) string { rules.WriteString(fmt.Sprintf("pass out quick on ! lo0 route-to lo0 inet proto udp from any to ! %s port 53\n", listenerIP)) rules.WriteString(fmt.Sprintf("pass out quick on ! lo0 route-to lo0 inet proto tcp from any to ! %s port 53\n\n", listenerIP)) - // Force remaining outbound IPv6 UDP DNS through loopback for interception. - // IPv6 TCP DNS is blocked instead — raw socket response injection only handles UDP, - // and TCP DNS is rare (truncated responses, zone transfers). Apps fall back to IPv4 TCP. - rules.WriteString("# Force remaining outbound IPv6 UDP DNS through loopback for interception.\n") - rules.WriteString("pass out quick on ! lo0 route-to lo0 inet6 proto udp from any to ! ::1 port 53\n") - rules.WriteString("# Block IPv6 TCP DNS — raw socket can't handle TCP; apps fall back to IPv4.\n") - rules.WriteString("block return out quick on ! lo0 inet6 proto tcp from any to ! ::1 port 53\n\n") + // Block all outbound IPv6 DNS. ctrld only intercepts IPv4 DNS via the loopback + // redirect. IPv6 DNS interception on macOS is not feasible because the kernel rejects + // sendmsg from [::1] to global unicast IPv6 (EINVAL), and pf's nat-on-lo0 doesn't + // fire for route-to'd packets. Blocking forces macOS to fall back to IPv4 DNS, + // which is fully intercepted. See docs/pf-dns-intercept.md for details. + rules.WriteString("# Block outbound IPv6 DNS — ctrld intercepts IPv4 only.\n") + rules.WriteString("# macOS falls back to IPv4 DNS automatically.\n") + rules.WriteString("block out quick on ! lo0 inet6 proto { udp, tcp } from any to any port 53\n\n") // Allow route-to'd DNS packets to pass outbound on lo0. // Without this, VPN firewalls with "block drop all" (e.g., Windscribe) drop the packet @@ -1000,7 +1001,8 @@ func (p *prog) buildPFAnchorRules(vpnExemptions []vpnDNSExemption) string { rules.WriteString("# Pass route-to'd DNS outbound on lo0 — no state to avoid bypassing rdr inbound.\n") rules.WriteString(fmt.Sprintf("pass out quick on lo0 inet proto udp from any to ! %s port 53 no state\n", listenerIP)) rules.WriteString(fmt.Sprintf("pass out quick on lo0 inet proto tcp from any to ! %s port 53 no state\n", listenerIP)) - rules.WriteString("pass out quick on lo0 inet6 proto udp from any to ! ::1 port 53 no state\n\n") + // No IPv6 lo0 pass — IPv6 DNS is blocked, not routed through lo0. + rules.WriteString("\n") // Allow the redirected traffic through on loopback (inbound after rdr). // @@ -1015,7 +1017,7 @@ func (p *prog) buildPFAnchorRules(vpnExemptions []vpnDNSExemption) string { // (source 127.0.0.1 → original DNS server IP, e.g., 10.255.255.3). rules.WriteString("# Accept redirected DNS — reply-to lo0 forces response through loopback.\n") rules.WriteString(fmt.Sprintf("pass in quick on lo0 reply-to lo0 inet proto { udp, tcp } from any to %s\n", listenerAddr)) - rules.WriteString(fmt.Sprintf("pass in quick on lo0 reply-to lo0 inet6 proto udp from any to %s\n", listenerAddr6)) + // No IPv6 pass-in — IPv6 DNS is blocked, not redirected to [::1]. return rules.String() } diff --git a/cmd/cli/dns_proxy.go b/cmd/cli/dns_proxy.go index cb8393d..f1aa810 100644 --- a/cmd/cli/dns_proxy.go +++ b/cmd/cli/dns_proxy.go @@ -211,11 +211,7 @@ func (p *prog) serveDNS(listenerNum string) error { proto := proto if needLocalIPv6Listener(p.cfg.Service.InterceptMode) { g.Go(func() error { - var ipv6Handler dns.Handler = handler - if proto == "udp" { - ipv6Handler = wrapIPv6Handler(handler) - } - s, errCh := runDNSServer(net.JoinHostPort("::1", strconv.Itoa(listenerConfig.Port)), proto, ipv6Handler) + s, errCh := runDNSServer(net.JoinHostPort("::1", strconv.Itoa(listenerConfig.Port)), proto, handler) defer s.Shutdown() select { case <-p.stopCh: @@ -907,16 +903,13 @@ func needLocalIPv6Listener(interceptMode string) bool { mainLog.Load().Debug().Msg("IPv6 listener: enabled (Windows)") return true } - // On macOS in intercept mode, pf can't redirect IPv6 DNS to an IPv4 listener (cross-AF rdr - // not supported), and blocking IPv6 DNS causes ~1s timeouts (BSD doesn't deliver ICMP errors - // to unconnected UDP sockets). Listening on [::1] lets us intercept IPv6 DNS directly. - // - // NOTE: We accept the intercept mode string as a parameter instead of reading the global - // dnsIntercept bool, because dnsIntercept is derived later in prog.run() — after the - // listener goroutines are already spawned. Same pattern as the port 5354 fallback fix (MR !860). - if (interceptMode == "dns" || interceptMode == "hard") && runtime.GOOS == "darwin" { - mainLog.Load().Debug().Msg("IPv6 listener: enabled (macOS intercept mode)") - return true + // macOS: IPv6 DNS is blocked at the pf level (not intercepted). The [::1] listener + // is not needed — macOS falls back to IPv4 DNS automatically. See #507 and + // docs/pf-dns-intercept.md for why IPv6 interception on macOS is not feasible + // (sendmsg EINVAL from ::1 to global unicast, nat-on-lo0 doesn't fire for route-to). + if runtime.GOOS == "darwin" { + mainLog.Load().Debug().Msg("IPv6 listener: not needed (macOS — IPv6 DNS blocked at pf, fallback to IPv4)") + return false } mainLog.Load().Debug().Str("os", runtime.GOOS).Str("interceptMode", interceptMode).Msg("IPv6 listener: not needed") return false diff --git a/cmd/cli/rawipv6_darwin.go b/cmd/cli/rawipv6_darwin.go deleted file mode 100644 index 0509677..0000000 --- a/cmd/cli/rawipv6_darwin.go +++ /dev/null @@ -1,163 +0,0 @@ -//go:build darwin - -package cli - -import ( - "encoding/binary" - "fmt" - "net" - "syscall" - - "github.com/miekg/dns" -) - -// wrapIPv6Handler wraps a DNS handler so that UDP responses on the [::1] listener -// are sent via raw IPv6 sockets instead of the normal sendmsg path. This is needed -// because macOS rejects sendmsg from [::1] to global unicast IPv6 addresses (EINVAL). -func wrapIPv6Handler(h dns.Handler) dns.Handler { - return dns.HandlerFunc(func(w dns.ResponseWriter, r *dns.Msg) { - h.ServeDNS(&rawIPv6Writer{ResponseWriter: w}, r) - }) -} - -// rawIPv6Writer wraps a dns.ResponseWriter for the [::1] IPv6 listener on macOS. -// When pf redirects IPv6 DNS traffic via route-to + rdr to [::1]:53, the original -// client source address is a global unicast IPv6 (e.g., 2607:f0c8:...). macOS -// rejects sendmsg from [::1] to any non-loopback address (EINVAL), so the normal -// WriteMsg fails. This wrapper intercepts UDP writes and sends the response via a -// raw IPv6 socket on lo0, bypassing the kernel's routing validation. -// -// TCP is not handled — IPv6 TCP DNS is blocked by pf rules and falls back to IPv4. -type rawIPv6Writer struct { - dns.ResponseWriter -} - -// WriteMsg packs the DNS message and sends it via raw socket. -func (w *rawIPv6Writer) WriteMsg(m *dns.Msg) error { - data, err := m.Pack() - if err != nil { - return err - } - _, err = w.Write(data) - return err -} - -// Write sends raw DNS response bytes via a raw IPv6/UDP socket on lo0. -// It constructs a UDP packet (header + payload) and sends it using -// IPPROTO_RAW-like behavior via IPV6_HDRINCL-free raw UDP socket. -// -// pf's rdr state table will reverse-translate the addresses on the response: -// - src [::1]:53 → original DNS server IPv6 -// - dst [client]:port → unchanged -func (w *rawIPv6Writer) Write(payload []byte) (int, error) { - localAddr := w.ResponseWriter.LocalAddr() - remoteAddr := w.ResponseWriter.RemoteAddr() - - srcIP, srcPort, err := parseAddrPort(localAddr) - if err != nil { - return 0, fmt.Errorf("rawIPv6Writer: parse local addr %s: %w", localAddr, err) - } - dstIP, dstPort, err := parseAddrPort(remoteAddr) - if err != nil { - return 0, fmt.Errorf("rawIPv6Writer: parse remote addr %s: %w", remoteAddr, err) - } - - // Build UDP packet: 8-byte header + DNS payload. - udpLen := 8 + len(payload) - udpPacket := make([]byte, udpLen) - binary.BigEndian.PutUint16(udpPacket[0:2], uint16(srcPort)) - binary.BigEndian.PutUint16(udpPacket[2:4], uint16(dstPort)) - binary.BigEndian.PutUint16(udpPacket[4:6], uint16(udpLen)) - // Checksum placeholder — filled below. - binary.BigEndian.PutUint16(udpPacket[6:8], 0) - copy(udpPacket[8:], payload) - - // Compute UDP checksum over IPv6 pseudo-header + UDP packet. - // For IPv6, UDP checksum is mandatory (unlike IPv4 where it's optional). - csum := udp6Checksum(srcIP, dstIP, udpPacket) - binary.BigEndian.PutUint16(udpPacket[6:8], csum) - - // Open raw UDP socket. SOCK_RAW with IPPROTO_UDP lets us send - // hand-crafted UDP packets. The kernel adds the IPv6 header. - fd, err := syscall.Socket(syscall.AF_INET6, syscall.SOCK_RAW, syscall.IPPROTO_UDP) - if err != nil { - return 0, fmt.Errorf("rawIPv6Writer: socket: %w", err) - } - defer syscall.Close(fd) - - // Bind to lo0 interface so the packet exits on loopback where pf can - // reverse-translate via its rdr state table. - if err := bindToLoopback6(fd); err != nil { - return 0, fmt.Errorf("rawIPv6Writer: bind to lo0: %w", err) - } - - // Send to the client's address. - sa := &syscall.SockaddrInet6{Port: 0} // Port is in the UDP header, not the sockaddr for raw sockets. - copy(sa.Addr[:], dstIP.To16()) - - if err := syscall.Sendto(fd, udpPacket, 0, sa); err != nil { - return 0, fmt.Errorf("rawIPv6Writer: sendto [%s]:%d: %w", dstIP, dstPort, err) - } - - return len(payload), nil -} - -// parseAddrPort extracts IP and port from a net.Addr (supports *net.UDPAddr and string parsing). -func parseAddrPort(addr net.Addr) (net.IP, int, error) { - if ua, ok := addr.(*net.UDPAddr); ok { - return ua.IP, ua.Port, nil - } - host, portStr, err := net.SplitHostPort(addr.String()) - if err != nil { - return nil, 0, err - } - ip := net.ParseIP(host) - if ip == nil { - return nil, 0, fmt.Errorf("invalid IP: %s", host) - } - port, err := net.LookupPort("udp", portStr) - if err != nil { - return nil, 0, err - } - return ip, port, nil -} - -// udp6Checksum computes the UDP checksum over the IPv6 pseudo-header and UDP packet. -// The pseudo-header includes: src IP (16), dst IP (16), UDP length (4), next header (4). -func udp6Checksum(src, dst net.IP, udpPacket []byte) uint16 { - // IPv6 pseudo-header for checksum: - // Source Address (16 bytes) - // Destination Address (16 bytes) - // UDP Length (4 bytes, upper layer packet length) - // Zero (3 bytes) + Next Header (1 byte) = 17 (UDP) - psh := make([]byte, 40) - copy(psh[0:16], src.To16()) - copy(psh[16:32], dst.To16()) - binary.BigEndian.PutUint32(psh[32:36], uint32(len(udpPacket))) - psh[39] = 17 // Next Header: UDP - - // Checksum over pseudo-header + UDP packet. - var sum uint32 - data := append(psh, udpPacket...) - for i := 0; i+1 < len(data); i += 2 { - sum += uint32(binary.BigEndian.Uint16(data[i : i+2])) - } - if len(data)%2 == 1 { - sum += uint32(data[len(data)-1]) << 8 - } - for sum > 0xffff { - sum = (sum >> 16) + (sum & 0xffff) - } - return ^uint16(sum) -} - -// bindToLoopback6 binds a raw IPv6 socket to the loopback interface (lo0) -// and sets the source address to ::1. This ensures the packet exits on lo0 -// where pf's rdr state can reverse-translate the addresses. -func bindToLoopback6(fd int) error { - // Bind source to ::1 — this is the address ctrld is listening on, - // and what pf's rdr state expects as the source of the response. - sa := &syscall.SockaddrInet6{Port: 0} - copy(sa.Addr[:], net.IPv6loopback.To16()) - return syscall.Bind(fd, sa) -} diff --git a/cmd/cli/rawipv6_other.go b/cmd/cli/rawipv6_other.go deleted file mode 100644 index f99895d..0000000 --- a/cmd/cli/rawipv6_other.go +++ /dev/null @@ -1,12 +0,0 @@ -//go:build !darwin - -package cli - -import "github.com/miekg/dns" - -// wrapIPv6Handler is a no-op on non-darwin platforms. The raw IPv6 response -// writer is only needed on macOS where pf's rdr preserves the original global -// unicast source address, and the kernel rejects sendmsg from [::1] to it. -func wrapIPv6Handler(h dns.Handler) dns.Handler { - return h -} diff --git a/docs/pf-dns-intercept.md b/docs/pf-dns-intercept.md index 213619b..f8cbb42 100644 --- a/docs/pf-dns-intercept.md +++ b/docs/pf-dns-intercept.md @@ -122,70 +122,31 @@ Three problems prevent a simple "mirror the IPv4 rules" approach: 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: Raw Socket Response + rdr + [::1] Listener +### Solution: Block IPv6 DNS, Fallback to IPv4 -**Key insight:** pf's `nat on lo0` doesn't fire for `route-to`'d packets (pf already ran the translation phase on the original outbound interface and skips it on lo0's outbound pass). `rdr` works because it fires on lo0's *inbound* side (a new direction after loopback reflection). So we can't use `nat` to rewrite the source, and any address bound to lo0 (including ULAs like `fd00:53::1`) can't send to global unicast addresses — the kernel segregates lo0's routing. - -Instead, we use a **raw IPv6 socket** to send UDP responses. The `[::1]` listener receives queries normally via `rdr`, but responses are sent via `SOCK_RAW` with `IPPROTO_UDP`, bypassing the kernel's routing validation. The raw socket constructs the UDP packet (header + DNS payload) with correct checksums and sends it on lo0. pf matches the response against the `rdr` state table and reverse-translates the addresses. - -**IPv6 TCP DNS** is blocked (`block return`) and falls back to IPv4 — TCP DNS is rare (truncated responses, zone transfers) and raw socket injection for TCP would require managing the full TCP state machine. +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: ``` -# RDR: redirect IPv6 UDP DNS to ctrld's listener (no nat needed) -rdr on lo0 inet6 proto udp from any to ! ::1 port 53 -> ::1 port 53 - -# Filter: route-to forces IPv6 UDP DNS to loopback -pass out quick on ! lo0 route-to lo0 inet6 proto udp from any to ! ::1 port 53 - -# Block IPv6 TCP DNS — raw socket can't handle TCP; apps fall back to IPv4 -block return out quick on ! lo0 inet6 proto tcp from any to ! ::1 port 53 - -# Pass on lo0 without state (mirrors IPv4) -pass out quick on lo0 inet6 proto udp from any to ! ::1 port 53 no state - -# Accept redirected IPv6 DNS with reply-to (mirrors IPv4) -pass in quick on lo0 reply-to lo0 inet6 proto udp from any to ::1 port 53 +block out quick on ! lo0 inet6 proto { udp, tcp } from any to any port 53 ``` -### IPv6 Packet Flow (UDP) +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. -``` -Application queries [2607:f0c8:8000:8210::1]:53 (IPv6 DNS server) - ↓ -pf filter: "pass out route-to lo0 inet6 proto udp ... port 53" → redirects to lo0 - ↓ -pf (outbound lo0): "pass out on lo0 inet6 ... no state" → passes - ↓ -Loopback reflects packet inbound on lo0 - ↓ -pf rdr: rewrites dest [2607:f0c8:8000:8210::1]:53 → [::1]:53 -(source remains: 2607:f0c8:...:ec6e — the machine's global IPv6) - ↓ -ctrld receives query from [2607:f0c8:...:ec6e]:port → [::1]:53 - ↓ -ctrld resolves via DoH upstream - ↓ -Raw IPv6 socket sends response: [::1]:53 → [2607:f0c8:...:ec6e]:port -(bypasses kernel routing validation — raw socket on lo0) - ↓ -pf reverses rdr: src [::1]:53 → [2607:f0c8:8000:8210::1]:53 - ↓ -Application receives response from [2607:f0c8:8000:8210::1]:53 ✓ -``` +### What Was Tried and Why It Failed -### Client IP Recovery - -pf's `rdr` preserves the original source (machine's global IPv6), so ctrld sees the real address. The existing `spoofLoopbackIpInClientInfo()` logic replaces loopback IPs with the machine's real RFC1918 IPv4 address for `X-Cd-Ip` reporting. For IPv6 intercepted queries, the source is already the real address — no spoofing needed. +| 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 reuses the existing infrastructure from Windows (where it was added for the same reason — can't suppress IPv6 DNS resolvers from the system config). The `needLocalIPv6Listener()` function gates it, returning `true` on: -- **Windows**: Always (if IPv6 is available) -- **macOS**: Only in intercept mode - -On macOS, the UDP handler is wrapped with `rawIPv6Writer` which intercepts `WriteMsg`/`Write` calls and sends responses via a raw IPv6 socket on lo0 instead of the normal `sendmsg` path. - -If the `[::1]` listener fails to bind, it logs a warning and continues — the IPv4 listener is primary. +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 @@ -377,6 +338,8 @@ We chose `route-to + rdr` as the best balance of effectiveness and deployability 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. Solved with raw socket response injection (SOCK_RAW + IPPROTO_UDP on lo0). +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** — `SOCK_RAW` with `IPPROTO_UDP` can send from `::1` to global unicast on lo0, unlike normal `SOCK_DGRAM` sockets. The kernel doesn't apply the same routing checks for raw sockets. +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.