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

WdNisDrv:流上下文与设备访问边界

沿 WFP 过滤器、流上下文和异步通知还原网络检查链路,核对服务 SID、注入语义及三类长度错误,区分修改访问条件后的调试观察与默认配置下的实际可达性。

date[31:24]
read[23:16]
9 分钟
cat[15:8]
二进制
目录
  1. 0x00过滤器注册之后,还需要流上下文
  2. 0x01用 FlowHandle 贯穿对象生命周期
  3. 0x02通知是异步事件,不是逐包镜像
  4. 0x03策略、排除项与注入是不同入口
  5. 0x04设备访问边界先于 IOCTL 解析
  6. 0x05三种长度错误,三条独立证据链
  7. ·数量乘法回绕
  8. ·外层头通过,内层仍然越界
  9. ·目标清零挡不住源对象尾部残留
  10. 0x06离线验证与复核重点
  11. 0x07官方参考

WdNisDrv 的网络检查链路连接着三个对象:WFP 中的流、驱动内的 FLOW_CONTEXT,以及 NisSrv.exe 承载的 WdNisSvc 服务收到的事件。只有沿同一个流标识把三者串起来,才有依据解释“哪些数据被检查”与“某次通知为什么没有出现”。

分析限定于文件版本 4.18.2102.3-0 的 x64 实现。该版本的设备访问受服务 SID 和独占打开约束;下述异常调试发生在这些条件被修改之后,因此要将内部缓冲区缺陷与默认配置下的可达性分开。

过滤器注册之后,还需要流上下文

读取当前 WFP 规则powershell
netsh wfp show filters

在导出的规则中检索 windefend。ALE 连接建立层负责为选中的连接建立上下文,STREAM 层随后处理 TCP 数据;数据报以及可选的传输层路径则承担不同层级的检查,不应把所有回调都叫作“抓包”。

flowchart TD
  A["TCP/IP 与 WFP 层"] --> B["ALE:选择连接"]
  B --> C["关联 FLOW_CONTEXT"]
  C --> D["STREAM / DATAGRAM 回调"]
  D --> E["通知队列 → WdNisSvc"]
样本的过滤层分工5 行
规则组 关键条件或行为
ALE_FLOW_ESTABLISHED_V4 / V6 出站,TCP 或 UDP,检查型动作
STREAM_V4 / V6 TCP 流数据,关联 context 后分类
DATAGRAM_DATA_V4 / V6 数据报路径,使用相应流状态
OUTBOUND_TRANSPORT_V4 / V6 可选传输层路径
INBOUND_TRANSPORT_V4 / V6 可选传输层路径

STREAM 规则中的 FWP_CALLOUT_FLAG_CONDITIONAL_ON_FLOW 是关键:引擎只向已关联上下文的流调用该 classify 回调。FWPM_FILTER_FLAG_PERMIT_IF_CALLOUT_UNREGISTERED 则规定 callout 未注册时按放行处理。两者分别控制回调条件和缺席行为;没有检查不等于被阻断。FWPS_CALLOUT2、FWPM_FILTER0

用 FlowHandle 贯穿对象生命周期

FLOW_CONTEXT — 4.18.2102.3-0x86-64
偏移名称类型大小
0x00RefCountint4
0x04填充4
0x08FlowHandleuint64_t8
0x10FilterFlagint4
0x14填充4
0x18RemoteSockaddrStorageSOCKADDR_STORAGE0x80
0x98pFilterStreampointer8
0xa0Statusint4
0xa4Initchar1
0xa5填充3
0xa8pWFP_Registrationpointer8
0xb0BytesReceiveduint64_t8
0xb8BytesSentuint64_t8
0xc0ReceivedPacketsCountuint64_t8
0xc8SentPacketsCountuint64_t8
sizeof(struct _FLOW_CONTEXT) = 0xd0(208 字节) · 填充 0xb

这是逆向恢复的版本相关布局,空洞表示字段间填充,并非公开 ABI。RefCount 约束生命周期,FilterFlag 和 Status 参与处理决策,FlowHandle 则用于把建立、数据和删除事件归到同一条流。

FwpsCalloutRegister2 注册 classify、notify 和 flow-delete 函数指针,但每组 callout 使用哪些回调,应按该组注册参数确认。这里的 ALE classify 提取端点和进程信息并建立上下文;数据层读取上下文;删除路径递减引用并释放关联资源。不要把 WFP 的 notifyFn 与驱动发往服务的“通知消息”混为同一接口。

通知是异步事件,不是逐包镜像

TCP / HTTPpcap
序号源地址目的地址协议长度信息
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 个数据包
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

上述同次观测保留了包号、端口、长度及 FlowHandle = 46,地址已替换为文档地址。网络上的 HTTP 请求与服务收到的 STREAM 通知可以相互对应,但单个 TCP 段、一次 WFP 数据指示和一次用户态读取没有固定的一一关系。

服务用 0x22A00E 等待通知。无事件时,IRP 留在 cancel-safe 队列中;有事件时,驱动组织输出并完成请求。这类队列解决挂起请求的取消与同步问题,业务事件的格式仍由驱动决定。Microsoft IRP 队列文档

公共头由两个八字节字段组成:CreationTime 和 NotificationType。连接建立分支携带流、层、callout、协议、端点及进程信息;数据分支携带同一流标识、方向与长度;删除分支携带 FlowHandle。先解公共头,再按类型和实际返回长度解释有效分支,避免把 union 尾部当成协议载荷。

策略、排除项与注入是不同入口

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}

