简介:这份资源聚焦Windows平台下的WFP流量转发与流量监控技术,面向从事网络管理、安全开发及驱动编程的工程师与学习者,帮助其掌握内核级数据包过滤、定向转发与实时流量监测的实现思路。压缩包共155个文件,以65个h头文件与52个cpp源文件为核心,辅以少量c文件、vcxproj工程配置及sln解决方案文件,整体约444KB,结构上偏向Visual Studio工程源码,便于直接编译研究与二次开发。内容涉及流量采集、数据包处理、连接统计与异常检测等模块,可帮助读者理解WFP过滤驱动的组织方式、监控数据的获取路径以及网络管理程序的工程搭建方法。目前已有510人学习关注,适合希望深入Windows网络监控与流量转发实践、需要可运行代码参考的开发者作为学习起点。
1. 从一份代码.rar说起:WFP流量转发与监控到底能干什么
如果你手头正好有一份名为“代码.rar_WFP流量转发_wfp_流量监控_流量监控系统_网络监控系统”的资源包,大概率你正在做这几件事之一:想抓取本机或网关的流量做分析、想把指定进程的流量重定向到另一个端口、或者想搭一套轻量的网络监控系统。这些需求听起来分散,底层其实都指向同一个东西——Windows Filtering Platform,也就是 WFP。它不是某个第三方抓包工具的私有驱动,而是微软从 Vista 开始就内置在网络栈里的过滤框架,杀毒软件、防火墙、家长控制、企业级流量审计工具,几乎都在用它。
这份资源包的价值在于,它把 WFP 里最容易被讲得云山雾罩的两块——流量转发和流量监控——拆成了可编译、可调试的代码。你不需要从 WFP 的 ALE 层、Transport 层、Stream 层这些概念开始啃文档,而是直接看到一套能跑起来的实现:怎么注册 callout 驱动、怎么在 classifyFn 里拿到五元组、怎么把匹配到的包改写成新目标地址再重新注入。适合谁?适合已经会写 Windows 内核驱动基础框架、能看懂 WFP 的 FWPS_LAYER 枚举、但卡在“知道概念却拼不出完整链路”的工程师。新手如果连 WDK 都没装过,建议先补驱动编译环境,否则这份代码会让你在第一个链接错误上耗掉一整天。
2. WFP 流量转发的实现骨架:从 callout 注册到包重注入
2.1 为什么转发要挂在 WFP 而不是 Winsock 层
很多人第一反应是用 Winsock 的 LSP 或者 hook connect/send 来做转发,但这条路在 64 位系统上越来越窄:LSP 被微软标记为废弃,hook 用户态 API 又绕不过内核直接发包的进程。WFP 的优势在于它工作在更底层,能同时覆盖 TCP、UDP、原始套接字,甚至能处理非本机进程发起的流量。转发场景里最关键的层是FWPS_LAYER_ALE_AUTH_CONNECT_V4和FWPS_LAYER_OUTBOUND_TRANSPORT_V4:前者在连接建立前给你一次改目标地址的机会,后者在包已经成型后让你做 NAT 式的重写。资源包里的转发逻辑主要挂在 ALE_AUTH_CONNECT 层,因为在这一层改地址,上层应用完全无感知,不需要处理序列号重算。
选型上还有一个现实原因:WFP 的 callout 驱动可以做成纯软件过滤,不依赖网卡驱动,换机器只要重新安装驱动即可。而基于 NDIS 的转发方案虽然性能更高,但和具体网卡绑定太深,调试成本翻倍。对于监控系统来说,WFP 还能顺便拿到进程 ID,这是 NDIS 层很难直接关联的信息。
2.2 注册 callout 与 classifyFn 的最小闭环
下面这段代码是资源包里转发模块的注册骨架,我做了注释补全。它做三件事:定义 callout 的 classifyFn、把 callout 注册到 WFP 引擎、把过滤器挂到指定层。
// 定义 classifyFn,WFP 每匹配到一个包就会回调这里 VOID NTAPI ClassifyFn( const FWPS_INCOMING_VALUES* inFixedValues, const FWPS_INCOMING_METADATA_VALUES* inMetaValues, void* layerData, const void* classifyContext, const FWPS_FILTER* filter, UINT64 flowContext, FWPS_CLASSIFY_OUT* classifyOut) { UINT32 localAddr, remoteAddr; UINT16 remotePort; // 从固定字段里取五元组,注意不同层字段索引不同 localAddr = inFixedValues->incomingValue[ FWPS_FIELD_ALE_AUTH_CONNECT_V4_IP_LOCAL_ADDRESS].value.uint32; remoteAddr = inFixedValues->incomingValue[ FWPS_FIELD_ALE_AUTH_CONNECT_V4_IP_REMOTE_ADDRESS].value.uint32; remotePort = inFixedValues->incomingValue[ FWPS_FIELD_ALE_AUTH_CONNECT_V4_IP_REMOTE_PORT].value.uint16; // 命中转发规则:把目标改成 127.0.0.1:8080 if (remotePort == 80 && ShouldRedirect(remoteAddr)) { inFixedValues->incomingValue[ FWPS_FIELD_ALE_AUTH_CONNECT_V4_IP_REMOTE_ADDRESS].value.uint32 = htonl(0x7F000001); inFixedValues->incomingValue[ FWPS_FIELD_ALE_AUTH_CONNECT_V4_IP_REMOTE_PORT].value.uint16 = htons(8080); classifyOut->actionType = FWP_ACTION_PERMIT; return; } classifyOut->actionType = FWP_ACTION_CONTINUE; }逻辑说明:ClassifyFn是 WFP 的回调入口,inFixedValues里装的是当前层可读的字段。转发动作本质上就是修改这些字段的值,然后返回FWP_ACTION_PERMIT让连接按新地址继续。参数上要特别注意FWPS_FIELD_ALE_AUTH_CONNECT_V4_IP_REMOTE_ADDRESS这个索引,它只在 ALE_AUTH_CONNECT 层有效,换到 OUTBOUND_TRANSPORT 层索引名会变,写错会直接蓝屏。ShouldRedirect是资源包里自定义的规则匹配函数,通常读一个内核态配置表,支持按进程名、端口段、目标 IP 段过滤。
注册部分用FwpmCalloutAdd0和FwpmFilterAdd0,顺序不能反:先加 callout 拿到 calloutId,再用这个 ID 去构造 filter。资源包里把这两步封装在RegisterRedirectCallout里,失败时返回 NTSTATUS,驱动入口用DriverEntry判断,失败直接返回不加载,避免半残状态。
2.3 包重注入与校验和修正
在 ALE_AUTH_CONNECT 层改地址不需要重算校验和,因为包还没生成。但如果你把转发逻辑放到 OUTBOUND_TRANSPORT 层,就必须处理 IP 头和 TCP/UDP 头的校验和。资源包里给了一个ReinjectPacket函数,核心是调用FwpsInjectNetworkSendAsync0,在注入前用FwpsConstructIpHeaderForTransportPacket0重建 IP 头。常见坑是注入的包没有设置FWPS_INJECTION_CONTEXT,导致 WFP 再次回调你的 classifyFn,形成死循环。解决办法是在 classifyFn 开头判断inMetaValues->isInjected或者用FwpsQueryPacketInjectionState0查状态,已注入的包直接放行。
3. 流量监控系统的数据采集:从五元组到进程关联
3.1 监控层选择与字段取舍
流量监控系统要回答三个问题:谁在发、发去哪、发了多少。WFP 里能同时拿到进程 ID 和五元组的层是FWPS_LAYER_ALE_FLOW_ESTABLISHED_V4,这个层在连接建立后触发一次,适合做流表记录。资源包的监控模块就挂在这里,用inMetaValues->processId拿进程 ID,再用FwpsGetFilePathNameFromProcessId反查进程路径。注意这个反查函数在部分 Windows 版本上需要SeDebugPrivilege,驱动里要提前启用。
字段取舍上,不要试图在 classifyFn 里做统计累加,因为 classifyFn 运行在 DISPATCH_LEVEL,能用的 API 很少。正确做法是把五元组和进程信息通过FwpsAllocateNetBufferAndNetBufferList0拷贝到一个队列,交给一个独立的工作线程做统计和上报。资源包里用KSPIN_LOCK保护一个环形缓冲区,工作线程每 500ms 排空一次,通过FwpsFlowAssociateContext0把流上下文和统计结构绑定。
3.2 用户态与内核态的数据通道
监控数据最终要落到用户态做展示或入库。资源包提供了两种通道:一种是FwpmEngineOpen0配合FwpmNetEventSubscribe0订阅 WFP 自带的 net event,优点是代码量少,缺点是字段有限、延迟高;另一种是自定义的IOCTL通道,驱动里创建一个设备对象,用户态用DeviceIoControl拉取环形缓冲区数据。第二种更灵活,也是资源包默认启用的方式。
// 用户态拉取监控数据的 IOCTL 调用示例 HANDLE hDevice = CreateFileW( L"\\\\.\\WfpMonitor", GENERIC_READ | GENERIC_WRITE, 0, NULL, OPEN_EXISTING, 0, NULL); MONITOR_RECORD records[256]; DWORD bytesReturned = 0; BOOL ok = DeviceIoControl( hDevice, IOCTL_GET_MONITOR_RECORDS, // 自定义控制码 NULL, 0, records, sizeof(records), &bytesReturned, NULL); // bytesReturned / sizeof(MONITOR_RECORD) 就是本次拿到的记录条数逻辑说明:IOCTL_GET_MONITOR_RECORDS在驱动里对应一个IRP_MJ_DEVICE_CONTROL处理例程,它从环形缓冲区拷贝数据到用户态缓冲区。参数上records数组大小决定单次最大拉取量,资源包默认 256 条,超过会分多次拉。注意CreateFileW的设备路径要和驱动里IoCreateDevice的名字一致,符号链接用IoCreateSymbolicLink创建,否则用户态打不开。
3.3 流表老化与性能边界
监控系统跑久了,流表会膨胀。资源包里用了一个简单的老化策略:每条流记录最后活跃时间,工作线程每轮扫描超过 60 秒没更新的流就删除。这个阈值可以改,但不要低于 10 秒,否则短连接会被反复创建删除,CPU 占用飙升。性能边界方面,单驱动实例在普通办公机上能稳定处理 5000 条并发流左右,再往上需要把统计逻辑拆到多个工作线程,或者改用FWPS_LAYER_STREAM_V4层做流式统计。资源包没有做多线程拆分,这是它的一个明确边界,别指望拿它直接扛网关级流量。
4. 避坑与排查:WFP 驱动最容易翻车的五个地方
4.1 加载驱动后直接蓝屏,错误码 0x000000D1
现象:sc create注册驱动后sc start立刻蓝屏,WinDbg 显示DRIVER_IRQL_NOT_LESS_OR_EQUAL。原因:classifyFn 在 DISPATCH_LEVEL 调用了分页内存函数,比如ExAllocatePoolWithTag没加NonPagedPool,或者访问了用户态指针。解决:所有在 classifyFn 里用到的内存必须是非分页的,字符串比较用RtlCompareUnicodeString而不是wcsstr,配置表用ExAllocatePool2(POOL_FLAG_NON_PAGED, ...)分配。
4.2 转发规则不生效,但驱动加载正常
现象:驱动状态 running,抓包看目标地址没变。原因:过滤器权重(weight)设得太低,被系统自带防火墙规则覆盖。WFP 的过滤器按 weight 排序,weight 高的先匹配。解决:在FwpmFilterAdd0时把filter->weight设成0xFFFF,并且FWP_ACTION_PERMIT之后不要再返回FWP_ACTION_CONTINUE,否则会继续走后面的规则。
4.3 监控数据里进程 ID 全是 0
现象:用户态收到的记录 processId 字段恒为 0。原因:挂的层不对。FWPS_LAYER_ALE_AUTH_CONNECT_V4层在连接发起时触发,此时进程上下文完整;但如果挂到FWPS_LAYER_INBOUND_IPPACKET_V4这种更底层的层,进程 ID 拿不到。解决:确认监控模块挂的是 ALE 层,并且inMetaValues->processId字段在FWPS_INCOMING_METADATA_VALUES里确实有效,用FWPS_IS_METADATA_FIELD_PRESENT先判断。
4.4 卸载驱动时卡死或蓝屏
现象:sc stop后系统无响应。原因:callout 没注销就卸载驱动,WFP 引擎还在引用你的 classifyFn。解决:在DriverUnload里先调用FwpmFilterDeleteById0删除所有过滤器,再FwpmCalloutDeleteById0删除 callout,最后FwpmEngineClose0关闭引擎句柄。顺序反了就会卡死。资源包里把这段封装在UnregisterAll里,但如果你自己改了过滤器 ID 的存储方式,记得同步改。
4.5 用户态 IOCTL 返回 ERROR_INVALID_PARAMETER
现象:DeviceIoControl一直失败,GetLastError 返回 87。原因:控制码定义时METHOD和ACCESS不匹配,或者输入输出缓冲区长度对不上。解决:用CTL_CODE(FILE_DEVICE_UNKNOWN, 0x800, METHOD_BUFFERED, FILE_ANY_ACCESS)定义控制码,驱动里用Irp->AssociatedIrp.SystemBuffer读写,不要用Irp->UserBuffer。缓冲区长度在IrpSp->Parameters.DeviceIoControl.OutputBufferLength里检查,小于预期直接返回STATUS_BUFFER_TOO_SMALL。
5. 进阶技巧:用 WFP 做进程级流量限速与验证
资源包本身没带限速功能,但基于它的监控数据,加限速是顺理成章的事。思路是在FWPS_LAYER_ALE_FLOW_ESTABLISHED_V4层给每条流分配一个令牌桶,classifyFn 里每次匹配到该流的包就扣令牌,令牌不足时返回FWP_ACTION_BLOCK。令牌桶的补充由工作线程定时做,速率参数从用户态通过 IOCTL 下发。
// 令牌桶结构,挂在流上下文里 typedef struct _FLOW_THROTTLE { UINT64 tokens; // 当前令牌数 UINT64 lastRefill; // 上次补充时间,单位 100ns UINT32 rateBytesPerSec; // 限速值 KSPIN_LOCK lock; } FLOW_THROTTLE; // classifyFn 里扣令牌 KIRQL oldIrql; KeAcquireSpinLock(&throttle->lock, &oldIrql); RefillTokens(throttle); // 按时间差补充 if (throttle->tokens >= packetSize) { throttle->tokens -= packetSize; classifyOut->actionType = FWP_ACTION_PERMIT; } else { classifyOut->actionType = FWP_ACTION_BLOCK; } KeReleaseSpinLock(&throttle->lock, oldIrql);参数说明:rateBytesPerSec是限速值,设成 0 表示不限速;RefillTokens里用KeQueryInterruptTime拿当前时间,和lastRefill做差算出应补充的令牌数,注意时间单位是 100 纳秒,换算成秒要除以 10^7。packetSize从inFixedValues里取FWPS_FIELD_ALE_FLOW_ESTABLISHED_V4_IP_LOCAL_ADDRESS同层的包长度字段,不同层字段名不同。
验证限速是否生效,别只看用户态统计,那个有延迟。我一般用两台机器对拉:一台跑资源包驱动并设 1MB/s 限速,另一台用iperf3 -c打流,看接收端带宽是否稳定在 1MB/s 附近。如果带宽忽高忽低,检查令牌桶的锁粒度,KSPIN_LOCK在 DISPATCH_LEVEL 下竞争激烈会拖慢包处理,可以改成 per-CPU 的令牌桶,每个 CPU 独立扣减,最后汇总。
还有一个容易忽略的点:限速只对新建流生效,已经建立的流如果没在ALE_FLOW_ESTABLISHED层挂上上下文,后续包不会走你的 classifyFn。所以测试前先netstat确认没有残留连接,或者干脆重启一次驱动。从那以后我每次改完限速参数,都强制走一遍「卸载驱动 → 确认流表清空 → 重新加载 → iperf 打流」的流程,不然总会被旧流的缓存数据骗过去。希望帮到你。
本文还有配套的精品资源,点击获取