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.
This commit is contained in:
Cuong Manh Le
2026-08-14 15:28:16 +07:00
parent 52b7aaab87
commit 7de6298fa4
3 changed files with 87 additions and 13 deletions
+32 -5
View File
@@ -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(
+20 -6
View File
@@ -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
}
+35 -2
View File
@@ -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.