Skip to content

netebpfext: purge stale WFP state at init and fail loudly when it cannot - #5501

Draft
D. Michael Agun (mikeagun) wants to merge 3 commits into
microsoft:mainfrom
mikeagun:fix/netebpfext-init-purge
Draft

netebpfext: purge stale WFP state at init and fail loudly when it cannot#5501
D. Michael Agun (mikeagun) wants to merge 3 commits into
microsoft:mainfrom
mikeagun:fix/netebpfext-init-purge

Conversation

@mikeagun

Copy link
Copy Markdown
Contributor

Follow-up to #5472, stacked on fix/netebpfext-wfp-delete-failed — that should merge first. Relates to #5486.

#5472 makes driver unload always complete when a WFP filter delete permanently fails. This handles the consequence: the filter that could not be deleted is still installed in WFP.

WFP management objects are not owned by the driver that created them, and this extension's engine sessions are non-dynamic, so closing the engine handle does not remove them. A surviving filter pins the callout, sub-layer and provider it references, their deletion at unload fails too, and the next FwpmProviderAdd fails with STATUS_FWP_ALREADY_EXISTS (0xC0220009). Confirmed on a test machine, with the service still reporting STATE: 4 RUNNING and zero WFP components registered.

The safety invariant

A stale filter's rawContext holds the address of a filter context that the previous driver instance freed. It is inert only because initialization fails at FwpmProviderAdd and never reaches FwpsCalloutRegister. Register a callout under a GUID a stale filter references, and WFP starts invoking classifyFn with a dangling pointer.

So: purge every stale filter, or refuse to start. A partial purge followed by a successful start is strictly worse than the current silent failure, which is why both changes below ship together.

Purge

_net_ebpf_ext_purge_stale_wfp_objects() runs after FwpmEngineOpen and before FwpmTransactionBegin, in its own transaction so that aborting the initialization transaction cannot roll it back. It deletes EBPF_WFP_PROVIDER-tagged objects in reverse dependency order: filters, callout objects, sub-layers, then the provider.

Running before any FwpsCalloutRegister is what makes it safe: with no callout function registered, WFP delivers no delete notification for the filters removed here, so the purge never touches a stale rawContext. Every *_NOT_FOUND result is success, so a clean boot is a handful of no-op calls.

If the sweep cannot be proven complete — any filter left undeleted, or an enumeration that did not drain — initialization fails.

Fail loudly

The result of net_ebpf_extension_initialize_wfp_components() is checked instead of discarded with (void). DriverEntry already calls _net_ebpf_ext_driver_uninitialize_objects() and returns the failing status, so the service start fails with a real error code.

Behaviour change: netebpfext now refuses to load when WFP initialization fails, instead of loading in a silently inert state. Its program-info providers are registered after this point, so on that path the network program types no longer appear in netsh ebpf show programs. That is the intended signal, but it is worth a careful look.

On #521: the TODO is kept and expanded rather than removed. That issue asks the driver to register for BFE service status before adding WFP objects, which is still unimplemented, and failing the start makes the gap more visible: a start attempted while BFE is unavailable now fails outright instead of quietly doing nothing. Reviewers may want to weigh whether #521 should land first.

Tracing teardown also moves out of _net_ebpf_ext_driver_uninitialize_objects() to its two call sites, so it happens after the final log statement rather than before it. The new failure path is the first to routinely reach that code, and it was discarding the exit trace for a failed start.

Testing

No new tests in this PR. The existing netebpfext_unit WFP lifecycle tests ([wfp_cleanup]) provide coverage: they strand undeletable filters across unload, so every subsequent WFP initialization exercises the purge, and the suite fails without it.

Dedicated purge tests — positive, negative (the safety invariant), and enumeration-not-drained — and real-hardware A/B validation are still to come. Draft until both land.

The highest-risk unverified assumption is that the WFP mock does not model enumType, so the driver's FWP_FILTER_ENUM_FULLY_CONTAINED plus GUID_NULL layerKey template is untested against real WFP. If that combination returns nothing on real hardware, the purge would be inert while CI stays green. That is the first thing hardware validation must check.

Dependency

