~/posts/binary/wdnisdrv-flow-context-device-boundaries.md

WdNisDrv: flow contexts and device-access boundaries

Trace WFP filters, flow contexts, and asynchronous notifications in WdNisDrv. Separate documented injection behavior, service-SID access, and three buffer defects from claims about default reachability.

date[31:24]
read[23:16]
9 min
cat[15:8]
Binary
Contents
  1. 0x00A registered filter still needs a flow context
  2. 0x01Carry FlowHandle through the object lifetime
  3. 0x02Notifications are asynchronous events, not packet mirrors
  4. 0x03Policy, exclusions, and injection are separate inputs
  5. 0x04Device access precedes IOCTL parsing
  6. 0x05Three length errors require three evidence chains
  7. ·A wrapping element-count multiplication
  8. ·A valid outer header does not validate inner reads
  9. ·Clearing the destination does not initialize the source tail
  10. 0x06Offline checks and review priorities
  11. 0x07Official references

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

Read the current WFP rulespowershell
netsh wfp show filters

Search 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"]
Filtering layers in the sample5 rows
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

FLOW_CONTEXT — 4.18.2102.3-0x86-64
OffsetNameTypeSize
0x00RefCountint4
0x04padding4
0x08FlowHandleuint64_t8
0x10FilterFlagint4
0x14padding4
0x18RemoteSockaddrStorageSOCKADDR_STORAGE0x80
0x98pFilterStreampointer8
0xa0Statusint4
0xa4Initchar1
0xa5padding3
0xa8pWFP_Registrationpointer8
0xb0BytesReceiveduint64_t8
0xb8BytesSentuint64_t8
0xc0ReceivedPacketsCountuint64_t8
0xc8SentPacketsCountuint64_t8
sizeof(struct _FLOW_CONTEXT) = 0xd0 (208 bytes) · padding 0xb

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

TCP / HTTPpcap
No.SourceDestinationProtocolLengthInfo
2192.0.2.10:49695192.0.2.20:8080TCP66[SYN] Seq=0 Len=0
3192.0.2.20:8080192.0.2.10:49695TCP66[SYN, ACK] Seq=0 Ack=1 Len=0
4192.0.2.10:49695192.0.2.20:8080TCP54[ACK] Seq=1 Ack=1 Len=0
5192.0.2.10:49695192.0.2.20:8080HTTP174GET / HTTP/1.1
4 packets
FLOW_CLASSIFY / FLOW_DATAlog
[+] 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 : STREAM

This 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
$ 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.

Control codes identified in this version5 rows
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

DACL / ACElog
AceCount : 0x1
AceType  : ACCESS_ALLOWED_ACE_TYPE
Mask     : 0x001f01ff
SID      : S-1-5-80-3668810961-2468724468-4084584310-3029221373-430494444
Service  : NT SERVICE\WdNisSvc

The 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

IPAddress_ArrayCountasm
mov  IPAddress_ArrayCount, [SystemBuffer]
mov  ExpectedBufferLength, IPAddress_ArrayCount
shl  ExpectedBufferLength, 7
add  ExpectedBufferLength, 8
cmp  InputBufferLength, ExpectedBufferLength

The 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

INJECT_HEADER — pack(1)x86-64
OffsetNameTypeSize
0x00unk18
0x08FlowId8
0x10layer_id2
0x12callout_id4
0x16stream_flags1
0x17pad13
0x1astream_size4
size = 0x1e (30 bytes) · padding 3

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.

0x226011: selected instructionsasm
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.

WdNisDrv+0x71c9x86-64
  1. Step 1/1
    Code
    0xfffff8026b4971c944 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

FLOW_DELETE
00000000902A037A09F8FFFF
  1. pointer0x00–0x07
WdNisDrv+0x2a90
0xfffff8097a032a9040 53pushrbx
0xfffff8097a032a9248 83 ec 20subrsp, 20h
0xfffff8097a032a9649 8b 88 98 00 00 00movrcx, qword ptr [r8+98h]

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.

wdnis-boundary-model.py
$ 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=True

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

Official references

NORMAL~/posts/binary/wdnisdrv-flow-context-device-boundaries.md§--
0%en