配置输出中的权限提示不是“排除列表为空”。该版本把 IP 排除项存入 AVL 树,在连接建立时查询目标地址;进程排除状态通过 WdFilter 的进程通知链更新,再查进程条目中的 IsPathExcluded、ProcessFlag 等字段。命中条件应从分支恢复,而不是从单个变量名推断。

逆向识别的控制码5 行
IOCTL 该版本用途
0x226005 过滤状态
0x226009 进程排除路径
0x226011 流或数据报注入
0x226015 IP 排除项
0x22A00E 异步连接通知

EnableNetworkProtection 的 AuditMode 与 Enabled 分别对应审计和阻断语义。检查 Microsoft-Windows-Windows Defender/Operational 时,1125 表示审计模式事件,1126 表示阻断模式事件;应同时记录策略和版本,事件数量并不等于驱动处理的流数量。Microsoft Network protection

注入观察中,一组记录使用 FlowHandle = 62、CalloutId = 290,线上出现了数据但同一通知路径没有再次报告它;另一组用 FlowHandle = 454、CalloutId = 290 关联到端口 49733 → 8080 的 20 字节 TCP 载荷。两组是独立观察,不能混用流 ID。

FwpsStreamInjectAsync0 明确规定:注入流数据不会再次指示给该 callout,但仍可供更低权重子层的流 callout 检查。因此这支持“同一回调不重复接收”的解释,而不是“所有检测均失效”。函数成功返回也仅代表异步注入已启动,效果还要看完成回调和接收端。Microsoft 注入接口文档

设备访问边界先于 IOCTL 解析

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

安全描述符包含一个允许 ACE,目标是 NT SERVICE\WdNisSvc。再加上设备的独占属性,普通测试进程并不因为拥有管理员身份就自然获得与服务相同的打开条件。WdmlibIoCreateDeviceSecure 的安全描述符与 Exclusive 参数是两个需要分别核实的输入。Microsoft 设备创建接口

异常调试先修改了这些限制,之后才取得测试句柄。该前提必须随结果保留,否则会把调试可达路径误写成默认攻击面。此外,该实现的 IRP_MJ_CLEANUP 会注销 callout;关闭额外句柄后通知消失,也可能是全局清理副作用。

三种长度错误,三条独立证据链

数量乘法回绕

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

入口先要求输入至少八字节,然后按 count × 128 + 8 计算地址数组所需长度。以上是保留符号操作数的指令节选,不是可直接汇编的源文件。

在 64 位无符号运算中,count = 1 << 57 使乘积回绕为零,计算长度退化为八字节;后续按原始 count 遍历,长度校验与迭代上界由此脱节。更稳健的判断是在 N >= 8 之后验证 count <= (N - 8) / 128,避免先形成溢出乘积。

外层头通过,内层仍然越界

INJECT_HEADER — pack(1)x86-64
偏移名称类型大小
0x00unk18
0x08FlowId8
0x10layer_id2
0x12callout_id4
0x16stream_flags1
0x17pad13
0x1astream_size4
大小 = 0x1e(30 字节) · 填充 3

入口验证至少存在 0x1E 字节,再加上末尾声明的数据长度。字段在不同反汇编命名中出现 stream_size / data_size,这里统一展示它所在的四字节槽位,并不据命名认定整个分支只处理 TCP 流。

0x226011: selected instructionsasm
lea  rdi, [rdx+1Eh]
add  rcx, 8
cmp  dword ptr [rdi], 0
jz   loc_FFFFF8076E1771D2
mov  r8d, [rdi+98h]

数据长度为零、输入恰好 0x1E 字节时,第一个 [rdi] 读取就落在输入之后。非零分支再读取 rdi + 0x98,相对于缓冲区是 0xB6,四字节访问结束于 0xBA。

WdNisDrv+0x71c9x86-64
  1. 步骤 1/1
    代码
    0xfffff8026b4971c944 8b 87 98 00 00 00movr8d, dword ptr [rdi+98h]
    寄存器
    rdi
    0xffffb083156e179e

对应调试状态的 InputBufferLength = 0x1E、IoControlCode = 0x226011、SystemBuffer = 0xffffb083156e1780,与 rdi = SystemBuffer + 0x1E 一致。它支持越界读取的结论,未建立稳定写入、提权链或默认访问条件下的触发路径。

目标清零挡不住源对象尾部残留

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]

删除通知的定义字段仅需公共头 16 字节加 FlowHandle 八字节。返回数据中却出现了上方八字节值,按小端解释为 0xfffff8097a032a90,调试器将它解析到 WdNisDrv+0x2a90 的指令入口。

错误在于临时栈对象仅初始化当前分支,却按更大的 union 容量复制并返回。先清零目标分配也无济于事:整块复制会再次覆盖目标尾部。修正应覆盖源对象初始化、复制长度、IRP 实际返回长度三处;缺少完整 ABI 与返回长度记录时,不给出固定泄漏字节数。

离线验证与复核重点

下面是实际运行的离线边界模型输出;它不加载驱动,不更改设备权限,也不产生网络注入。

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

模型核对服务 SID 的 SHA-1 / UTF-16LE 计算、24,582 组除法上界、紧凑头长及越界位置,并用 64 字节示例验证“清零目标后整块复制仍带入尾部”。该示例大小不代表真实通知大小。历史驱动的完整二进制摘要、对应 Windows 构建及修复版本尚未逐一匹配,因此这些结果不外推到当前 Defender。

审阅驱动时,按 设备打开条件 → IOCTL 分支 → 流上下文 → 长度转换 → 完成与返回 逐段保留证据。网络回调控制哪些流进入检查,设备 DACL 控制哪些调用者提交请求;两类边界共同决定缺陷的实际影响。

官方参考

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