作者说明:本文记录了对 WFP Callout 内核机制的完整逆向研究过程,包括如何从公开 API 出发,通过反汇编推导未公开的内核数据结构,以及如何在不同 Windows 版本间适配。研究工具:IDA Pro、WinDbg 内核调试器。
参考:0mWindyBug/WFPCalloutReserach | V-i-x-x/kernel-callback-removal
目录
- WFP 基础概念
- Callout 注册机制(公开视角)
- 内核内部实现(逆向视角)
- 跨版本差异与通用方法论
- 实战:WinDbg 枚举所有 Callout
- 编程实现枚举(驱动层)
- 延伸:Callout 的安全含义
- 研究案例:NetworkKernelBypass 分析
- WinDbg 调试实验:观察 Entry 状态变化
- 防御检测:蓝队视角的完整性校验
一、WFP 基础概念
1.1 什么是 WFP
Windows Filtering Platform(WFP)是 Windows Vista 引入的内核网络过滤框架,替代了旧有的 NDIS 和 TDI 过滤机制。它同时提供用户态和内核态 API,允许开发者对网络流量进行拦截、检查、修改和记录。
Windows 防火墙、各大安全产品的网络模块(AV、EDR、AC)均基于 WFP 构建。
1.2 核心术语
Layer(层)
Layer 代表网络栈中的一个过滤点,由 GUID 标识。例如:
| Layer GUID 常量 |
含义 |
FWPM_LAYER_INBOUND_TRANSPORT_V4 |
IPv4 入站传输层(传输头解析后) |
FWPM_LAYER_OUTBOUND_TRANSPORT_V4 |
IPv4 出站传输层 |
FWPM_LAYER_ALE_AUTH_CONNECT_V4 |
应用层连接授权(connect 时触发) |
FWPM_LAYER_ALE_FLOW_ESTABLISHED_V4 |
流建立时触发 |
FWPM_LAYER_STREAM_V4 |
TCP 流数据层 |
Filter(过滤器)
Filter 由条件(源/目标 IP、端口、进程等)和动作(permit、block、callout)组成。当 filter 的条件匹配时,触发对应动作。动作为 callout 时,会调用注册的驱动回调。
Filter 的 callout 动作有三种类型:
| 类型 |
含义 |
FWP_ACTION_CALLOUT_TERMINATING |
callout 必须返回 permit 或 block,具有最终决定权 |
FWP_ACTION_CALLOUT_INSPECTION |
callout 只能返回 continue,仅做旁路检查 |
FWP_ACTION_CALLOUT_UNKNOWN |
callout 可以自行决定行为 |
Callout(回调)
Callout 是驱动注册的一组函数指针,WFP 在 filter 匹配时调用。一个 callout 包含三个回调:
1 2 3 4 5 6 7
| typedef struct FWPS_CALLOUT0_ { GUID calloutKey; UINT32 flags; FWPS_CALLOUT_CLASSIFY_FN0 classifyFn; FWPS_CALLOUT_NOTIFY_FN0 notifyFn; FWPS_CALLOUT_FLOW_DELETE_NOTIFY_FN0 flowDeleteFn; } FWPS_CALLOUT0;
|
Sublayer(子层)
用于在同一 Layer 内对 filter 分组,每个 sublayer 有独立的 weight(优先级)。同一 layer 内所有 sublayer 都会被遍历,block 优先于 permit。
Shim(垫片)
内核组件,负责在每个 layer 触发 classification 过程。由 tcpip.sys 在数据包到达各层时调用。
二、Callout 注册机制(公开视角)
2.1 注册流程
驱动注册一个 callout 需要以下步骤:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23
| FWPS_CALLOUT0 sCallout = {0}; sCallout.calloutKey = MY_CALLOUT_GUID; sCallout.classifyFn = MyClassifyFn; sCallout.notifyFn = MyNotifyFn; sCallout.flowDeleteFn = MyFlowDeleteFn;
UINT32 calloutId = 0; FwpsCalloutRegister0(deviceObject, &sCallout, &calloutId);
FWPM_CALLOUT0 mCallout = {0}; mCallout.calloutKey = MY_CALLOUT_GUID; mCallout.applicableLayer = FWPM_LAYER_ALE_AUTH_CONNECT_V4; FwpmCalloutAdd0(engineHandle, &mCallout, NULL, NULL);
FWPM_FILTER0 filter = {0}; filter.layerKey = FWPM_LAYER_ALE_AUTH_CONNECT_V4; filter.action.type = FWP_ACTION_CALLOUT_TERMINATING; filter.action.calloutKey = MY_CALLOUT_GUID; FwpmFilterAdd0(engineHandle, &filter, NULL, NULL);
|
注意 FwpsCalloutRegister 和 FwpmCalloutAdd 的顺序可以互换,但 FwpmFilterAdd 之前两者都需完成。
2.2 用户态枚举 API
WFP 提供了用户态枚举 API,但只能获取管理层信息(GUID、Layer、名称),无法获取实际函数指针地址:
1 2 3 4 5 6 7 8 9 10 11 12
| HANDLE engineHandle; FwpmEngineOpen0(NULL, RPC_C_AUTHN_WINNT, NULL, NULL, &engineHandle);
HANDLE enumHandle; FwpmCalloutCreateEnumHandle0(engineHandle, NULL, &enumHandle);
FWPM_CALLOUT0 **entries; UINT32 count; FwpmCalloutEnum0(engineHandle, enumHandle, 100, &entries, &count);
|
calloutId 是后续内核逆向的关键桥梁。
三、内核内部实现(逆向视角)
3.1 逆向方法论
当没有内核源码时,推导内部结构的标准路径:
1 2 3 4 5 6 7 8 9 10 11 12 13
| 公开 API 函数名 │ │ IDA / WinDbg uf 追调用链 ▼ 找到最终执行写内存操作的函数 │ │ 读反编译伪代码里的 *(ptr + offset) = value ▼ 每一个写操作 → 一个结构体字段 │ │ 找初始化函数确认数组大小 ▼ 完整结构体布局
|
3.2 追调用链
从 FwpsCalloutRegister0(位于 fwpkclnt.sys)出发:
1 2 3 4
| fwpkclnt!FwpsCalloutRegister0 └─> fwpkclnt!FwppCalloutRegister └─> netio!KfdRegisterCalloutEntry (跨模块,netio 导出) └─> netio!FeRegisterCalloutEntry ← 真正写入函数指针
|
不同版本中间层可能不同(有的版本是 KfdAddCalloutEntry → FeAddCalloutEntry),但最终都落到 netio.sys 里的 FeXxxCalloutEntry 系列函数。
在 IDA 里找跨模块调用:
在 fwpkclnt.sys 的 Imports 窗口搜索 Kfd,可以看到所有调用 netio 的接口,包括 KfdRegisterCalloutEntry、KfdAddCalloutEntry、KfdGetRefCallout 等。
3.3 核心数据结构:gWfpGlobal
netio!gWfpGlobal 是 WFP 的全局控制结构指针,所有 callout、filter 的管理都以它为根。
在 FeRegisterCalloutEntry(或各版本对应函数)的反编译代码里,可以直接读出两个关键偏移:
1 2
| CalloutEntryPtr = *(gWfpGlobal + OFFSET_ARRAY_PTR) + ENTRY_SIZE * calloutId;
|
通过反汇编 netio!GetCalloutEntry 可以更直接地确认这两个偏移:
1 2 3 4 5 6
| ; GetCalloutEntry(calloutId, &outPtr) mov r8, qword ptr [netio!gWfpGlobal] ; r8 = 全局结构基址 cmp ecx, dword ptr [r8 + OFFSET_COUNT] ; 越界检查 lea rcx, [rax + rax*4] ; index * 5 shl rcx, N ; * (1 << N) = entry size add rcx, qword ptr [r8 + OFFSET_ARRAY] ; + 数组基址
|
其中 OFFSET_COUNT 是数组容量偏移,OFFSET_ARRAY 是数组基址偏移。
3.4 CalloutEntry 结构体布局
通过逐行阅读 FeRegisterCalloutEntry/FeAddCalloutEntry 的写入操作,还原出结构体:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19
|
struct _WFP_CALLOUT_ENTRY { DWORD calloutType; DWORD isActive; BYTE unknown[8]; PVOID classifyFn; PVOID notifyFn; PVOID flowDeleteFn; PVOID classifyFnFast; DWORD flags; BYTE pad[12]; PVOID deviceObject; BYTE more[...]; WORD layerInfo; BYTE isTfoIncompat; };
|
从 FeRegisterCalloutEntry 里读出的关键赋值(这是真相,不依赖任何博客):
1 2 3 4 5 6 7 8 9 10 11 12 13 14
| *(_DWORD *)(v14 + 0) = v12; *(_DWORD *)(v14 + 4) = 1;
if ( v12 == 3 ) *(_QWORD *)(v14 + 40) = a2; else *(_QWORD *)(v14 + 16) = a2;
*(_QWORD *)(v14 + 24) = a3; *(_QWORD *)(v14 + 32) = a4; *(_DWORD *)(v14 + 48) = a5; *(_QWORD *)(v14 + 64) = Object; *(_WORD *) (v14 + 76) = a9; *(_BYTE *) (v14 + 78) = a6;
|
3.5 数组的组织方式
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19
| gWfpGlobal │ ├─ [+0x190] maxCalloutId / count(DWORD,初始值 1024 = 0x400) └─ [+0x198] calloutArray(QWORD 指针,指向 callout entry 数组)
calloutArray: [0x00] entry[0] 大小 ENTRY_SIZE [ENTRY_SIZE] entry[1] [ENTRY_SIZE*2] entry[2] ... [ENTRY_SIZE * calloutId] = entry[calloutId]
ENTRY_SIZE 计算(从 GetCalloutEntry 汇编读出): lea rcx, [rax + rax*4] → index * 5 shl rcx, 4 → * 16 结果:5 * 16 = 80 = 0x50 (某些版本)
或者直接在 FeRegisterCalloutEntry 里看: v14 = base + 96LL * a7 → 96 = 0x60 (另一些版本)
|
初始数组大小:0x14000 字节 ÷ ENTRY_SIZE = 最大初始 callout 数。当注册数量超出时,WFP 会重新分配更大的内存,复制数据,释放旧内存。
四、跨版本差异与通用方法论
4.1 已知版本差异
| Windows 版本 |
Entry 大小 |
写入函数名 |
备注 |
| Win10 早期版本 |
0x50 |
FeAddCalloutEntry |
博客 0mWindyBug 研究的版本 |
| Win10/11 新版本 |
0x60 |
FeRegisterCalloutEntry |
本文实验版本 |
| 其他版本 |
待测 |
待测 |
需自行逆向确认 |
gWfpGlobal + 0x190(count)和 gWfpGlobal + 0x198(array ptr)这两个偏移在已测版本中稳定,但不保证所有版本一致。
4.2 通用适配方法:永远从汇编读偏移
不要硬编码偏移,每次都从目标系统的汇编里读。 具体步骤:
Step 1:确认 gWfpGlobal 地址
1 2
| x netio!gWfpGlobal dp netio!gWfpGlobal L1 → 得到全局结构基址 BASE
|
Step 2:反汇编 GetCalloutEntry,读出 count 偏移和 array 偏移
1
| uf netio!GetCalloutEntry
|
找汇编里 [r8 + XXX] 的两处引用:
cmp ecx, [r8 + OFFSET_A] → OFFSET_A 是 count 的偏移
add rcx, [r8 + OFFSET_B] → OFFSET_B 是 array 指针的偏移
Step 3:找 entry 大小
1
| uf netio!GetCalloutEntry
|
看 lea + shl 组合,计算出乘数即为 entry 大小。或者在 FeRegisterCalloutEntry 里找 base + N * calloutId 的形式。
Step 4:反编译 FeRegisterCalloutEntry(或同类函数),读出所有 *(v14 + offset) = xxx 的赋值,得到 entry 内部布局。
Step 5:dump 第一个有效 entry 验证
找到第一个 [+0x04] != 0 的 entry,对照偏移布局验证 classifyFn 是否指向合理地址(用 ln 确认)。
4.3 在驱动代码中动态适配
如果要编写适配多版本的驱动,不应硬编码偏移。正确做法是使用 netio 导出的函数 FeGetWfpGlobalPtr 和 KfdGetRefCallout:
1 2 3 4 5 6 7 8 9
|
UNICODE_STRING funcName; RtlInitUnicodeString(&funcName, L"KfdGetRefCallout"); pKfdGetRefCallout = MmGetSystemRoutineAddress(&funcName);
|
用 KfdGetRefCallout 获取 entry 后,仍然需要知道 entry 内部的偏移才能读出 classifyFn。此时可以结合特征码扫描 FeRegisterCalloutEntry 来动态提取偏移,或使用版本号判断表。
五、实战:WinDbg 枚举所有 Callout
5.1 准备工作
确保符号正确加载:
1 2 3
| .symfix .reload /f netio.sys lm m netio
|
5.2 Step-by-Step 完整流程
第一步:找全局结构基址
记录输出值,假设为 BASE。
第二步:反汇编确认偏移(每次必做,不要假设)
1
| uf netio!GetCalloutEntry
|
第三步:读 count 和 array 基址
1 2
| dd BASE+0x190 L1 → count(DWORD) dq BASE+0x198 L1 → arrayBase(QWORD)
|
第四步:确认 entry 大小(看 GetCalloutEntry 汇编的乘法)
第五步:dump 前几个 entry 确认结构
找到第一个 +0x04 != 0 的 entry,用 ln 验证 +0x10 处的地址是否是函数。
第六步:遍历所有有效 callout
适配 entry 大小为 0x60 的版本:
1 2 3 4 5 6 7 8 9 10 11
| r $t1=0 .for (; @$t1 < COUNT; r $t1=@$t1+1) { r $t2=ARRAY_BASE+@$t1*0x60; .if (by(@$t2+4) != 0) { .printf "\n==== callout id=%d entry=%p ====\n", @$t1, @$t2; .printf " [+0x10] classifyFn : "; ln poi(@$t2+0x10); .printf " [+0x18] notifyFn : "; ln poi(@$t2+0x18); .printf " [+0x20] flowDeleteFn : "; ln poi(@$t2+0x20); .printf " [+0x40] DeviceObject : %p\n", poi(@$t2+0x40) } }
|
适配 entry 大小为 0x50 的版本(将 0x60 替换为 0x50,0x40 替换为 0x38)。
第七步:反查驱动身份
对感兴趣的 classifyFn 地址:
1 2 3
| ln 函数地址 → 显示模块名+偏移 lmvm 模块名 → 显示驱动文件路径、编译时间、大小 !drvobj poi(entry+0x40) 2 → 通过 DeviceObject 看驱动详情
|
5.3 实际输出示例
1 2 3 4 5 6 7 8 9 10 11
| ==== callout id=290 entry=ffffbb088ddf1aa0 ==== [+0x10] classifyFn : (fffff8018f522a70) ChangmenWfp+0x2a70 [+0x18] notifyFn : (fffff8018f523150) ChangmenWfp+0x3150 [+0x20] flowDeleteFn : (fffff8018f523140) ChangmenWfp+0x3140 [+0x40] DeviceObject : ffffbb0895fc62c0
0: kd> lmvm ChangmenWfp start: fffff801`8f520000 end: fffff801`8f529000 Image path: ChangmenWfp.sys Timestamp: Tue Apr 21 05:49:50 2026 ImageSize: 00009000
|
5.4 常见陷阱
陷阱1:.for 里不能用反引号地址,不能逗号同时初始化两个伪寄存器
1 2 3 4 5 6 7
| ; 错误 .for (r $t0=ffffbb08`94dde090, r $t1=0; ...)
; 正确:循环外赋值,地址用 0x 前缀 r $t0=0xffffbb0894dde090 r $t1=0 .for (; @$t1 < 20; r $t1=@$t1+1) { ... }
|
陷阱2:by() 只读 1 字节,poi() 读完整指针
1 2 3 4
| by(addr) → BYTE(1字节) wo(addr) → WORD(2字节) dwo(addr) → DWORD(4字节) poi(addr) → QWORD(8字节,64位下)
|
陷阱3:entry 大小版本差异导致地址错位
如果遍历结果大量 classifyFn 指向 null 或无效地址,第一件事是重新检查 entry 大小。
六、编程实现枚举(驱动层)
6.1 架构设计
最实用的架构是 0mWindyBug 提出的两层结构:
1 2 3 4 5 6 7 8 9 10 11
| 用户态进程 (WFPEnumUM) │ FwpmCalloutEnum0() → 得到所有 calloutId │ DeviceIoControl(IOCTL_GET_CALLOUT_INFO, calloutId) ▼ 内核驱动 (WFPEnumDriver) │ 接收 calloutId │ KfdGetRefCallout(calloutId, &entry) 或手动计算地址 │ 读取 entry 内偏移 │ 返回 classifyFn、notifyFn、DeviceObject 等 ▼ 用户态显示完整信息
|
用户态 API FwpmCalloutEnum0 能枚举所有已注册的 calloutId,但不提供函数指针。驱动层通过 calloutId 找到内核 entry,读出实际地址,通过 IOCTL 传回用户态。
6.2 内核驱动核心代码框架
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46
| typedef NTSTATUS(*pfnKfdGetRefCallout)(UINT32 calloutId, PVOID* ppEntry); typedef VOID(*pfnKfdDeRefCallout)(PVOID pEntry);
pfnKfdGetRefCallout g_KfdGetRefCallout = NULL; pfnKfdDeRefCallout g_KfdDeRefCallout = NULL;
NTSTATUS ResolveNetioExports() { UNICODE_STRING name1, name2; RtlInitUnicodeString(&name1, L"KfdGetRefCallout"); RtlInitUnicodeString(&name2, L"KfdDeRefCallout"); g_KfdGetRefCallout = MmGetSystemRoutineAddress(&name1); g_KfdDeRefCallout = MmGetSystemRoutineAddress(&name2); if (!g_KfdGetRefCallout || !g_KfdDeRefCallout) return STATUS_NOT_FOUND; return STATUS_SUCCESS; }
NTSTATUS GetCalloutInfo(UINT32 calloutId, CALLOUT_INFO* outInfo) { PVOID pEntry = NULL; NTSTATUS status = g_KfdGetRefCallout(calloutId, &pEntry); if (!NT_SUCCESS(status) || !pEntry) return STATUS_NOT_FOUND;
outInfo->classifyFn = *(PVOID*)((PUCHAR)pEntry + 0x10); outInfo->notifyFn = *(PVOID*)((PUCHAR)pEntry + 0x18); outInfo->flowDeleteFn = *(PVOID*)((PUCHAR)pEntry + 0x20); outInfo->deviceObject = *(PVOID*)((PUCHAR)pEntry + 0x40);
if (outInfo->deviceObject) { PDRIVER_OBJECT drvObj = ((PDEVICE_OBJECT)outInfo->deviceObject)->DriverObject; if (drvObj && drvObj->DriverName.Buffer) { wcsncpy(outInfo->driverName, drvObj->DriverName.Buffer, drvObj->DriverName.Length / sizeof(WCHAR)); } }
g_KfdDeRefCallout(pEntry); return STATUS_SUCCESS; }
|
重要:KfdGetRefCallout 会增加 entry 的引用计数,KfdDeRefCallout 必须配对调用,否则驱动无法正常卸载(WFP 通过引用计数防止驱动提前卸载)。
6.3 版本适配策略
推荐用特征码扫描动态确定偏移,而不是用版本号硬编码:
1 2 3 4 5 6 7 8 9 10 11
|
ULONG FindClassifyFnOffset() { return 0x10; }
|
七、延伸:Callout 的安全含义
7.1 为什么要枚举 Callout
从安全研究角度,能够枚举所有 callout 及其实际函数地址,有以下价值:
防御/检测侧:
- 检测未知驱动注册的 callout(非白名单驱动)
- 验证已知安全产品的 callout 地址是否被篡改(hook 检测)
- 分析 callout 注册的 layer,推断驱动的监控范围
研究侧:
- 理解安全产品的网络过滤逻辑
- 分析 callout 注册了哪些 layer,对应什么网络行为
- 结合 filter 条件(端口、IP、进程),了解完整过滤规则
7.2 Callout 的引用计数机制
WFP 通过引用计数保护正在执行的 callout:
1 2 3 4 5 6 7 8 9 10
| 驱动调用 FwpsCalloutRegister └─> WFP 调用 ObReferenceObject(deviceObject) └─> 驱动的引用计数 +1
每次 classifyFn 被调用时 └─> WFP 内部持有引用(防止 classifyFn 执行中驱动被卸载)
驱动调用 FwpsCalloutUnregisterById └─> WFP 等待所有 pending classify 完成 └─> ObDereferenceObject(deviceObject)
|
这意味着:只要有 callout 正在执行,驱动就无法卸载。IopCheckUnloadDriver 检查引用计数,非零则阻止卸载。
7.3 Callout 的 Flag 含义
FWPS_CALLOUT0.flags 中几个重要标志:
1 2 3 4 5 6 7 8 9 10
|
#define FWP_CALLOUT_FLAG_CONDITIONAL_ON_FLOW 0x00000001
#define FWP_CALLOUT_FLAG_ALLOW_OFFLOAD 0x00000002
|
FWP_CALLOUT_FLAG_CONDITIONAL_ON_FLOW 值得特别关注:如果一个 callout 设置了这个 flag,而对应的流没有通过 FwpsFlowAssociateContext 关联 context,WFP 会直接跳过这个 callout 的 classifyFn 调用。
八、研究案例:NetworkKernelBypass 分析
来源:V-i-x-x/kernel-callback-removal/NetworkKernelBypass
定位:这个项目是前述逆向研究(0mWindyBug)的工程落地实现,代表了”理解内核结构之后能做什么”这一研究阶段的典型案例。
8.1 项目在整个知识体系中的位置
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15
| 【理论层】0mWindyBug 博客 逆向 FeRegisterCalloutEntry 推导 gWfpGlobal + 0x198 = callout 数组基址 发现 entry + 0x10 = classifyFn 可被读写 提出三种 callout 静默手法(理论) │ │ 理论 → 工程实现 ▼ 【实现层】V-i-x-x / NetworkKernelBypass 实现了 classifyFn 替换手法 解决了博客未完整处理的 KCFG 问题 提供了可调试的 PoC 代码 │ ├──→ 【攻击研究】理解篡改路径 └──→ 【防御研究】理解检测点
|
8.2 核心思路:classifyFn 替换
整个项目围绕一个核心操作:将目标 callout entry 的 classifyFn 指针替换成一个”无害”函数,使原有的过滤逻辑失效,同时避免产生副作用。
替换前后的状态对比:
1 2 3 4 5 6 7 8 9 10 11
| 【替换前】 entry + 0x10 → TargetDriver!ClassifyCallback ↓ WFP 调用 ↓ 执行过滤逻辑(检查、拦截、记录) ↓ 返回 permit / block / continue
【替换后】 entry + 0x10 → netio!FeDefaultClassifyCallback ↓ WFP 调用 ↓ 几乎无条件返回 continue/permit ↓ 原有过滤逻辑完全被跳过
|
8.3 为什么选择 FeDefaultClassifyCallback
这是项目最关键的设计决策,原因有三层:
原因一:避免 KCFG(Kernel Control Flow Guard)拦截
Windows 10 1703+ 引入了 Kernel CFG,它在内核调用函数指针前会验证目标地址是否在合法跳转表中。如果把 classifyFn 替换成任意地址(比如自己写的 shellcode 或普通函数),KCFG 验证会失败,直接触发 BSOD。
FeDefaultClassifyCallback 已经存在于 netio.sys 内部,天然在 KCFG 合法表中,替换后调用路径完全合规:
1 2 3 4 5 6 7 8 9
| 【非法替换(KCFG 拦截)】 WFP 调用 entry+0x10 → 自定义函数地址 ↓ KCFG 检查:目标不在合法表 ↓ KeBugCheck → BSOD ❌
【合法替换(KCFG 放行)】 WFP 调用 entry+0x10 → FeDefaultClassifyCallback ↓ KCFG 检查:目标在 netio.sys 合法表中 ↓ 正常执行,返回 permit ✓
|
原因二:避免清零 entry 的副作用
最简单的手法是把整个 entry 清零(memset 0),但这会导致严重副作用:
1 2 3 4 5
| filter action = CALLOUT_TERMINATING 时: callout entry 为空 → WFP 把它当作"未注册" → 未注册的 TERMINATING callout 等价于 BLOCK → 所有匹配该 filter 的流量被直接拦截 → 系统网络行为异常,极易被察觉
|
替换为 FeDefaultClassifyCallback 则不同:entry 仍然”有效”(isActive = 1,函数指针非空),WFP 会正常调用它,只是它的行为变成了放行,不会触发”未注册”逻辑。
原因三:FeDefaultClassifyCallback 的行为已知且稳定
从 FeDeleteCalloutEntry 的逆向可以看到,WFP 在删除一个 callout 时,会先将其 classifyFn 替换为 FeDefaultClassifyCallback,然后等待引用计数归零再释放内存。这说明这个函数本来就是 WFP 用来”标记 callout 无效但不崩溃”的内部机制,行为非常稳定。
8.4 FeDefaultClassifyCallback 的定位方式
FeDefaultClassifyCallback 不是导出函数,需要间接定位。项目中的思路:
方式一:通过 gFeCallout 指针
1 2 3 4
| ; gFeCallout 是 netio 的全局变量,指向默认 callout entry x netio!gFeCallout dp netio!gFeCallout L1 → 得到默认 entry 地址 dq 默认entry地址+0x10 → 就是 FeDefaultClassifyCallback 地址
|
方式二:特征码扫描
在 netio.sys 的代码段中搜索 FeDefaultClassifyCallback 的特征字节序列(在已知版本中固定),适合在驱动代码里动态定位。
方式三:从 FeDeleteCalloutEntry 追调用
反汇编 netio!FeDeleteCalloutEntry,找到它写入 classifyFn 的那条赋值语句,右侧的常量地址就是 FeDefaultClassifyCallback。
8.5 完整替换流程(思路层面)
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17
| Step 1: 用户态 FwpmCalloutEnum0() 枚举所有 calloutId ↓ Step 2: 通过 calloutId 计算或查询 entry 地址 方式A: 手动计算 gWfpGlobal[+0x198] + calloutId * ENTRY_SIZE 方式B: 调用 KfdGetRefCallout(calloutId, &entry) ↓ Step 3: 读取 entry + 0x10(classifyFn),确认指向目标驱动 用 ln / MmGetSystemRoutineAddress 验证所属模块 ↓ Step 4: 定位 FeDefaultClassifyCallback 地址 通过 gFeCallout → 默认entry → +0x10 读取 ↓ Step 5: 写入替换 *(PVOID*)(entry + 0x10) = FeDefaultClassifyCallback ↓ Step 6: (可选) 同样替换 notifyFn 和 flowDeleteFn 使 callout 完全静默,不留任何执行痕迹
|
8.6 这个项目解决的工程问题
| 问题 |
解决方案 |
| KCFG 验证阻止替换自定义函数 |
替换为已在 KCFG 表中的 netio 内部函数 |
| 清零 entry 导致流量被 block |
替换为放行函数而非清零 |
| 引用计数导致驱动无法卸载 |
不修改引用计数字段,WFP 仍认为 callout 有效 |
| 定位 FeDefaultClassifyCallback |
通过 gFeCallout 全局变量间接读取 |
| 多版本 entry 大小差异 |
在驱动初始化时动态解析偏移 |
九、WinDbg 调试实验:观察 Entry 状态变化
本节描述如何在调试环境中,用 WinDbg 亲眼观察 callout entry 被修改前后的状态变化,加深对整个机制的理解。所有实验均在本地内核调试环境(VM + WinDbg)中进行。
9.1 实验环境搭建
推荐使用双机调试(Host + Guest VM):
1 2 3 4 5 6
| Host(运行 WinDbg) │ 串口 / 网络 KD 连接 ▼ Guest VM(运行被调试内核) │ 加载测试驱动(WFPCalloutDriver PoC) │ 加载 NetworkKernelBypass 驱动
|
Guest VM 配置(在 Guest 管理员 cmd 中执行):
1 2 3 4 5 6 7 8 9
| ; 开启内核调试 bcdedit /debug on bcdedit /dbgsettings net hostip:HOST_IP port:50000 key:1.2.3.4
; 或者用串口 bcdedit /dbgsettings serial debugport:1 baudrate:115200
; 关闭驱动签名强制(测试环境) bcdedit /set testsigning on
|
9.2 实验一:观察 Callout 注册过程
目标:在 FeRegisterCalloutEntry 写入 classifyFn 的位置下断点,捕获注册瞬间。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24
| ; 1. 在写入 classifyFn 的指令下断点 ; 先找到 FeRegisterCalloutEntry 的地址 x netio!FeRegisterCalloutEntry
; 反汇编,找 mov qword ptr [rbx+10h], rXX 的位置 uf netio!FeRegisterCalloutEntry
; 在写入指令处下断点(假设地址为 ADDR) bp ADDR
; 2. 在 Guest 里加载测试驱动,触发注册 ; WinDbg 命中断点后查看寄存器和内存
; 此时 rbx 指向 entry 基址,查看写入前的状态 dqs @rbx L0xc
; g 执行写入 p
; 查看写入后 dqs @rbx L0xc
; 确认 +0x10 已经是 classifyFn 地址 ln poi(@rbx+0x10)
|
预期输出:断点命中后,可以看到 entry 从全零变成有效状态,+0x10 处写入了测试驱动的 classifyFn 地址。
9.3 实验二:快照替换前后的 Entry 状态
目标:在替换发生前后各 dump 一次 entry,对比变化。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29
| ; 前提:已知目标 callout 的 entry 地址(通过枚举获得) ; 假设 entry 地址为 TARGET_ENTRY
; === 替换前快照 === .printf "=== BEFORE ===\n" dqs TARGET_ENTRY L0xc
; 特别记录 classifyFn .printf "classifyFn before: " ln poi(TARGET_ENTRY+0x10)
; 记录 DeviceObject(应指向目标驱动) .printf "DeviceObject: %p\n", poi(TARGET_ENTRY+0x40) !drvobj poi(TARGET_ENTRY+0x40) 1
; === 触发替换 === ; 在 Guest 里执行 NetworkKernelBypass,然后回到 WinDbg
; === 替换后快照 === .printf "=== AFTER ===\n" dqs TARGET_ENTRY L0xc
; 对比 classifyFn .printf "classifyFn after: " ln poi(TARGET_ENTRY+0x10)
; 验证:应该指向 netio!FeDefaultClassifyCallback 附近 ; DeviceObject 应该没有变化(entry 仍然"有效") .printf "DeviceObject: %p\n", poi(TARGET_ENTRY+0x40)
|
预期看到的变化:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15
| === BEFORE === TARGET_ENTRY+0x00 00000001`00000004 (type=4, active=1) TARGET_ENTRY+0x08 00000000`00000000 TARGET_ENTRY+0x10 fffff801`8f522a70 ← ChangmenWfp!ClassifyFn TARGET_ENTRY+0x18 fffff801`8f523150 ← ChangmenWfp!NotifyFn TARGET_ENTRY+0x20 fffff801`8f523140 ← ChangmenWfp!FlowDeleteFn TARGET_ENTRY+0x40 ffffbb08`95fc62c0 ← DeviceObject
=== AFTER === TARGET_ENTRY+0x00 00000001`00000004 (不变) TARGET_ENTRY+0x08 00000000`00000000 (不变) TARGET_ENTRY+0x10 fffff801`850808xx ← netio!FeDefaultClassifyCallback ← 已替换! TARGET_ENTRY+0x18 fffff801`8f523150 (未替换,视实现而定) TARGET_ENTRY+0x20 fffff801`8f523140 (未替换) TARGET_ENTRY+0x40 ffffbb08`95fc62c0 (不变,DeviceObject 未动)
|
9.4 实验三:在 classifyFn 被调用处观察行为
目标:在替换前后分别对 classifyFn 下断,观察调用频率和参数。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20
| ; === 替换前:对原始 classifyFn 下断 === ; 获取原始地址 r $t0=poi(TARGET_ENTRY+0x10) bp @$t0 ".printf \"[BEFORE] classifyFn called!\n\"; g"
; 产生网络流量触发 classify ; 观察断点命中次数(应该频繁触发) bl ; 列出断点 bc 0 ; 清除断点
; === 替换后:对 FeDefaultClassifyCallback 下断 === ; 先找到 FeDefaultClassifyCallback 地址 dp netio!gFeCallout L1 ; 结果假设为 DEFAULT_ENTRY_ADDR r $t1=poi(DEFAULT_ENTRY_ADDR+0x10) bp @$t1 ".printf \"[AFTER] default classify called!\n\"; g"
; 同样产生网络流量 ; 观察:FeDefaultClassifyCallback 现在被调用了 ; 而原始 classifyFn 不再被调用
|
9.5 实验四:用条件断点观察 WFP 写锁
FeRegisterCalloutEntry 和任何修改 entry 的操作都需要持有 WFP 的写锁(FeAcquireWriteEngineLock)。可以观察写锁的争用情况:
1 2 3 4 5 6 7
| ; 在写锁获取处下断点 x netio!FeAcquireWriteEngineLock bp netio!FeAcquireWriteEngineLock ".printf \"WFP write lock acquired by: \"; k 3; g"
; 在写锁释放处下断点 x netio!WfpReleaseFastWriteLock bp netio!WfpReleaseFastWriteLock ".printf \"WFP write lock released\n\"; g"
|
这可以帮助理解:为什么替换操作需要在持有写锁的情况下进行,以及并发场景下的安全性。
9.6 调试时的注意事项
注意1:不要在生产系统上调试
所有实验必须在 VM 内核调试环境中进行。在真实系统上修改内核内存会导致不可预知的系统不稳定。
注意2:WinDbg 冻结内核时 WFP 写锁可能已持有
如果在 WFP 持有写锁时中断内核(Ctrl+Break),再尝试读写 callout entry 可能遇到不一致状态。建议在安静状态(无大量网络流量)时操作。
注意3:符号影响 ln 的输出质量
如果 netio 符号未加载,ln 只会显示最近的公开符号。确保符号路径正确:
1 2 3
| .sympath srv*C:\Symbols*https://msdl.microsoft.com/download/symbols .reload /f netio.sys x netio!gWfpGlobal ; 验证符号已加载
|
注意4:每次系统重启后地址会变(KASLR)
内核地址随机化(KASLR)意味着每次重启后 gWfpGlobal、数组基址等地址都会变。每次调试都要重新执行枚举步骤,不要缓存地址。
十、防御检测:蓝队视角的完整性校验
理解了攻击手法之后,防御侧的检测逻辑就自然地推导出来了。
10.1 核心检测原理
替换手法有一个无法消除的特征:
classifyFn 所在模块 与 DeviceObject 所属驱动 不一致
正常状态下,一个驱动注册的 callout,其 classifyFn 一定指向自身的代码段。替换后 classifyFn 指向 netio.sys,但 DeviceObject 仍然属于原驱动,这个矛盾是检测的基础。
1 2 3 4 5 6 7 8 9
| 正常: classifyFn → DriverA.sys 代码段 DeviceObject → DriverA 的设备对象 ✓ 模块一致
被替换后: classifyFn → netio.sys(FeDefaultClassifyCallback) DeviceObject → DriverA 的设备对象(未修改) ✗ 模块不一致 → 异常
|
10.2 检测驱动的核心逻辑
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54
| typedef struct _CALLOUT_INTEGRITY_RESULT { UINT32 calloutId; BOOLEAN isTampered; WCHAR registeredDriver[256]; WCHAR classifyFnModule[256]; PVOID classifyFn; PVOID deviceObject; } CALLOUT_INTEGRITY_RESULT;
NTSTATUS CheckCalloutIntegrity(UINT32 calloutId, CALLOUT_INTEGRITY_RESULT* result) { PVOID pEntry = NULL; NTSTATUS status = g_KfdGetRefCallout(calloutId, &pEntry); if (!NT_SUCCESS(status) || !pEntry) return STATUS_NOT_FOUND;
PVOID classifyFn = *(PVOID*)((PUCHAR)pEntry + 0x10); PVOID deviceObject = *(PVOID*)((PUCHAR)pEntry + 0x40);
result->classifyFn = classifyFn; result->deviceObject = deviceObject;
g_KfdDeRefCallout(pEntry);
if (!classifyFn || !deviceObject) return STATUS_SUCCESS;
PLDR_DATA_TABLE_ENTRY classifyModule = NULL; FindModuleByAddress(classifyFn, &classifyModule);
PDRIVER_OBJECT drvObj = ((PDEVICE_OBJECT)deviceObject)->DriverObject;
if (classifyModule && drvObj) { BOOLEAN match = RtlEqualUnicodeString( &classifyModule->BaseDllName, &drvObj->DriverName, TRUE);
result->isTampered = !match;
if (classifyFn == g_FeDefaultClassifyCallback) { result->isTampered = TRUE; } }
return STATUS_SUCCESS; }
|
10.3 检测的完整覆盖面
仅检测 classifyFn 还不够,完整的检测应覆盖所有三个回调:
1 2 3 4 5 6 7
| PVOID classifyFn = *(PVOID*)((PUCHAR)pEntry + 0x10); PVOID notifyFn = *(PVOID*)((PUCHAR)pEntry + 0x18); PVOID flowDeleteFn = *(PVOID*)((PUCHAR)pEntry + 0x20);
|
还有一个隐蔽的变体:只替换 classifyFn,保留 notifyFn 指向原驱动。这种情况下检测需要以 DeviceObject 所属驱动为基准,分别验证三个指针。
10.4 WinDbg 手动验证(快速检测)
在调试环境中,可以用以下命令快速验证所有 callout 的完整性:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15
| ; 遍历所有有效 entry,对比 classifyFn 模块和 DeviceObject 所属驱动 r $t1=0 .for (; @$t1 < 0x400; r $t1=@$t1+1) { r $t2=ARRAY_BASE+@$t1*ENTRY_SIZE; .if (by(@$t2+4) != 0) { r $t3=poi(@$t2+0x10); r $t4=poi(@$t2+0x40); .if (@$t3 != 0) { .printf "\n[%d] classifyFn模块: ", @$t1; ln @$t3; .printf "[%d] DeviceObject驱动: %p\n", @$t1, @$t4; !drvobj @$t4 1 } } }
|
输出后,肉眼查找 classifyFn 所属模块与 DeviceObject 所属驱动名不一致的条目。
10.5 更难检测的变体:filter 结构篡改
0mWindyBug 博客提到了另一种思路:不修改 classifyFn,而是修改 filter 的 action 类型,将 CALLOUT_TERMINATING 改为 CALLOUT_INSPECTION,让 WFP 忽略 callout 的 block 决定。
这种手法不触碰 callout entry,上述检测完全失效。检测它需要额外监控 filter 结构的完整性(gWfpGlobal + 0x180 的 filter hash 表),属于更深入的防御研究方向。
10.6 防御总结
| 攻击手法 |
特征 |
检测方法 |
| 清零整个 entry |
isActive=0,三个回调为 NULL |
检测 active entry 突然消失 |
| 替换 classifyFn 为默认函数 |
classifyFn 指向 netio.sys |
模块一致性校验 |
| 替换为其他合法内核函数 |
classifyFn 指向非注册驱动 |
同上 |
| 修改 filter action 类型 |
callout entry 不变,filter 变 |
监控 filter hash 表完整性 |
| 翻转 CONDITIONAL_ON_FLOW flag |
entry+0x30 flags 被修改 |
对比注册时的 flags 基线 |
附录 A:关键 WinDbg 命令速查
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59
| ; ── 基础信息 ────────────────────────────────────────── ; 找 netio 加载范围 lm m netio
; 找全局结构指针 dp netio!gWfpGlobal L1
; 找默认 callout entry(用于定位 FeDefaultClassifyCallback) dp netio!gFeCallout L1
; ── 确认结构偏移(每次必做,不要假设)──────────────── uf netio!GetCalloutEntry uf netio!FeRegisterCalloutEntry
; ── 读数组信息(替换 BASE 为实际值)───────────────── dd BASE+0x190 L1 ; count(数组最大容量) dq BASE+0x198 L1 ; callout 数组基址
; ── dump entry 验证结构 ────────────────────────────── dqs ARRAY_BASE L0x20
; ── 完整枚举(替换实际值)──────────────────────────── r $t1=0 .for (; @$t1 < COUNT; r $t1=@$t1+1) { r $t2=ARRAY_BASE+@$t1*ENTRY_SIZE; .if (by(@$t2+4) != 0) { .printf "\n==== callout id=%d entry=%p ====\n", @$t1, @$t2; .printf " classifyFn : "; ln poi(@$t2+0x10); .printf " notifyFn : "; ln poi(@$t2+0x18); .printf " flowDeleteFn : "; ln poi(@$t2+0x20); .printf " DeviceObject : %p\n", poi(@$t2+0x40) } }
; ── 反查驱动身份 ───────────────────────────────────── ln 函数地址 lmvm 模块名 !drvobj DeviceObject地址 2
; ── 完整性校验(对比 classifyFn 和 DeviceObject 所属模块) ; 对单个 entry(替换 ENTRY_ADDR) .printf "classifyFn: "; ln poi(ENTRY_ADDR+0x10) !drvobj poi(ENTRY_ADDR+0x40) 1
; ── 调试实验用断点 ──────────────────────────────────── ; 观察 callout 注册过程 bp netio!FeRegisterCalloutEntry
; 观察 WFP 写锁争用 bp netio!FeAcquireWriteEngineLock ".printf \"write lock acquired\n\"; k 3; g"
; 对特定 classifyFn 下断(观察调用频率) bp 函数地址 ".printf \"classifyFn hit\n\"; g"
; ── 符号管理 ───────────────────────────────────────── .sympath srv*C:\Symbols*https://msdl.microsoft.com/download/symbols .reload /f netio.sys .reload /f fwpkclnt.sys x netio!gWfpGlobal ; 验证符号加载成功
|
附录 B:调用链速查表
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34
| 【注册路径】 FwpsCalloutRegister0 (fwpkclnt.sys, 公开 API) └─ FwppCalloutRegister (fwpkclnt.sys, 内部) └─ KfdRegisterCalloutEntry (netio.sys, 导出) └─ FeRegisterCalloutEntry (netio.sys, 内部) ← 真正写入 entry └─ 写入 gWfpGlobal[+0x198][calloutId * ENTRY_SIZE]
【旧版注册路径(早期 Win10)】 FwpsCalloutRegister0 └─ FwppCalloutRegister └─ KfdAddCalloutEntry (netio.sys, 导出) └─ FeAddCalloutEntry (netio.sys, 内部)
【查询路径】 KfdGetRefCallout(calloutId, &pEntry) (netio.sys, 导出) └─ FeGetRefCallout (netio.sys, 内部) └─ GetCalloutEntry (netio.sys, 内部) └─ 返回 gWfpGlobal[+0x198] + calloutId * ENTRY_SIZE
【引用释放】 KfdDeRefCallout(pEntry) (netio.sys, 导出) ← 每次 KfdGetRefCallout 后必须配对调用
【获取全局指针】 FeGetWfpGlobalPtr() (netio.sys, 导出) └─ 返回 gWfpGlobal 的值(比直接读符号更稳定)
【分类过程(WFP 内部调用 classifyFn)】 tcpip.sys(数据包到达某 layer) └─ Shim 触发 classification └─ ProcessCallout / ProcessCallout2 (netio.sys) └─ 读取 entry + 0x10(classifyFn) └─ KCFG 验证目标地址合法性 └─ call classifyFn(inFixedValues, inMetaValues, ...)
|
附录 C:gWfpGlobal 已知偏移汇总
| 偏移 |
类型 |
含义 |
版本稳定性 |
+0x190 |
DWORD |
callout 数组最大容量(初始 1024) |
已测稳定 |
+0x198 |
QWORD* |
callout entry 数组基址 |
已测稳定 |
+0x180 |
— |
filter hash 表基址 |
build dependent,不稳定 |
附录 D:CalloutEntry 偏移汇总(多版本对照)
| 字段 |
版本A (0x50 entry) |
版本B (0x60 entry) |
通用读法 |
| calloutType |
+0x00 |
+0x00 |
*(+0)=a1 |
| isActive |
+0x04 |
+0x04 |
*(+4)=1 |
| classifyFn(普通) |
+0x10 |
+0x10 |
else *(+16)=a2 |
| notifyFn |
+0x18 |
+0x18 |
*(+24)=a3 |
| flowDeleteFn |
+0x20 |
+0x20 |
*(+32)=a4 |
| classifyFn(fast) |
+0x28 |
+0x28 |
if(type==3) *(+40)=a2 |
| flags |
+0x30 |
+0x30 |
*(+48)=a5 |
| deviceObject |
+0x38 |
+0x40 |
ObfRef后 *(+N)=Object |
| layerInfo |
+0x44 |
+0x4C |
*(+76)=a9 |
| isTfoIncompat |
+0x46 |
+0x4E |
*(+78)=a6 |
| Entry 总大小 |
0x50 |
0x60 |
从 GetCalloutEntry 汇编乘法读 |
关键结论:
classifyFn (+0x10)、notifyFn (+0x18)、flowDeleteFn (+0x20) 在已知版本中偏移一致
deviceObject 偏移有版本差异(0x38 vs 0x40),需从 ObfReferenceObject 调用后的写操作确认
- 永远不要硬编码,每次从目标系统的
FeRegisterCalloutEntry 反编译结果读出
附录 E:研究项目参考
本文基于实际内核调试(WinDbg 双机调试)和 IDA Pro 逆向结果撰写,所有偏移均通过实验验证。
在不同 Windows 版本上,请始终重新从 uf netio!GetCalloutEntry 和 FeRegisterCalloutEntry 反编译结果读取实际偏移,不要直接使用本文数值。