简介:这份TDI驱动源代码面向Windows内核驱动开发者与网络监控方向的学习者,核心目标是在应用层实时动态获取每个进程打开的网络端口变化情况。为兼容Win7与WinXP,作者未借用tdifw等现成源码,自行搭建框架完成开发,并附带实现了进程流量监控、单连接流量统计以及简单的进程禁网功能,限速部分留作扩展空间。资源包共68个文件,约567KB,包含8个cpp与7个h源文件、2个c文件、2个sys驱动、2个mac编译配置、18个xml与18个obj中间产物,以及pdb、log、makefile、dsp、dsw等工程文件,另附应用层接口代码与说明文档,结构完整便于二次开发。目前已有530人学习下载。读者可借此理解TDI过滤驱动的整体架构、IOCTL通信与红黑树对象管理思路,并在此基础上扩展限速等能力,对研究360流量防火墙核心机制具有参考价值。
1. TDI驱动源代码:从一次蓝屏回溯到网络过滤的底层入口
某次在虚拟机上调试一个网络流量统计模块,系统毫无征兆地蓝屏,WinDbg 回溯栈顶赫然是tdi.sys与自研驱动的交替调用。那一刻我才意识到,TDI 驱动源代码不是老古董,它仍然潜伏在大量存量网络过滤、协议解析、安全审计类项目里。TDI 全称 Transport Driver Interface,是 Windows 网络协议栈中传输层与上层驱动之间的接口规范,核心价值在于让驱动开发者不必直接跟 NDIS 或 TCPIP 内核死磕,就能拿到连接、端点、发送接收的粒度控制。今天做流量整形、端口级访问控制、轻量级抓包过滤,很多团队仍会翻出 TDI 驱动源代码来改,原因很直接:它比 WFP 调用链短,比 NDIS 协议驱动好上手,比用户态 hook 稳定。这篇文章面向需要维护或二次开发 TDI 过滤驱动的工程师,从源码结构、编译环境、关键例程到排错,把能复现的路径讲清楚。
2. TDI驱动源代码的骨架:从DriverEntry到回调注册的完整链路
2.1 源码目录里到底有哪些东西
拿到一份典型的 TDI 驱动源代码,不要急着打开.c文件。先看目录结构,它通常按功能切分:sys/放驱动入口和分发例程,inc/放 TDI 头文件与自定义 IOCTL 定义,lib/放跨版本兼容的封装,test/放用户态加载器和测试用例。核心文件一般不超过五个,但每个都承担明确角色。tdi_driver.c是DriverEntry所在,负责创建设备对象、注册IRP_MJ_CREATE、IRP_MJ_CLOSE、IRP_MJ_DEVICE_CONTROL等分发入口。tdi_filter.c才是真正跟 TDI 打交道的地方,里面会调用TdiRegisterDeviceObject或直接向\Device\Tcp、\Device\Udp发送IRP_MJ_INTERNAL_DEVICE_CONTROL来建立过滤上下文。tdi_conn.c处理连接建立与断开,tdi_send.c处理发送路径的缓冲与转发。如果源码里出现TdiBuildInternalDeviceControlIrp这类宏,说明它走的是标准 TDI 客户端路径,而不是直接 hook 分发表。
提示:先确认源码针对的 TDI 版本。Windows XP 到 Windows 7 之间 TDI 接口基本稳定,Windows 8 之后微软主推 WFP,但 TDI 客户端仍可运行在兼容层上,只是部分
IOCTL_TDI_*行为有差异。
2.2 编译环境与依赖项怎么配
TDI 驱动源代码对编译环境挑剔,不是随便一个 Visual Studio 就能过。常见做法是用 Visual Studio 2019 加 Windows Driver Kit 10 的旧版组合,目标平台选Windows 7或Windows 10的x64,但要把TargetPlatformVersion降到10.0.17763.0附近,否则 TDI 相关头文件里的TDI_STATUS定义会跟新版 SDK 冲突。下面是一个可用的sources文件片段,适合用build命令行编译的场景:
# 驱动编译配置片段,放在源码根目录 TARGETNAME=tdifilter TARGETTYPE=DRIVER TARGETPATH=obj INCLUDES=inc;$(DDK_INC_PATH) SOURCES=sys/tdi_driver.c \ sys/tdi_filter.c \ sys/tdi_conn.c \ sys/tdi_send.c # 必须显式链接 tdi.lib 和 ndis.lib TARGETLIBS=$(DDK_LIB_PATH)\tdi.lib \ $(DDK_LIB_PATH)\ndis.lib \ $(DDK_LIB_PATH)\ntstrsafe.lib # 关闭对 TDI 弃用警告的严格检查 USER_C_FLAGS=/wd4996 /wd28125这段配置的逻辑是:TARGETTYPE=DRIVER告诉构建系统产出.sys,TARGETLIBS里tdi.lib提供TdiRegisterDeviceObject等导出函数,ndis.lib用于底层缓冲池分配。USER_C_FLAGS里的/wd4996屏蔽 TDI 弃用警告,/wd28125屏蔽静态分析对旧 API 的告警。参数上,TARGETPATH决定中间文件位置,INCLUDES必须把inc放在 DDK 头文件之前,否则自定义的tdi_ioctl.h会被系统头覆盖。如果编译报LNK2019: unresolved external symbol __imp__TdiRegisterDeviceObject,九成是tdi.lib没链上,或者目标平台选成了ARM64而库不匹配。
2.3 驱动入口与设备对象的创建顺序
DriverEntry里最容易翻车的是设备对象创建顺序。TDI 过滤驱动通常需要先创建一个控制设备供用户态通信,再注册 TDI 过滤回调。顺序反了会导致回调触发时控制设备还没就绪,用户态拿不到事件。下面是一个最小化的入口代码:
NTSTATUS DriverEntry(PDRIVER_OBJECT DriverObject, PUNICODE_STRING RegistryPath) { NTSTATUS status; UNICODE_STRING devName; PDEVICE_OBJECT ctrlDevice = NULL; // 1. 先创建控制设备,供用户态下发过滤规则 RtlInitUnicodeString(&devName, L"\\Device\\TdiFilterCtl"); status = IoCreateDevice(DriverObject, 0, &devName, FILE_DEVICE_UNKNOWN, 0, FALSE, &ctrlDevice); if (!NT_SUCCESS(status)) return status; // 2. 设置分发例程,IRP_MJ_CREATE/CLOSE 用于打开关闭句柄 DriverObject->MajorFunction[IRP_MJ_CREATE] = TdiFilterCreate; DriverObject->MajorFunction[IRP_MJ_CLOSE] = TdiFilterClose; DriverObject->MajorFunction[IRP_MJ_DEVICE_CONTROL] = TdiFilterIoctl; // 3. 注册 TDI 过滤,必须在控制设备就绪之后 status = TdiFilterRegister(DriverObject); if (!NT_SUCCESS(status)) { IoDeleteDevice(ctrlDevice); return status; } // 4. 创建符号链接,方便用户态用 Win32 路径打开 RtlInitUnicodeString(&devName, L"\\DosDevices\\TdiFilterCtl"); IoCreateSymbolicLink(&devName, &devName); // 实际应使用两个不同字符串 return STATUS_SUCCESS; }逻辑说明:第一步IoCreateDevice的FILE_DEVICE_UNKNOWN表示自定义设备类型,FALSE表示非独占。第二步绑定分发例程,IRP_MJ_DEVICE_CONTROL是用户态DeviceIoControl的入口。第三步TdiFilterRegister是自定义函数,内部会调用TdiRegisterDeviceObject或向\Device\Tcp发送绑定 IRP。第四步符号链接让用户态能用\\.\TdiFilterCtl打开。参数上,IoCreateDevice的DeviceExtensionSize如果传 0,后续想存上下文就得用全局变量,多线程下容易踩坑,建议至少留sizeof(FILTER_CONTEXT)。注意IoCreateSymbolicLink的两个参数不能是同一个字符串,上面示例为了简洁做了简化,实际要分别初始化\DosDevices\...和\Device\...。
3. 核心例程拆解:连接跟踪、发送拦截与缓冲管理
3.1 连接建立路径的过滤点在哪
TDI 过滤驱动最常拦截的是连接建立。客户端调用TdiBuildInternalDeviceControlIrp发送IOCTL_TDI_CONNECT或IOCTL_TDI_ASSOCIATE_ADDRESS时,过滤驱动有机会在 IRP 下发到 TCP 设备之前拿到TA_IP_ADDRESS结构,里面包含目标 IP 和端口。源码里通常有一个TdiFilterDispatchConnect函数,它先解析IRP_MJ_INTERNAL_DEVICE_CONTROL的Parameters.DeviceIoControl.IoControlCode,判断是不是连接类请求,然后从Irp->AssociatedIrp.SystemBuffer里取地址信息。下面是一个解析片段:
NTSTATUS TdiFilterDispatchConnect(PDEVICE_OBJECT DeviceObject, PIRP Irp) { PIO_STACK_LOCATION irpSp = IoGetCurrentIrpStackLocation(Irp); ULONG ioctl = irpSp->Parameters.DeviceIoControl.IoControlCode; PTA_IP_ADDRESS addr = NULL; UCHAR remotePort = 0; ULONG remoteIp = 0; // 只处理连接与地址关联两类 IOCTL if (ioctl == IOCTL_TDI_CONNECT || ioctl == IOCTL_TDI_ASSOCIATE_ADDRESS) { addr = (PTA_IP_ADDRESS)Irp->AssociatedIrp.SystemBuffer; if (addr && addr->AddressFamily == AF_INET) { remoteIp = addr->Address[0].in_addr; remotePort = addr->Address[0].in_port; // 在这里查规则表,决定放行还是拒绝 if (TdiFilterIsBlocked(remoteIp, remotePort)) { Irp->IoStatus.Status = STATUS_CONNECTION_REFUSED; IoCompleteRequest(Irp, IO_NO_INCREMENT); return STATUS_CONNECTION_REFUSED; } } } // 放行则继续下发到下层驱动 IoSkipCurrentIrpStackLocation(Irp); return IoCallDriver(g_TdiTcpDevice, Irp); }逻辑说明:IoGetCurrentIrpStackLocation拿到当前栈位置,IoControlCode区分请求类型。TA_IP_ADDRESS是 TDI 定义的地址结构,AddressFamily == AF_INET表示 IPv4,Address[0].in_addr是网络字节序的 IP,in_port是端口。TdiFilterIsBlocked是自定义规则查询,可以查哈希表或位图。如果拒绝,直接设置STATUS_CONNECTION_REFUSED并完成 IRP,不再下发。放行时用IoSkipCurrentIrpStackLocation跳过当前栈再IoCallDriver,这是过滤驱动的标准写法,漏掉IoSkip会导致栈位置错乱,蓝屏概率极高。参数上,g_TdiTcpDevice是绑定到\Device\Tcp的设备对象指针,通常在TdiFilterRegister里通过IoGetDeviceObjectPointer获取。
3.2 发送路径的缓冲拷贝与零拷贝取舍
发送拦截比连接拦截复杂,因为要处理IRP_MJ_WRITE和IOCTL_TDI_SEND两种路径。TDI 发送通常用TDI_SEND结构描述缓冲,里面SendBuffer指向 MDL 或系统缓冲。源码里常见两种做法:一种是拷贝到自定义缓冲再检查内容,另一种是直接锁住 MDL 做零拷贝扫描。拷贝方案简单但吞吐掉得厉害,零拷贝方案性能好但容易在MmGetSystemAddressForMdlSafe失败时翻车。下面是一个带拷贝的发送检查片段:
NTSTATUS TdiFilterSend(PDEVICE_OBJECT DeviceObject, PIRP Irp) { PIO_STACK_LOCATION irpSp = IoGetCurrentIrpStackLocation(Irp); PTDI_SEND sendReq = NULL; PUCHAR buffer = NULL; ULONG length = 0; if (irpSp->MajorFunction == IRP_MJ_WRITE) { // 写路径直接用 Irp->UserBuffer 或 MdlAddress if (Irp->MdlAddress) { buffer = MmGetSystemAddressForMdlSafe(Irp->MdlAddress, NormalPagePriority); length = MmGetMdlByteCount(Irp->MdlAddress); } else { buffer = (PUCHAR)Irp->UserBuffer; length = irpSp->Parameters.Write.Length; } } else if (irpSp->MajorFunction == IRP_MJ_INTERNAL_DEVICE_CONTROL) { sendReq = (PTDI_SEND)Irp->AssociatedIrp.SystemBuffer; if (sendReq && sendReq->SendBuffer) { buffer = (PUCHAR)sendReq->SendBuffer; length = sendReq->SendLength; } } if (buffer && length > 0) { // 这里做内容检查,比如关键字过滤或协议解析 if (TdiFilterScanPayload(buffer, length)) { Irp->IoStatus.Status = STATUS_ACCESS_DENIED; Irp->IoStatus.Information = 0; IoCompleteRequest(Irp, IO_NO_INCREMENT); return STATUS_ACCESS_DENIED; } } IoSkipCurrentIrpStackLocation(Irp); return IoCallDriver(g_TdiTcpDevice, Irp); }逻辑说明:IRP_MJ_WRITE路径优先用MdlAddress,因为用户态缓冲可能分页,直接读UserBuffer在IRQL > PASSIVE_LEVEL时会崩。MmGetSystemAddressForMdlSafe把 MDL 映射到系统地址,NormalPagePriority表示允许分页。IRP_MJ_INTERNAL_DEVICE_CONTROL路径从SystemBuffer取TDI_SEND,SendBuffer和SendLength是 TDI 定义字段。TdiFilterScanPayload是自定义扫描函数,可以按字节匹配或解析协议头。拒绝时设置STATUS_ACCESS_DENIED并完成 IRP。参数上,MmGetMdlByteCount返回 MDL 描述的实际字节数,不要用irpSp->Parameters.Write.Length替代,两者在部分场景下不一致。注意MmGetSystemAddressForMdlSafe返回 NULL 时要直接放行,不要强行解引用,否则就是蓝屏。
3.3 缓冲池与引用计数的管理边界
TDI 驱动源代码里最容易积累技术债的地方是缓冲池管理。每个连接上下文、每个待处理 IRP 队列、每个规则节点都要分配内存,分配标签不统一会导致池监控看不到泄漏。常见做法是统一用ExAllocatePoolWithTag,标签用四个字符如'TdFc',释放时用ExFreePoolWithTag并传相同标签。引用计数方面,连接上下文通常被发送路径和接收路径同时引用,必须用InterlockedIncrement和InterlockedDecrement保护,减到零再释放。下面是一个上下文释放的片段:
VOID TdiFilterDerefContext(PTDI_FILTER_CONTEXT ctx) { LONG refCount; if (ctx == NULL) return; refCount = InterlockedDecrement(&ctx->RefCount); if (refCount == 0) { // 先清理挂起的 IRP 队列 TdiFilterFlushPendingIrps(ctx); // 再释放地址关联 if (ctx->TdiAddressHandle) { TdiFilterCloseAddress(ctx->TdiAddressHandle); } // 最后释放内存,标签必须与分配时一致 ExFreePoolWithTag(ctx, TDI_FILTER_TAG); } }逻辑说明:InterlockedDecrement返回减一后的值,等于零说明没有其他路径持有引用。TdiFilterFlushPendingIrps负责取消队列里未完成的 IRP,避免释放后还有回调访问。TdiFilterCloseAddress关闭 TDI 地址句柄,释放底层资源。ExFreePoolWithTag的标签TDI_FILTER_TAG必须和ExAllocatePoolWithTag时一致,否则池监控工具会报标签不匹配。参数上,RefCount初始值设为 1,每次跨线程传递前InterlockedIncrement,用完InterlockedDecrement。注意不要在持有自旋锁时调用ExFreePoolWithTag,因为释放可能触发分页,自旋锁下 IRQL 太高会死锁。
4. 避坑与排查:TDI驱动源代码调试中最容易翻车的五件事
4.1 蓝屏 0x000000D1:IRQL 与分页内存的经典冲突
现象:驱动加载后一发起连接就蓝屏,错误码DRIVER_IRQL_NOT_LESS_OR_EQUAL,参数指向TdiFilterDispatchConnect里的地址解析行。原因:TDI 的连接 IRP 可能在DISPATCH_LEVEL下发,而代码里直接访问了Irp->AssociatedIrp.SystemBuffer指向的分页内存,或者调用了RtlCopyMemory从分页地址拷贝。解决:所有在DISPATCH_LEVEL以上执行的代码,访问的缓冲必须来自非分页池或已锁定的 MDL。把地址解析逻辑改成先MmProbeAndLockPages或直接用Irp->MdlAddress,如果拿不到 MDL 就放行,不要强行读。
4.2 绑定设备对象失败:IoGetDeviceObjectPointer 返回 STATUS_OBJECT_NAME_NOT_FOUND
现象:TdiFilterRegister里调用IoGetDeviceObjectPointer获取\Device\Tcp时返回STATUS_OBJECT_NAME_NOT_FOUND,驱动加载失败。原因:TDI 设备名在不同 Windows 版本里不一样,XP 是\Device\Tcp,部分版本是\Device\Tcp6,还有的用\Device\RawIp。另外,如果驱动加载太早,TCP 设备还没创建,也会找不到。解决:先枚举\Device目录,用ZwOpenDirectoryObject加ObReferenceObjectByName动态查找包含Tcp的设备名。加载时机上,把驱动启动类型设为SERVICE_DEMAND_START,等网络栈就绪后再net start。
4.3 发送拦截导致吞吐骤降:拷贝与锁的代价
现象:启用发送内容检查后,大文件传输速度从 100MB/s 掉到 5MB/s。原因:每次发送都ExAllocatePoolWithTag拷贝一份缓冲,再逐字节扫描,分配和拷贝占了主要开销。解决:改成零拷贝扫描,直接对 MDL 映射的系统地址做检查,检查完立即释放映射。如果必须拷贝,用LookasideList预分配固定大小缓冲,避免频繁池分配。扫描算法上,先检查长度和协议头,不匹配直接放行,不要全量遍历。
4.4 卸载驱动后蓝屏:挂起 IRP 未取消
现象:net stop卸载驱动时蓝屏,错误码0x000000CE或0x0000007E,栈回溯在TdiFilterUnload里。原因:卸载时还有未完成的 IRP 挂在自定义队列里,TdiFilterUnload直接释放了上下文,后续 IRP 完成回调访问了已释放内存。解决:在TdiFilterUnload里先设置g_Unloading = TRUE,然后遍历所有连接上下文,调用IoCancelIrp取消挂起 IRP,等待引用计数归零再释放。取消后要等一个KeDelayExecutionThread周期,确保回调执行完毕。
4.5 规则表并发访问:哈希表在 DISPATCH_LEVEL 下损坏
现象:多线程压测时随机蓝屏,错误码0x000000C2或0x00000050,池损坏。原因:规则哈希表用ExAllocatePoolWithTag分配,插入和查询没有加锁,或者用了ExAcquireFastMutex但在DISPATCH_LEVEL下调用导致死锁。解决:规则表用ERESOURCE保护,查询走共享模式,插入删除走独占模式。如果查询在DISPATCH_LEVEL触发,改用ExAcquireSpinLock加非分页池存储,或者把规则表设计成只读快照,更新时整体替换指针。
5. 进阶技巧:用ETW与WPP给TDI驱动装上可观测性
TDI 驱动源代码调试最痛苦的是没有日志,一出问题只能靠 WinDbg 硬看。我一般会在源码里集成 WPP 跟踪,配合 ETW 做实时过滤事件采集。WPP 的好处是编译期生成跟踪宏,运行时开销极低,适合在发送路径这种高频位置打点。下面是一个 WPP 初始化的最小示例:
// 在驱动入口初始化 WPP #include "tdifilter.tmh" NTSTATUS DriverEntry(PDRIVER_OBJECT DriverObject, PUNICODE_STRING RegistryPath) { WPP_INIT_TRACING(DriverObject, RegistryPath); // ... 其他初始化 return STATUS_SUCCESS; } // 在连接过滤点打点 DoTraceMessage(TRACE_CONNECT, "Connect to %u.%u.%u.%u:%u blocked=%d", (remoteIp >> 24) & 0xFF, (remoteIp >> 16) & 0xFF, (remoteIp >> 8) & 0xFF, remoteIp & 0xFF, remotePort, blocked);逻辑说明:WPP_INIT_TRACING在DriverEntry里调用,传入驱动对象和注册表路径。DoTraceMessage是 WPP 宏,第一个参数是跟踪级别,后面是格式化字符串。tdifilter.tmh是构建系统自动生成的头文件,需要在sources里加RUN_WPP指令。参数上,跟踪级别用自定义的TRACE_CONNECT,在.tmh里映射到具体值。用户态用traceview或logman启动会话,实时看输出。注意 WPP 宏在DISPATCH_LEVEL下也能用,但格式化字符串里的%s要小心,分页字符串会触发页错误。
另一个技巧是用 ETW 自定义提供程序,把过滤决策写成结构化事件,方便跟用户态日志关联。ETW 注册比 WPP 重,但胜在用户态工具链成熟,可以用xperf做火焰图。我通常只在关键路径用 WPP,在规则更新和连接统计这种低频路径用 ETW。验证方法上,先加载驱动,用logman start tdifilter -p {GUID} -o tdifilter.etl -ets启动会话,然后跑测试用例,最后logman stop tdifilter -ets停止,用tracerpt转成 CSV 分析。如果事件丢失,检查 ETW 缓冲大小,默认 64KB 在高频场景不够,改成 1MB 并启用EVENT_TRACE_FLAG_DRIVER。
血泪经验是:不要等蓝屏了才加日志。TDI 驱动源代码一旦跑在客户环境,复现成本极高,提前把 WPP 和 ETW 埋好,出问题时至少知道是哪个连接、哪个端口、哪条规则触发的。我现在的习惯是每个过滤决策点都打一条 WPP,规则表更新打 ETW,卸载时输出统计摘要。这样即使现场没有 WinDbg,也能靠 ETL 文件回溯。希望帮到你。
本文还有配套的精品资源,点击获取