Requires the usersim submodule bump. Depends on microsoft/usersim#325.

The submodule URL is temporarily pointed at a fork so that CI can build this pull request before that change merges. Both the URL and the commit must be moved back to microsoft/usersim, at the merged commit, before this merges.

Michael Agun and others added 3 commits August 14, 2026 12:53
PR microsoft#5472 guarantees that driver unload always completes, even when a WFP filter
delete permanently fails. That exposes the next problem: the filter that could
not be deleted is still installed in WFP.

WFP management objects are not owned by the driver that created them, and this
extension's engine sessions are non-dynamic, so closing the engine handle does
not remove them. A surviving filter therefore pins the callout, sub-layer and
provider objects it references, their deletion at unload fails too, and the
next FwpmProviderAdd fails with FWP_E_ALREADY_EXISTS (0xC0220009). Observed on
a test machine, with the service still reporting STATE: 4 RUNNING afterwards.

A stale filter is also actively dangerous. Its rawContext still holds the
address of a filter context that the previous driver instance allocated and
freed. Today that is inert only because initialization fails at
FwpmProviderAdd and so never reaches FwpsCalloutRegister. The moment a callout
is registered under a GUID a stale filter references, WFP begins invoking
classifyFn with a dangling pointer.

Three changes, which have to travel together:

* Add _net_ebpf_ext_purge_stale_wfp_objects(), called after FwpmEngineOpen and
  before FwpmTransactionBegin, in its own transaction so that aborting the
  initialization transaction cannot roll the purge back. It deletes objects
  tagged with EBPF_WFP_PROVIDER in reverse dependency order: filters, callout
  objects, sub-layers, then the provider. Running it before the first
  FwpsCalloutRegister is what makes it safe: with no callout function
  registered, WFP delivers no delete notification for the filters removed here,
  so the purge never touches a stale rawContext. Every *_NOT_FOUND result is
  success, so a clean boot is a handful of no-op calls.

  If any eBPF filter survives the purge, initialization fails. A partial purge
  followed by a successful start is strictly worse than not starting, because
  it is exactly the case that registers callouts alongside dangling contexts.

* Check the result of net_ebpf_extension_initialize_wfp_components() in
  _net_ebpf_ext_driver_initialize_objects() instead of discarding it with
  (void). DriverEntry already calls _net_ebpf_ext_driver_uninitialize_objects()
  and returns the failing status, so the service start now fails with a real
  error code rather than reporting RUNNING with no WFP components registered.
  This is a deliberate behaviour change: netebpfext now refuses to load when
  WFP initialization fails, instead of loading in a silently inert state.
  Closes the TODO for issue microsoft#521.

* Clear rundown_acquired once create_filter_context() succeeds, in
  _net_ebpf_extension_hook_provider_attach_client(). The provider rundown
  reference is acquired by the attach frame, but ownership transfers to the
  filter context the instant it exists; from then on the context releases it,
  either when its refcount reaches zero or via the unload sweep for a zombie.
  Without this, a failure between context creation and the end of the function
  would release the same reference twice. That path is currently unreachable,
  but it becomes a live rundown underflow as soon as one is added, and the
  purge work above adds failure paths to this file's neighbourhood.
The netebpfext stale-WFP-object purge discovers objects by enumerating them,
which the user-mode WFP mock could not do, and it relies on the mock modelling
provider identity and object references to be testable at all.

TEMPORARY: this also points the external/usersim submodule URL at a fork so
that CI can build this pull request before the usersim change merges. Both the
URL and the commit must be moved back to microsoft/usersim, at the merged
commit, before this merges.

Depends on microsoft/usersim#325.
Adds the positive case, where an unload that leaves filters installed is
followed by a start that purges them and succeeds, and the negative case that
guards the safety invariant: a stale filter that cannot be deleted must make
initialization fail rather than register callouts it still references.

The helper gains a wfp_is_initialized() accessor because its constructor cannot
use REQUIRE.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Copilot-Session: 5196c1b8-883f-440e-8846-e5fd79db2f5d
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

Status: Todo

Development

Successfully merging this pull request may close these issues.

1 participant