From 7de6298fa41c2645f6f2ff532ec20678d6554b2b Mon Sep 17 00:00:00 2001 From: Cuong Manh Le Date: Thu, 30 Jul 2026 15:28:41 +0700 Subject: [PATCH] cmd/cli: scope ctrld's WFP objects to a dynamic session on Windows ctrld opened the WFP engine with a plain FWPM_SESSION0, so every filter and sublayer it installed was persistent for the engine's boot lifetime: the kernel kept enforcing them after the installing process was gone. Any exit that did not run the shutdown path - kill, crash, or a service stop during upgrade - left them behind. In hard intercept mode that orphaned the DNS block filters. With Firewall Mode enabled it orphaned machine-wide block-all filters that carry no process or SID condition, so the entire host lost outbound traffic: browsers, other users, and a replacement ctrld's own API bootstrap alike, with no way back short of a reboot. Set FWPM_SESSION_FLAG_DYNAMIC on both engine sessions (hard intercept and loopback protect). Windows then deletes everything the session owns when the handle closes, including on abnormal termination, so ctrld's enforcement can no longer outlive the process that installed it. This removes the cause. The next commit adds startup self-heal for hosts already carrying orphaned filters from a build that predates this change. --- cmd/cli/dns_intercept_windows.go | 37 +++++++++++++++++++++++++++----- cmd/cli/service_windows.go | 26 ++++++++++++++++------ docs/firewall-mode.md | 37 ++++++++++++++++++++++++++++++-- 3 files changed, 87 insertions(+), 13 deletions(-) diff --git a/cmd/cli/dns_intercept_windows.go b/cmd/cli/dns_intercept_windows.go index 6baaf32..6c9e40e 100644 --- a/cmd/cli/dns_intercept_windows.go +++ b/cmd/cli/dns_intercept_windows.go @@ -129,6 +129,21 @@ const ( // from overriding this filter's PERMIT action ("hard permit"). Used in DNS // mode to override third-party WFP blocks (e.g., OpenVPN's block-outside-dns). fwpmFilterFlagClearActionRight uint32 = 0x00000008 + + // fwpmSessionFlagDynamic is FWPM_SESSION_FLAG_DYNAMIC from fwpmtypes.h. + // + // Every WFP object added through a dynamic session is owned by that session and + // is deleted by the OS when the engine handle closes - including when the process + // exits, crashes, or is killed. ctrld relies on this so its filters can never + // outlive the process that installed them. + // + // This matters most in Firewall Mode: its block-all filters are machine-wide, so + // an orphaned set silently denies outbound traffic for every process on the host + // (browsers, other users, even a replacement ctrld's own API bootstrap) until a + // reboot. Session-scoped ownership makes the OS clean that up for us instead of + // depending on ctrld reaching its own shutdown or startup cleanup path. + // See: https://learn.microsoft.com/en-us/windows/win32/api/fwpmtypes/ns-fwpmtypes-fwpm_session0 + fwpmSessionFlagDynamic uint32 = 0x00000001 ) // WFP API structures. These mirror the C structures from fwpmtypes.h and fwptypes.h. @@ -1114,6 +1129,10 @@ func (p *prog) startWFPFilters(state *wfpState) error { session := fwpmSession0{} sessionName, _ := windows.UTF16PtrFromString("ctrld DNS Intercept") session.displayData.name = sessionName + // Session-scoped ownership: if this process dies without running its shutdown + // path, Windows removes our filters (including Firewall Mode's machine-wide + // block-all) instead of leaving the host enforced by a dead ctrld. + session.flags = fwpmSessionFlagDynamic // RPC_C_AUTHN_DEFAULT (0xFFFFFFFF) lets the system pick the appropriate // authentication service. RPC_C_AUTHN_NONE (0) returns ERROR_NOT_SUPPORTED @@ -1129,11 +1148,11 @@ func (p *prog) startWFPFilters(state *wfpState) error { if r1 != 0 { return fmt.Errorf("FwpmEngineOpen0 failed: HRESULT 0x%x", r1) } - mainLog.Load().Info().Msgf("DNS intercept: WFP engine opened (handle: 0x%x)", engineHandle) + mainLog.Load().Info().Msgf("DNS intercept: WFP engine opened (handle: 0x%x, session-scoped)", engineHandle) - // Clean up any stale sublayer from a previous unclean shutdown. - // If ctrld crashed or was killed, the non-dynamic WFP session may have left - // orphaned filters. Deleting the sublayer removes all its child filters. + // Clean up any sublayer left by an older ctrld that used a non-dynamic session + // (or by a build predating session-scoped ownership). Deleting the sublayer + // removes all its child filters. r1, _, _ = procFwpmSubLayerDeleteByKey0.Call( engineHandle, uintptr(unsafe.Pointer(&ctrldSubLayerGUID)), @@ -1141,7 +1160,12 @@ func (p *prog) startWFPFilters(state *wfpState) error { if r1 == 0 { mainLog.Load().Info().Msg("DNS intercept: cleaned up stale WFP sublayer from previous session") } - // r1 != 0 means sublayer didn't exist — that's fine, nothing to clean up. + // A non-zero r1 is not necessarily "nothing to clean up": it is also + // FWP_E_SUBLAYER_NOT_FOUND (the normal case), FWP_E_WRONG_SESSION for a sublayer a + // live ctrld owns, or FWP_E_DYNAMIC_SESSION_IN_PROGRESS for a non-dynamic one this + // dynamic session may not delete. None of them need handling here: the add below + // fails cleanly if the sublayer really is still present, and the non-dynamic case is + // what cleanupStaleDNSInterceptState handles at startup. sublayer := fwpmSublayer0{ subLayerKey: ctrldSubLayerGUID, @@ -1566,6 +1590,9 @@ func (p *prog) activateLoopbackWFPProtect(state *wfpState) error { session := fwpmSession0{} sessionName, _ := windows.UTF16PtrFromString("ctrld DNS Loopback Protect") session.displayData.name = sessionName + // Session-scoped, like the hard-intercept engine: no ctrld filter should + // outlive the process that installed it. + session.flags = fwpmSessionFlagDynamic const rpcCAuthnDefault = 0xFFFFFFFF r1, _, _ := procFwpmEngineOpen0.Call( diff --git a/cmd/cli/service_windows.go b/cmd/cli/service_windows.go index aa36bd8..0c6d521 100644 --- a/cmd/cli/service_windows.go +++ b/cmd/cli/service_windows.go @@ -66,15 +66,29 @@ func ConfigureWindowsServiceFailureActions(serviceName string) error { return err } - // Then proceed with existing actions, e.g. setting failure actions + // Recovery policy for a service that carries enforcement. + // + // ctrld's WFP session is dynamic, so Windows removes its filters when the process + // dies - a host with no ctrld is unfiltered rather than locked out. That makes the + // restart budget part of the enforcement story: three restarts five seconds apart + // with a two-minute reset window could be spent inside fifteen seconds, after which + // the service stays stopped and the host stays unfiltered until an operator acts. + // + // The delays back off instead, and the reset window is long enough that a burst + // cannot exhaust the budget faster than the backoff allows. A genuine crash loop + // still ends in a stopped service - that is the point of a bounded policy - but it + // takes minutes rather than seconds, and the third restart survives a transient + // failure that repeats. actions := []mgr.RecoveryAction{ - {Type: mgr.ServiceRestart, Delay: time.Second * 5}, // 5 seconds - {Type: mgr.ServiceRestart, Delay: time.Second * 5}, // 5 seconds - {Type: mgr.ServiceRestart, Delay: time.Second * 5}, // 5 seconds + {Type: mgr.ServiceRestart, Delay: time.Second * 5}, + {Type: mgr.ServiceRestart, Delay: time.Second * 30}, + {Type: mgr.ServiceRestart, Delay: time.Minute * 2}, } - // Set the recovery actions (3 restarts, reset period = 120). - err = s.SetRecoveryActions(actions, 120) + // Reset the failure count only after the service has stayed up longer than the whole + // backoff schedule, so repeated failures keep escalating instead of restarting the + // count from the first five-second delay. + err = s.SetRecoveryActions(actions, uint32((10 * time.Minute).Seconds())) if err != nil { return err } diff --git a/docs/firewall-mode.md b/docs/firewall-mode.md index 500653e..71ec9d6 100644 --- a/docs/firewall-mode.md +++ b/docs/firewall-mode.md @@ -1,7 +1,8 @@ # Firewall Mode -Firewall mode makes DNS policy unbypassable by blocking outbound connections to any -IP that wasn't resolved by ctrld. This closes the "DNS gap" - where apps use hardcoded +Firewall mode makes DNS policy unbypassable *while ctrld is running* by blocking outbound +connections to any IP that wasn't resolved by ctrld. On Windows, enforcement is tied to the +process lifetime - see [Enforcement lifetime](#enforcement-lifetime-what-happens-when-the-process-dies). This closes the "DNS gap" - where apps use hardcoded IPs, direct-IP fallbacks, or alternative DNS resolvers to bypass DNS-based filtering. ## How It Works @@ -63,6 +64,38 @@ sublayer with dynamic permit filters: Permit filters are added/removed dynamically as the allowlist changes. +#### Enforcement lifetime: what happens when the process dies + +ctrld opens its WFP session as a **dynamic** session, so Windows removes every filter it +added - including firewall mode's block-all - as soon as the process exits, however it +exits. That is a deliberate trade, recorded here because it changes what "unbypassable" +means on Windows: + +- **Before**: a hard kill (`taskkill /f`, a crash) left the filters installed with no ctrld + to manage them. The host was unusable rather than unfiltered, and only a reboot or a + manual WFP cleanup recovered it. A replacement ctrld could not even reach the API to + start, so it never got far enough to clean up - the deadlock this session change breaks. +- **Now**: the same kill leaves the host *unfiltered* until the service restarts. + +A clean stop or uninstall behaved this way already, and both need administrator rights, as +does killing a SYSTEM service - so the newly exposed case is specifically the hard kill of +an already-privileged process. What it costs is that an administrator can turn enforcement +off without uninstalling and without a trace beyond the service state. + +Compensating controls: + +1. **Restart policy backs off instead of burning out.** `ConfigureWindowsServiceFailureActions` + uses 5s / 30s / 2m restart delays with a 10-minute reset window, so three failures cannot + spend the whole budget inside 15 seconds and leave the host unfiltered. Repeated kills + still end in a stopped service - a bounded policy has to - but it takes minutes. +2. **Startup cleans up predecessors.** `cleanupStaleDNSInterceptState` removes filters left + by a build that predates session-scoped ownership, so an upgrade from such a build cannot + inherit the old lockout. + +Still open: enforcement stopping while policy should be active is visible only in the local +log. Reporting that state centrally is follow-up work, and is the control that would make +the hard-kill case detectable rather than merely bounded. + ### Linux and Unsupported Platforms Kernel enforcement is not implemented yet. On unsupported platforms, `firewall_mode = "on"` currently fails open: ctrld still records allowlist stats, but it does not block outbound traffic. A warning is logged at startup so this is visible. Future work: iptables/nftables rules or eBPF, and possibly a strict mode that fails closed when platform enforcement is unavailable.