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
+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.