The network inspection path in WdNisDrv connects three objects: a WFP flow, the driver's FLOW_CONTEXT, and events delivered to WdNisSvc in NisSrv.exe. Following the same flow identifier across all three is essential to explaining which data is inspected and why a notification may be absent.
This analysis is limited to the x64 implementation with file version 4.18.2102.3-0. Its device uses a service-SID access boundary and exclusive opening. The anomalous debugger observations below were made after those conditions were changed, so internal buffer defects must be separated from reachability under the default configuration.
A registered filter still needs a flow context
netsh wfp show filtersSearch the exported rules for windefend. The ALE flow-established layer creates context for selected connections; STREAM subsequently processes TCP data. Datagram and optional transport-layer paths operate at different levels, so treating every callback as a packet capture loses important distinctions.
flowchart TD A["TCP/IP and WFP layers"] --> B["ALE: select a connection"] B --> C["Associate FLOW_CONTEXT"] C --> D["STREAM / DATAGRAM callbacks"] D --> E["Notification queue → WdNisSvc"]
| Rule group | Relevant condition or behavior |
|---|---|
| ALE_FLOW_ESTABLISHED_V4 / V6 | Outbound, TCP or UDP, inspection action |
| STREAM_V4 / V6 | TCP stream data, classified after context association |
| DATAGRAM_DATA_V4 / V6 | Datagram path with the corresponding flow state |
| OUTBOUND_TRANSPORT_V4 / V6 | Optional transport-layer path |
| INBOUND_TRANSPORT_V4 / V6 | Optional transport-layer path |
The important STREAM flag is FWP_CALLOUT_FLAG_CONDITIONAL_ON_FLOW: the engine invokes this classify callback only for flows with associated context. FWPM_FILTER_FLAG_PERMIT_IF_CALLOUT_UNREGISTERED instead specifies permit behavior when the callout is absent. These flags govern different conditions; an absence of inspection is not a block. FWPS_CALLOUT2, FWPM_FILTER0
Carry FlowHandle through the object lifetime
| Offset | Name | Type | Size |
|---|---|---|---|
| 0x00 | RefCount | int | 4 |
| 0x04 | padding | 4 | |
| 0x08 | FlowHandle | uint64_t | 8 |
| 0x10 | FilterFlag | int | 4 |
| 0x14 | padding | 4 | |
| 0x18 | RemoteSockaddrStorage | SOCKADDR_STORAGE | 0x80 |
| 0x98 | pFilterStream | pointer | 8 |
| 0xa0 | Status | int | 4 |
| 0xa4 | Init | char | 1 |
| 0xa5 | padding | 3 | |
| 0xa8 | pWFP_Registration | pointer | 8 |
| 0xb0 | BytesReceived | uint64_t | 8 |
| 0xb8 | BytesSent | uint64_t | 8 |
| 0xc0 | ReceivedPacketsCount | uint64_t | 8 |
| 0xc8 | SentPacketsCount | uint64_t | 8 |
This is a recovered, version-specific layout rather than a public ABI; gaps represent padding between fields. RefCount governs lifetime, FilterFlag and Status participate in decisions, and FlowHandle connects establishment, data, and deletion events.
FwpsCalloutRegister2 accepts classify, notify, and flow-delete function pointers, but each registration must be checked to establish which callbacks it actually uses. Here, ALE classify extracts endpoint and process information and creates context; data-layer callbacks consume that context; deletion decrements references and releases associated resources. WFP's notifyFn is not the same interface as the driver's notification messages to the service.
Notifications are asynchronous events, not packet mirrors
| No. | Source | Destination | Protocol | Length | Info |
|---|---|---|---|---|---|
| 2 | 192.0.2.10:49695 | 192.0.2.20:8080 | TCP | 66 | [SYN] Seq=0 Len=0 |
| 3 | 192.0.2.20:8080 | 192.0.2.10:49695 | TCP | 66 | [SYN, ACK] Seq=0 Ack=1 Len=0 |
| 4 | 192.0.2.10:49695 | 192.0.2.20:8080 | TCP | 54 | [ACK] Seq=1 Ack=1 Len=0 |
| 5 | 192.0.2.10:49695 | 192.0.2.20:8080 | HTTP | 174 | GET / HTTP/1.1 |
[+] FLOW_CLASSIFY:
Flow handle : 46
CalloutId : 290
Layer : STREAM
Local : 192.0.2.10 (49695)
Remote : 192.0.2.20 (8080)
[+] FLOW_DATA:
Flow handle : 46
CalloutId : 290
Layer : STREAMThis observation retains packet numbers, ports, lengths, and FlowHandle = 46, with documentation addresses substituted. The HTTP request can be correlated with the STREAM notification, but a TCP segment, a WFP data indication, and a user-mode read do not have a fixed one-to-one relationship.
The service waits for notifications through 0x22A00E. With no event available, the IRP remains in a cancel-safe queue; when an event arrives, the driver fills the output and completes the request. The queue framework handles cancellation and synchronization, while the event format remains driver-specific. Microsoft IRP queue documentation
The common header contains two eight-byte fields, CreationTime and NotificationType. Establishment adds flow, layer, callout, protocol, endpoint, and process information; data adds the flow identifier, direction, and length; deletion adds FlowHandle. Decode the common header first, then interpret only the active branch within the actual returned length, rather than treating a union tail as protocol data.
Policy, exclusions, and injection are separate inputs
$ Get-MpPreference
EnableNetworkProtection : 0
ExclusionExtension : {N/A: Must be admin to view exclusions}
ExclusionIpAddress : {N/A: Must be admin to view exclusions}
ExclusionPath : {N/A: Must be admin to view exclusions}
ExclusionProcess : {N/A: Must be admin to view exclusions}The permission messages do not mean the exclusion lists are empty. This version stores IP exclusions in an AVL tree and looks up the destination during flow establishment. Process exclusions are updated through the WdFilter process-notification chain, then consulted through fields such as IsPathExcluded and ProcessFlag. Recover the branch conditions instead of inferring behavior from a variable name alone.
| IOCTL | Observed purpose |
|---|---|
0x226005 |
Filtering state |
0x226009 |
Process exclusion paths |
0x226011 |
Stream or datagram injection |
0x226015 |
IP exclusions |
0x22A00E |
Asynchronous connection notifications |
AuditMode and Enabled for EnableNetworkProtection have audit and blocking semantics respectively. In Microsoft-Windows-Windows Defender/Operational, 1125 denotes an audit-mode event and 1126 a block-mode event. Record policy and version alongside events: their count is not a count of all flows processed by the driver. Microsoft Network protection
One injection observation used FlowHandle = 62 and CalloutId = 290: data appeared on the network without another report on the same notification path. A separate observation associated FlowHandle = 454 and CalloutId = 290 with a 20-byte TCP payload on ports 49733 → 8080. These are distinct observations; their flow IDs must not be combined.
FwpsStreamInjectAsync0 documents that injected stream data is not indicated again to the same callout, although stream callouts in lower-weight sublayers can still receive it. That supports an explanation of callback non-repetition, not a claim that all inspection has failed. A successful return also means asynchronous injection has started, rather than proving delivery; check completion and the receiving endpoint. Microsoft injection API documentation
Device access precedes IOCTL parsing
AceCount : 0x1
AceType : ACCESS_ALLOWED_ACE_TYPE
Mask : 0x001f01ff
SID : S-1-5-80-3668810961-2468724468-4084584310-3029221373-430494444
Service : NT SERVICE\WdNisSvcThe descriptor contains one allow ACE for NT SERVICE\WdNisSvc. Together with exclusive device access, this means an ordinary test process does not acquire the service's opening conditions merely by running as an administrator. The security descriptor and Exclusive argument to WdmlibIoCreateDeviceSecure are separate inputs to verify. Microsoft device-creation API
The anomalous debugger runs changed those restrictions before obtaining a test handle. Omitting that prerequisite would turn a debugger-reachable path into a misleading default-attack-surface claim. This implementation also unregisters callouts during IRP_MJ_CLEANUP, so lost notifications after closing an additional handle may be a global cleanup side effect.
Three length errors require three evidence chains
A wrapping element-count multiplication
mov IPAddress_ArrayCount, [SystemBuffer]
mov ExpectedBufferLength, IPAddress_ArrayCount
shl ExpectedBufferLength, 7
add ExpectedBufferLength, 8
cmp InputBufferLength, ExpectedBufferLengthThe entry first requires eight input bytes, then computes the address-array length as count × 128 + 8. The listing preserves symbolic operands from selected instructions; it is not directly assemblable source.
In unsigned 64-bit arithmetic, count = 1 << 57 wraps the product to zero, leaving a computed length of eight. A later loop still uses the original count, separating its iteration bound from the validated length. A robust test first establishes N >= 8, then checks count <= (N - 8) / 128 without forming an overflowing product.
A valid outer header does not validate inner reads
| Offset | Name | Type | Size |
|---|---|---|---|
| 0x00 | unk1 | 8 | |
| 0x08 | FlowId | 8 | |
| 0x10 | layer_id | 2 | |
| 0x12 | callout_id | 4 | |
| 0x16 | stream_flags | 1 | |
| 0x17 | pad1 | 3 | |
| 0x1a | stream_size | 4 |
The entry requires 0x1E bytes and then includes the trailing declared data length in its check. The final four-byte field appears as stream_size or data_size in different disassembly annotations. Its location is what matters here; the name alone does not establish that the entire branch handles only TCP streams.
lea rdi, [rdx+1Eh]
add rcx, 8
cmp dword ptr [rdi], 0
jz loc_FFFFF8076E1771D2
mov r8d, [rdi+98h]With a zero declared data length and exactly 0x1E input bytes, the first [rdi] read already falls beyond the input. The nonzero branch then reads rdi + 0x98, which is buffer-relative 0xB6; that four-byte access ends at 0xBA.
- Step 1/1Code0xfffff8026b4971c944 8b 87 98 00 00 00movr8d, dword ptr [rdi+98h]Registers
- rdi
- 0xffffb083156e179e
The corresponding debugger state shows InputBufferLength = 0x1E, IoControlCode = 0x226011, and SystemBuffer = 0xffffb083156e1780, consistent with rdi = SystemBuffer + 0x1E. This establishes an out-of-bounds read, not a reliable write, privilege-escalation chain, or trigger under the default access conditions.
Clearing the destination does not initialize the source tail
pointer0x00–0x07
The defined deletion fields require only the 16-byte common header and an eight-byte FlowHandle. The returned data nevertheless contains the eight-byte value above, which decodes little-endian to 0xfffff8097a032a90. The debugger resolves it to instructions at WdNisDrv+0x2a90.
The problem is initializing only the active branch of a stack temporary, then copying and returning the larger union capacity. Clearing the destination allocation first does not help when a later whole-object copy overwrites its tail. The correction must address source initialization, copy length, and the IRP's actual returned length. Without the complete ABI and return-length record, no fixed disclosure size is asserted.
Offline checks and review priorities
The following is actual output from an offline boundary model. It does not load the driver, change device permissions, or inject network traffic.
$ python wdnis-boundary-model.py
service_sid=S-1-5-80-3668810961-2468724468-4084584310-3029221373-430494444
wrapped_size=8; safe_check=False
header=0x1e; deeper_read=[0xb6,0xba)
division_bound_cases=24582 PASS
zeroed_destination_overwritten_by_source_tail=TrueThe model checks the service SID's SHA-1 / UTF-16LE calculation, 24,582 division-bound cases, the packed header size, and the deeper read offset. A 64-byte example also verifies that copying an uninitialized source tail overwrites a cleared destination; that example size is not a claim about the real notification. The full historical binary hash, matching Windows build, and fixed version have not been matched, so the results are not extrapolated to current Defender releases.
Preserve evidence at each stage: device opening → IOCTL branch → flow context → length transformation → completion and return. Network callbacks determine which flows enter inspection, while the device DACL determines who can submit control requests. Both boundaries are needed to assess actual impact.