实现一个EDR不可见的网络通信
引言
写这篇文章的灵感来源于对 lwIP 这个轻量级 TCP/IP 协议栈的分析。lwIP(lightweight IP)是嵌入式世界里赫赫有名的一个开源协议栈,它的代码非常精简,内存占用极低,可配置性又很强,因此在单片机、IoT 设备这些资源受限的平台上应用极其广泛。但有意思的是,把这个本该跑在嵌入式系统上的协议栈移植到了 Windows 的内核驱动里,让它能在内核态直接收发网络数据包。这意味着什么?意味着我们可以绕过 Winsock 那一整套用户态协议栈的封装,获得更高的性能和更灵活的控制力。
我们先看看这个移植项目的整体结构吧。
先说说 lwIP 是什么
lwIP 是瑞士计算机科学院(SICS)开发的一个开源 TCP/IP 协议栈。它诞生的初衷是为嵌入式系统提供一个功能完整但体积小巧的 TCP/IP 实现。跟 Linux 内核的 TCP/IP 栈比起来,lwIP 的代码量大概只有前者的十分之一,但它一样实现了 TCP、UDP、IP、ICMP、ARP、DHCP、DNS 这些核心协议。
lwIP 最厉害的地方在于它的可移植性。它把自己和操作系统的接口定义得清清楚楚——信号量怎么创建、线程怎么启动、内存怎么分配,所有这些都抽象成一组接口,你只要在 sys_arch.c 里把这些接口填上,lwIP 就能在你的平台上跑起来。所以你能看到 lwIP 被移植到了 FreeRTOS、uCOS、RT-Thread 这些 RTOS 上,也被移植到了 Windows、Linux 这些通用操作系统上,甚至还有人把它移植到了完全没有操作系统的裸机上。
我们这个项目做的就是把它移植到 Windows NT 内核里,而且不仅仅是让它在内核态能跑,还要让它能真正通过物理网卡收发数据。这就要用到 NDIS 和 API Hook 了。
架构概览
分层设计
整个项目是分层的,每一层各司其职:
lwIP 核心协议栈 (tcp/udp/ip/icmp/arp/dhcp...)
↕
ethernetif.c — 网络接口适配层,桥接 pbuf 与 NDIS
↕
protocol.c — 协议注册与发包管理,支持 NDIS 5 /6 两种路径
↕
miniport.c + dma.c — 微型端口事件处理 + 散列汇聚 DMA
↕
ndisif.c + ndisx.h — NDIS 接口层 + 逆向的内部结构
↕
ndishook.c + minihook.c — NDIS 挂钩引擎,JMP 补丁拦截 ndis.sys
↕
sys_arch.c + rosmem.c + rosip.c — OS 抽象层
最底层是系统抽象层,提供线程、信号量、邮箱、内存分配这些基础服务;往上是挂钩引擎层,负责拦截 NDIS 的导出函数;再往上是 NDIS 协议框架层,管理微型端口、适配器和协议绑定;最顶层就是 lwIP 核心协议栈本身。数据从网卡进来,经过这些层的逐级传递,最终被 lwIP 协议栈处理。
关键设计思想
这个项目有几个很有意思的设计决策。
一是零拷贝接收。接收数据包时,不是把数据从 NDIS 缓冲复制到 lwIP 缓冲,而是直接用 pbuf 引用 NDIS 缓冲里的数据,指针指过去就是了,省掉了一次内存拷贝。对于高速网络来说,这个优化是很可观的。
什么叫零拷贝接收呢?传统的做法是这样的:当 NDIS 从网卡收到一个数据包时,数据首先被 DMA 进一个 NDIS 分配的缓冲区,然后驱动调用 NdisGetDataBuffer 拿到数据指针,再调用 pbuf_alloc(PBUF_RAM) 在 lwIP 的堆上分配一块新的内存,最后用 memcpy 把数据从 NDIS 缓冲拷进这块新内存。这么一来,同样一份数据在物理内存里就有了两份拷贝——一份在 NDIS 那边,一份在 lwIP 这边。网速慢的时候还好说,到了千兆甚至万兆网络,每秒钟几百万个包进来,每个包都要 memcpy 一次,太麻烦了。
这个项目绕过这个问题的办法很巧妙——它用了一个叫做 pbuf_alloced_custom(PBUF_REF) 的 lwIP API 来创建所谓"自定义 pbuf"。这种 pbuf 不拥有自己的数据缓冲区,它只是持有一个指向外部数据的外指针。具体做法是,当 MpIndicateReceiveNetBufferLists 从 NDIS 的 NET_BUFFER_LIST 中取出数据后,调用 NdisGetDataBuffer 直接拿到 NDIS 内部缓冲区的虚拟地址,然后把这个地址塞进一个 pbuf_custom 结构里,最后用 pbuf_alloced_custom 让 lwIP 以为这是一个正常的 pbuf。这样一来,lwIP 的 TCP 栈在处理数据时,实际上是在直接操作 NDIS 的缓冲区,中间没有发生任何数据拷贝。
当然,代价也是有的。因为 pbuf 不拥有数据,所以调用者必须保证在 lwIP 处理完这个 pbuf 之前,NDIS 的缓冲区不能被释放。代码里用了一个引用计数的机制来管理这个生命周期——在 pbuf 的 custom_free_function 回调中释放 NDIS 缓冲的所有权。这样既保证了零拷贝的性能优势,又避免了野指针造成的崩溃。
二是 DMA 散列汇聚支持。对于支持 SG DMA 的网卡,干脆让硬件直接做数据搬移,CPU 可以在旁边歇着。
什么叫 DMA 呢?传统的 CPU 做数据搬移是这样的:CPU 从源地址读一个字节到寄存器,再从寄存器写一个字节到目的地址,循环往复。这个过程叫 PIO(Programmed I/O)。DMA(Direct Memory Access)就是把这个事情外包给专门的硬件——DMA 控制器。CPU 只需要告诉 DMA 控制器迁移什么,迁移到哪里,然后就可以去做别的事了,DMA 控制器会在后台把数据搬完,完了发个中断。
那 SG DMA(Scatter-Gather DMA)又是啥呢?简单来说,普通的 DMA 要求源地址和目的地址在物理内存里是连续的——你要搬 1500 字节,这 1500 字节在物理页上必须是一块连续的区间。但问题在于,NDIS 的数据包缓冲和 lwIP 的 pbuf 链不一定是物理连续的。一个 1500 字节的以太网包,在 NDIS 那边可能分散在两三个不连续的物理页上。如果只用普通 DMA,你得先分配一块连续的缓冲区,把所有数据拷贝进去,再让 DMA 控制器去搬——这又回到了拷贝的老路。
SG DMA(散列汇聚 DMA)解决了这个问题。它允许你把一个数据描述成一个"散列-汇聚列表"(Scatter-Gather List),列表中每一项是一个{物理地址, 长度}对,指向数据在内存中的各个碎片。DMA 控制器自己会按照这个列表,依次把各个碎片的数据搬到网卡上,或者从网卡收来的数据按列表依次写入各个碎片。这样一来,即使数据在物理内存里是东一块西一块的,硬件也能一次性搞定,不需要 CPU 介入做合并。
这个项目在 dma.c 里实现了一套三级降级策略来应对不同硬件的能力:
路径 A,最优路径:网卡支持 SG DMA,且 NDIS 提供了 Lookaside 列表预分配的 SG 缓冲区,直接让硬件干活。
路径 B,次优路径:网卡支持 SG DMA,但没有预分配缓冲区,让 NDIS 系统临时分配 SG 列表。
路径 C,回退路径:网卡不支持 SG DMA,或者 SG DMA 分配失败了,就分配一块连续缓存,把数据拷贝合并,再用普通方式发出去。
这套策略保证了无论硬件能力强弱,代码都能正常工作。
三是 NDIS 5/6 双模式兼容。NDIS 5 用 Packet API,NDIS 6 用 NetBufferLists API,代码里两套路径都有,遇到什么版本的驱动都能处理。
四是内核态 API Hook。这个是最难搞的部分,直接改 CR0 寄存器的 WP 位关掉写保护,往 ndis.sys 的导出函数入口写 JMP 指令,把调用重定向到自己的处理函数。当然,在 x64 Windows 10/11 上这么做会触发 Kernel PG。后面我们会详细说这个。
五是多介质支持。不止是 Ethernet 802.3,WAN、FDDI、IrDA、WirelessWAN 等 12 种 NDIS 介质类型都覆盖到了。
核心移植层文件详解
主驱动入口——lwip_driver.c
驱动入口非常精简,总共也就三四十行。DriverEntry 里干了五件事:
NTSTATUS DriverEntry (PDRIVER_OBJECT DriverObject, PUNICODE_STRING RegistryPath)
{
DriverObject->DriverUnload = DriverUnload;
LibIPInitialize();
NDIS_InitializeInterface();
ProtocolInitializeSubsystem();
Status = NdisSetHooks();
return STATUS_SUCCESS;
}
初始化顺序是有讲究的:lwIP 先启动(创建 tcpip_thread),然后初始化 NDIS 接口(准备好全局链表),再初始化协议子系统(分配 Lookaside 缓存),最后才安装挂钩。任何一步失败了,前面的操作都会回滚。卸载的时候顺序反过来就行:
VOID DriverUnload (PDRIVER_OBJECT DriverObject)
{
RemoveAllHooks(NULL );
ProtocolReleaseSubsystem();
NDIS_ReleaseInterface();
LibIPShutdown();
}
操作系统抽象层——sys_arch.c
这是移植最核心的一个文件。lwIP 要求平台提供信号量、邮箱、线程、临界区保护这些基础设施,sys_arch.c 就是在 Windows NT 内核里实现这些东西。
时间系统
static LARGE_INTEGER StartTime;
u32_t sys_now (void )
{
LARGE_INTEGER CurrentTime;
KeQuerySystemTime(&CurrentTime);
return (u32_t )((CurrentTime.QuadPart - StartTime.QuadPart) / 10000 );
}
KeQuerySystemTime 返回的是 1601 年 1 月 1 日以来经过的 100 纳秒间隔数,减去驱动加载时记录的 StartTime,再除以 10000,就得到了从驱动加载到现在过去了多少毫秒。精度是 100 纳秒,远超 lwIP 要求的毫秒级精度。
临界区保护
static KSPIN_LOCK GlobalSystemLock;
void sys_arch_protect (sys_prot_t *lev)
{
KeAcquireSpinLock(&GlobalSystemLock, lev);
}
void sys_arch_unprotect (sys_prot_t lev)
{
KeReleaseSpinLock(&GlobalSystemLock, lev);
}
这里用了一个全局自旋锁,把当前 IRQL 保存到 lev 里,释放的时候再恢复。优点是实现简单,缺点是全局锁在多核高并发下可能成为瓶颈。不过在驱动开发场景下,这个实现是够用的。
信号量
typedef struct _sys_sem_t {
KEVENT Event;
LONG Valid;
} sys_sem_t ;
SynchronizationEvent 类型保证一次只唤醒一个等待线程。count=1 时事件初始为 signaled(资源可用),count=0 时初始为 non-signaled(资源被占)。
信号量等待函数有一个很精妙的设计,它不光等信号量本身,还等一个 TerminationEvent:
void *WaitObjects[] = { &sem->Event, &TerminationEvent };
Status = KeWaitForMultipleObjects(2 , WaitObjects, WaitAny, ...);
为什么要这么设计呢?根本原因在于 Windows 内核中无法安全地强制终止一个正在等待中的线程。如果你直接调用 PspTerminateThreadByPointer,线程可能正持有某个锁、正在操作某个数据结构,强行杀掉它会导致系统状态不一致,轻则内存泄漏,重则死锁蓝屏。所以这里的策略不是"杀掉线程",而是"告诉线程自己退出"。TerminationEvent 就是这扇"告诉"的门——当 sys_shutdown 设置这个事件后,所有通过 KeWaitForMultipleObjects 等待信号量的线程都会在 wait 返回时发现第二个对象被触发了,于是从等待状态苏醒,走各自的清理路径后自行结束。这个模式在 Windows 内核编程中叫做"关闭事件模式"(Shutdown Event Pattern),是驱动开发里优雅关闭线程的标准做法。
消息邮箱
邮箱是 lwIP 线程间通信的核心。tcpip_thread 就是从这个邮箱里取消息来处理的。实现上用了三个要素:一个 LIST_ENTRY 链表存消息,一个 KEVENT 做等待,一个 KSPIN_LOCK 保护并发:
typedef struct _sys_mbox_t {
LIST_ENTRY ListHead;
KEVENT Event;
KSPIN_LOCK Lock;
LONG Valid;
} sys_mbox_t ;
发消息时分配一个 container,加锁插入链表尾部,设置事件唤醒等待者。收消息时等事件,加锁取出头部消息,如果链表空了就清除事件。
线程创建
sys_thread_t sys_thread_new (const char *name, lwip_thread_fn thread,
void *arg, int stacksize, int prio)
{
Container = ExAllocatePoolWithTag(NonPagedPool, sizeof (*Container), 'Thrd' );
Status = PsCreateSystemThread(&Container->Handle, ...);
}
线程包装函数 LwipThreadMain 负责把线程信息插入全局链表、调用实际线程函数、从链表移除自己、释放内存,最后 PsTerminateSystemThread(STATUS_SUCCESS) 收尾。
关闭的时候,sys_shutdown 先设置 TerminationEvent 通知所有线程退出,然后遍历线程链表等待每个线程真正结束,最后关闭句柄。
内存分配器——rosmem.c
lwIP 默认自带内存堆管理器,但我们在 lwipopts.h 里配了 MEM_LIBC_MALLOC=1,让它用标准的 malloc/free 代替。rosmem.c 就是在内核里实现这些函数:
void *malloc (size_t size)
{
return ExAllocatePoolWithTag(NonPagedPool, size, LWIP_POOL_TAG);
}
void *calloc (size_t count, size_t size)
{
void *mem = ExAllocatePoolWithTag(NonPagedPool, total, LWIP_POOL_TAG);
if (mem) RtlZeroMemory(mem, total);
return mem;
}
void free (void *mem)
{
if (mem) ExFreePoolWithTag(mem, LWIP_POOL_TAG);
}
所有分配用 NonPagedPool(非分页池),因为驱动在高于 DISPATCH_LEVEL 的 IRQL 下运行时不能触发页错误。LWIP_POOL_TAG = 'PIwl' 用于 WinDbg 的 !poolused 命令追踪内存泄漏。
lwIP 生命周期管理——rosip.c
把 lwIP 的初始化和关闭封装成两个函数:
void LibIPInitialize (void ) { tcpip_init(NULL , NULL ); }
void LibIPShutdown (void ) { sys_shutdown(); }
tcpip_init 会调用 sys_init() 初始化 OS 抽象层,然后创建 tcpip_thread 和 tcpip_lock。注意 tcpip_init 是异步的——它返回后 tcpip_thread 可能还没完全就绪,所以网络接口应该在初始化完成之后再加。
为什么 tcpip_init 要设计成异步的呢?这跟 lwIP 的线程模型有关。tcpip_init 的核心工作是在一个单独的线程——tcpip_thread——中运行整个协议栈的事件循环。这个线程负责处理所有协议相关的操作:TCP 连接管理、数据收发、定时器触发等等。如果 tcpip_init 是同步的,那就意味着它要等 tcpip_thread 启动完毕、所有子系统初始化完成、协议栈进入就绪状态之后才返回。但问题在于,tcpip_thread 的事件循环本身是无限循环,它永远"就绪"的标准很难定义——是线程创建成功了就算?还是第一个消息循环跑完了算?还是 TCP 监听端口打开了算?不同的上层调用者对"就绪"的定义是不同的。
所以 lwIP 的设计者选择了更灵活的方式:tcpip_init 只负责创建线程和初始化内部锁,然后立即返回,让调用者继续做自己的事。tcpip_thread 在后台慢慢完成剩余的初始化。调用者什么时候认为可以开始使用网络了,就什么时候去添加 netif 或者发消息。这种异步设计也带来一个好处:驱动入口(DriverEntry)不会被阻塞太久。Windows 内核要求 DriverEntry 在合理时间内返回,如果在里面等 tcpip_thread 初始化,可能会超时。异步初始化正好规避了这个限制。
当然,异步也意味着调用者需要自己处理好同步问题。所以在 ProtocolRegisterInterface 中,调用 netifapi_netif_add 而不是直接调用 netif_add——netifapi_* 系列的内部机制会把请求封装成消息发到 tcpip_mbox 中,由 tcpip_thread 串行处理,天然地解决了竞态条件。
网络接口适配层——ethernetif.c
这是 lwIP 和 NDIS 之间的桥梁,也是整个移植中最薄但也最关键的一层。lwIP 的协议栈是纯软件实现,它不知道什么是 NDIS、什么是网卡、什么是中断——它只知道 struct netif 这个抽象的网络接口。ethernetif.c 的任务就是把 NDIS 那边的硬件能力翻译成 lwIP 能理解的 netif 接口。翻译得好不好,直接决定了数据能不能顺畅地在协议栈和网卡之间流动。
具体来说,它实现了三个核心函数。
第一个是 low_level_init,在 netif 注册时被调用,负责从 NDIS 适配器读取 MAC 地址、MTU 这些硬件参数,然后填进 netif 结构体。这里有一个值得注意的细节:它根据介质类型动态决定是否启用 ARP。以太网(NdisMedium802_3)需要 ARP 来解析 IP 到 MAC 的映射,所以加上 NETIF_FLAG_ETHARP 标志,接口名命名为 en。WAN 链路则不需要 ARP,接口名用 wa。这个区分很重要——如果给 PPP 链路配上了 ARP,协议栈会不断地发无意义的 ARP 请求,白白浪费带宽。
第二个是 low_level_output,负责把 lwIP 的数据包发出去。它本身非常薄,拿到 pbuf 之后直接调用 pProto->PSendBuffer 委托给 ProtocolSendPBuffer 去处理。为什么这么薄?因为发包涉及 NDIS 5/6 的选择、DMA 路径的选择、数据包池的管理,这些逻辑都集中在 protocol.c 里更合理,ethernetif.c 不需要重复实现。
第三个是 ethernetif_init,作为 netif_add 的回调被调用,负责把上面两个函数挂到 netif 的正确位置。它还根据介质类型选择 output 函数:以太网介质用 etharp_output(先查 ARP 缓存再发),非以太网介质(WAN、IrDA 等)用 nonarp_output(直接发,不做 ARP 解析)。nonarp_output 的实现很简单——忽略 ipaddr 参数,直接调 low_level_output:
static err_t nonarp_output (struct netif *netif, struct pbuf *p, ip_addr_t *ipaddr)
{
LWIP_UNUSED_ARG(ipaddr);
return low_level_output(netif, p);
}
这层设计之所以薄,是因为它的定位就是"适配器",而不是"实现者"。它不关心数据包怎么组装的、不关心协议栈内部的状态机、不关心 NDIS 的细节——它只做一件事:把 lwIP 的 netif 和 NDIS 的协议状态绑在一起,让数据能够在两者之间流动。这种职责分离让移植的每一层都可以独立测试和调试。
low_level_init 从 NDIS 适配器读取 MAC 地址和 MTU,并根据介质类型设置 ARP 标志和接口名称:
static void low_level_init (struct netif *netif)
{
netif->hwaddr_len = (u8_t )pIf->HWAddressLength;
memcpy (netif->hwaddr, pIf->CurrentMacAddress, netif->hwaddr_len);
netif->mtu = (u16_t )pIf->MTU;
netif->flags = NETIF_FLAG_BROADCAST | NETIF_FLAG_LINK_UP;
switch (pIf->MediaType) {
case NdisMedium802_3:
case NdisMediumDix:
netif->flags |= NETIF_FLAG_ETHARP;
netif->name[0 ] = 'e' ; netif->name[1 ] = 'n' ;
break ;
case NdisMediumWan:
netif->name[0 ] = 'w' ; netif->name[1 ] = 'a' ;
break ;
}
}
low_level_output 是个薄包装,实际发包逻辑委托给 ProtocolSendPBuffer。
有意思的是 nonarp_output——对于 WAN、IrDA 这些非以太网介质,ARP 没有意义,所以直接调用 low_level_output 跳过 ARP 解析。
ethernetif_init 根据介质类型选择 output 函数:以太网用 etharp_output,非以太网用 nonarp_output。
NDIS 挂钩引擎
minihook.c/h——通用导出函数挂钩
这是整个项目最烦人的部分。minihook 是一个通用的内核级函数挂钩库,能在运行时修改 ndis.sys 的导出函数入口,把调用重定向到我们自己的处理函数。
注意,这里的挂钩会触发PG和HCVI的检查
数据结构和 JMP 补丁
挂钩函数的信息保存在 HOOK_FUNCTION 结构里,包括目标模块名、函数名、替换地址和原始地址。HOOK 结构多加了一个链表节点用于全局管理。
挂钩的核心是在目标函数入口写 JMP 指令。x86 下用 5 字节的 JMP rel32,x64 下用 14 字节的 JMP [RIP+offs]——因为 x64 不支持直接 64 位的相对跳转,得用 RIP 相对间接跳转。
写保护绕过
static VOID WriteProtected (PVOID Addr, UCHAR *Data, ULONG Size)
{
ULONG_PTR cr0 = __readcr0();
__writecr0(cr0 & ~((ULONG_PTR)0x10000 ));
RtlCopyMemory(Addr, Data, Size);
__writecr0(cr0);
__faststorefence();
KeInvalidateRangeAllCaches(Addr, Size);
}
static VOID WriteProtected (PVOID Addr, UCHAR *Data, ULONG Size)
{
ULONG_PTR cr0 = __readcr0();
__writecr0(cr0 & ~((ULONG_PTR)0x10000 ));
RtlCopyMemory(Addr, Data, Size);
__writecr0(cr0);
__faststorefence();
KeInvalidateRangeAllCaches(Addr, Size);
}
CR0 的位 16(WP)控制内核能不能写只读页面。清掉 WP 就可以修改 ndis.sys 的代码段。改完之后恢复 WP,然后 __faststorefence 保证所有 CPU 都看到修改,KeInvalidateRangeAllCaches 刷新指令缓存。
MakeCallStub 生成一段代码,在跳转到处理函数之前把上下文参数压栈。
GetModuleBase 用 ZwQuerySystemInformation(SystemModuleInformation) 遍历已加载模块,按名称匹配找到目标模块基址。然后 SetHook 解析 PE 导出表,找到目标函数的地址,写入 JMP 补丁。
这段代码在现代 Windows 10/11 下有什么问题呢?主要有三个方面。
第一个问题就是之前反复提到的 Kernel PatchGuard(KPP)。从 x64 版本的 Windows 开始,微软加入了这个内核保护机制,它会定时扫描内核代码段(包括 ndis.sys 的 .text 区),如果发现任何被修改的地方——比如我们写进去的 JMP 指令——就会直接触发 CRITICAL_STRUCTURE_CORRUPTION 的 bugcheck,也就是蓝屏。PatchGuard 的扫描是随机的、多线程的,你根本不知道它什么时候会检查,所以这种 inline hook 在开启了 PatchGuard 的系统上随时可能暴毙。
第二个问题是 Hyper-V 和 Windows Defender 的进一步限制。如果系统启用了 Hyper-V 的基于虚拟化的安全(VBS)或者内存完整性(HVCI),内核代码段所在的页面会被 Hyper-V 的 hypervisor 标记为只读,连 CR0.WP 都不好使了——因为写保护现在是在虚拟机管理器层面实施的,你改 CR0 只能骗过本地的 CPU,但 hypervisor 会拦截实际的页面写入。在这种情况下,WriteProtected 函数执行 __writecr0 之后去写内存会直接触发访问违规,驱动直接崩溃。
第三个问题是 Windows 内核版本差异。ZwQuerySystemInformation 这个 API 在 Windows 10 之后已经被标记为不推荐使用,虽然还能调用,但微软在后续版本中限制了一些调用者的权限。另外 ndis.sys 的导出表在不同 Windows 版本之间也有变化,有些函数在新版本中可能已经不是通过名称导出的了,而是通过编号导出(EAT-only),这样我们的 _stricmp 名称匹配就找不到目标函数了。
所以总体来说,这套挂钩方案在 Windows 7 上跑得很稳,但在现代 Windows 上需要面对 PatchGuard、HVCI、导出表变化这三座大山。
ndishook.c/h——NDIS 特定挂钩
在 minihook 的基础上,ndishook 定义了 11 个 ndis.sys 导出函数的挂钩,涵盖微型端口注册、初始化、数据收发、状态指示等关键操作。
NdisMRegisterMiniportDriver——最关键的挂钩
当真实网卡驱动调用 NdisMRegisterMiniportDriver 注册自己的时候,挂钩函数会拦截它:
static NDIS_STATUS my_NdisMRegisterMiniportDriver (...)
{
if (Chars->Flags & NDIS_INTERMEDIATE_DRIVER)
return Original(...);
m = NDIS_AllocateMiniport();
NdisMoveMemory(&m->Ndis6.Chars, Chars, ...);
Chars->InitializeHandlerEx = MakeCallStub(
&m->Ndis6.STUB_MiniportInitializeEx,
&my_MiniportInitializeEx, m, 3 );
Chars->HaltHandlerEx = my_MiniportHaltEx;
Chars->PauseHandler = my_MiniPause;
Chars->RestartHandler = my_MiniRestart;
return Original(...);
}
核心思想是:NDIS 不直接管网卡硬件,是各个网卡厂商的微型端口驱动在管。挂钩 NdisMRegisterMiniportDriver 后,我们可以替换微型端口驱动的 InitializeHandlerEx、HaltHandlerEx 等回调函数。
这样做的好处在于,我们完全绕过了注册表配置和驱动签名问题。如果按照正常的 NDIS 中间层驱动来做,你需要写一个 INF 文件,在注册表中注册 UpperBindings 之类的键值,还要给驱动签名才能加载。而且中间层驱动要处理各种 bind/unbind 的状态转换,跟系统协议栈的交互也很复杂。挂钩的方式就简单粗暴多了——不管系统里装了什么网卡驱动,只要它调用 NdisMRegisterMiniportDriver,我们就拦截、替换回调、注入自己的逻辑。整个过程对网卡驱动本身是透明的,它甚至不知道自己被劫持了。
初始化拦截链
my_MiniportInitializeEx 的调用链:微型端口驱动初始化成功 → 创建 KIP 适配器 → 调用 MpInitialize 初始化 lwIP netif → ProtocolBindAdapter 绑定协议。
接收拦截——透明模式
static VOID my_NdisMIndicateReceiveNetBufferLists (...)
{
PKIP_NDIS_ADAPTER p = NDIS_FindAdapterByHandle(H);
if (p && KIP_MINIPORT_IS_ACTIVE(p))
MpIndicateReceiveNetBufferLists(p, &N, P, &C, F);
if (N && C)
Original(H, N, P, C, F);
}
这里的设计非常巧妙:挂钩不阻断数据流。它先偷看数据包送给 lwIP,然后继续把数据传给系统原本的 NDIS 协议驱动。lwIP 和系统 TCP/IP 栈并行运行。
媒体状态追踪
通过挂钩 NdisMIndicateStatus 和 NdisMIndicateStatusEx,驱动可以跟踪网络连接和断开事件,这对 DHCP 自动配置和链路感知很重要。
具体而言,当微型端口驱动检测到网线插入或者无线网络连接建立时,它会调用 NdisMIndicateStatus 发出 NDIS_STATUS_MEDIA_CONNECT 状态指示。挂钩函数拦截到这个指示后,清除适配器上的 fADAPTER_INACTIVE 标志,并递增 MediaConnectCount 计数器。反过来,网线拔出或者无线信号丢失时会触发 NDIS_STATUS_MEDIA_DISCONNECT,挂钩函数设置 fADAPTER_INACTIVE 标志,递增 MediaDisconnectCount 计数器。
这个状态追踪的意义在于,lwIP 的 DHCP 客户端需要知道链路是否在线。如果链路断开,DHCP 续约请求发了也是白发,只会浪费带宽和增加重传超时。通过跟踪 MediaConnectCount 和 MediaDisconnectCount,驱动可以判断链路是否处于稳定连接状态,从而决定是否让 DHCP 客户端发起请求。另外在 MpInitialize 中初始化完成后,驱动也会根据当前的链路状态来设置适配器标志,确保初始状态和硬件实际状态一致。
NDIS 协议框架
ndisif.c/h——核心数据结构
定义了整个架构的核心数据结构。数据结构层次是这样的:
KIP_NDIS_MINIPORT — 被挂钩的微型端口驱动
├── AdapterList — 该驱动管理的网卡链表
▼
KIP_NDIS_ADAPTER — 一个物理网卡实例
├── Interface — KIP_NDIS_INTERFACE(抽象接口)
│ ├── Protocol — KIP_NDIS_PROTOCOL(协议状态)
│ │ ├── IIF — lwIP 的 struct netif
│ │ ├── PSendBuffer — 发包回调
│ │ └── PacketPoolHandle — NDIS 5 数据包池
│ ├── Miniport — 指向所属微型端口
│ ├── MTU, Speed — 硬件参数
│ └── CurrentMacAddress — MAC 地址
├── NdisMiniportHandle — NDIS 句柄
└── NdisMiniportAdapterContext — 微型端口上下文
辅助函数包括适配器查找(通过 NDIS 句柄或微型端口上下文)、OID 查询/设置、数据包拷贝等。全局链表用自旋锁保护,线程安全。
ndisx.h——NDIS 内部结构逆向
这是一个未文档化的头文件,逆向定义了 Windows 7 ndis.sys 的内部结构。主要用于获取 DMA Scatter-Gather 支持的内部字段。
typedef struct _NDIS_SG_DMA_BLOCK {
NDIS_OBJECT_HEADER Header;
PVOID Miniport;
PDMA_ADAPTER DmaAdapterObject;
PNPAGED_LOOKASIDE_LIST SGListLookasideList;
ULONG ScatterGatherListSize;
} NDIS_SG_DMA_BLOCK;
通过强转 NDIS_HANDLE 为 PNDIS_MINIPORT_BLOCK_WIN7,然后访问 MiniportSGDmaBlock 字段,就能拿到 DmaAdapterObject。这个技巧在 DMA 支持中至关重要。
protocol.c/h——协议绑定与发包管理
这是把 lwIP 的 pbuf 数据真正塞进网卡的关键模块。它负责三件事:初始化协议子系统、向 lwIP 注册网络接口、以及实现实际的发包路径。
先说初始化。ProtocolInitializeSubsystem 创建了两个 NPAGED_LOOKASIDE_LIST。Lookaside 列表是 Windows 内核提供的一种对象缓存机制——它预先分配好一批固定大小的对象放在列表里,分配的时候直接从列表头部取一个,释放的时候插回列表。比起每次都调 ExAllocatePoolWithTag 去走通用堆分配,Lookaside 列表要快得多,而且因为对象大小固定,不会产生堆碎片。这两个列表一个用于分配 KIP_PACKET_DESCRIPTOR(数据包描述符),一个用于分配 MAX_ETHER_SIZE(1514 字节)的数据缓冲。这两个结构在发包路径中被高频使用,所以用 Lookaside 优化是很值得的。
然后是接口注册。ProtocolRegisterInterface 调用 netifapi_netif_add 向 lwIP 添加网络接口。这里要注意参数传递的关键:pProtocol 被作为 state 参数传给了 netif,这样 ethernetif.c 里的 low_level_output 就能通过 netif->state 访问到 KIP_NDIS_PROTOCOL,进而拿到 PSendBuffer 回调函数和 NDIS 包池句柄。有了这个双向引用,lwIP 侧的数据包才能找到 NDIS 侧的发送路径。
最后是发包路径。代码支持两种模式:
NDIS 5 路径走的是 Packet API。具体过程是:先从 PacketPoolHandle 中分配一个 NDIS_PACKET_EX 结构——这个结构在 NDIS_PACKET 尾部嵌入了一个 NDIS_PACKET_RESERVED,里面预分配了 MAX_ETHER_SIZE 大小的 DataBuffer,这样就不需要额外分配数据缓冲了。然后用 CopyPbufToBuffer 遍历 lwIP 的 pbuf 链,把所有 pbuf 的 payload 按顺序拷贝到 DataBuffer 中。接着调用 NdisChainBufferAtFront 把 DataBuffer 挂到 Packet 上,最后通过微型端口的 SendPacketsHandler(批量发送)或 SendHandler(单包发送)把数据发出去。发送完成后,MpSendPacketCompleteHandler 会被回调,释放数据包并递减 OutstandingSends 计数。
NDIS 6 路径走的是 NetBufferLists API。过程类似,但使用的是 NET_BUFFER_LIST 和 NET_BUFFER 结构,最后通过 NdisSendNetBufferLists 发送。
无论走哪条路径,如果网卡支持 SG DMA,都会优先尝试 MpAllocSGList 走 DMA 路径。DMA 路径下数据包的物理地址由硬件直接管理,不需要 CPU 参与拷贝,性能最好。
miniport.c/h——微型端口事件处理
这个文件实现了微型端口驱动的事件处理函数,最主要的就是 MpInitialize——它在微型端口驱动的初始化完成回调中被调用,负责探测网卡的所有硬件参数并配置好 lwIP 的 netif。
MpInitialize 的第一步是介质类型检查。它用一个 switch 语句判断网卡的介质类型是否被支持:802.3 以太网、802.5 令牌环、FDDI、WAN、IrDA、WirelessWAN 等 12 种介质都通过,其余的(比如 NdisMedium1394、NdisMediumInfiniBand)直接返回 NDIS_STATUS_NOT_SUPPORTED 并标记为 inactive。这一步很重要——不是所有 NDIS 介质都需要 TCP/IP 协议栈处理,有些是专用总线的数据通道。
第二步是通过 OID 查询获取硬件参数。OID(Object Identifier)是 NDIS 定义的一套标准查询接口,每个 OID 对应一个特定的硬件属性。查询流程有严格的顺序依赖:
先查 OID_GEN_VENDOR_DESCRIPTION 拿厂商字串,主要给调试输出用。然后查 OID_GEN_MAC_OPTIONS,这个返回值是一个位掩码,包含几个重要的能力标志。比如 NDIS_MAC_OPTION_TRANSFERS_NOT_PEND 表示网卡不会暂挂传输——也就是说发送请求要么立即完成,要么失败,不会出现 NDIS_STATUS_PENDING。有这个标志的话,后面的接收路径可以做一些简化。NDIS_MAC_OPTION_COPY_LOOKAHEAD_DATA 表示网卡会在查看缓冲区中拷贝数据,上层可以直接使用。这些标志影响了后续的数据处理策略。
接着查 MAC 地址。这里有一个细节:不同的介质类型要查询不同的 OID。802.3 以太网查 OID_802_3_CURRENT_ADDRESS,WAN 介质查 OID_WAN_CURRENT_ADDRESS,如果查到的地址全是零,说明网卡还没初始化完成,需要后续再查。MAC 地址是 lwIP netif 注册的必要参数,没有它协议栈无法工作。
然后查 MTU。OID_GEN_MAXIMUM_FRAME_SIZE 返回的是网卡支持的帧数据区大小。以太网的标准 MTU 是 1500,但有些网卡支持 jumbo frame(9000 字节)。MpInitialize 会把查询结果和 MAX_802_3_LENGTH(1500)取较小值,确保 lwIP 的 TCP 段大小不会超过硬件限制。
之后依次查 OID_GEN_MAXIMUM_TOTAL_SIZE(含头部在内的最大帧大小)、OID_GEN_MAXIMUM_SEND_PACKETS(网卡驱动的发送队列深度)、OID_GEN_LINK_SPEED(链路速度,单位是 100bps,所以要乘以 100 转成 bps)、OID_GEN_CURRENT_PACKET_FILTER(当前的包过滤掩码,比如是否接收广播包、组播包等)。
所有参数查完后,MpInitialize 用 DbgPrint 输出一行调试日志,包含介质类型、MAC 地址、MTU、速度和 MAC 选项,方便驱动开发者验证网卡是否被正确识别。
除了 MpInitialize 之外,这个文件还实现了另外几个核心函数。
MpIndicateReceiveNetBufferLists 是接收路径的入口。它的工作机制是:当微型端口驱动收到一个数据包并调用 NdisMIndicateReceiveNetBufferLists 时,我们的挂钩函数会先一步拦截到它。这个函数遍历 NET_BUFFER_LIST 链中的每个 NET_BUFFER,检查 DataLength 是否不小于 14 字节(以太网头部的最小长度),然后调用 NdisGetDataBuffer 获取数据指针,用 pbuf_alloced_custom(PBUF_REF) 创建一个引用该数据的 pbuf,最后通过 pProto->IIF.input 递交给 lwIP 协议栈。整个路径没有数据拷贝,指针直接传递,性能开销极低。
MpHalt 在网卡被拔出或驱动卸载时调用,负责优雅关闭。它先调用 netifapi_netif_set_down 让 lwIP 知道接口下线,然后用两个 while 循环等待所有待发送包(OutstandingSends)和待处理包(PendingPackets)完成——每次检查之间 delay 20 毫秒,避免忙等浪费 CPU。确认所有包都处理完后,调用 ProtocolUnBindAdapter 解绑协议,最后 NDIS_FreeAdapter 释放适配器资源。这个顺序保证了下线过程不会丢包。
dma.c/h——DMA 支持
对于支持 DMA 的网卡,可以用硬件直接搬移数据。MpAllocSGList 实现了三级降级策略:
路径 A(最优) :用 Lookaside 列表预分配的 SG 缓冲 + BuildScatterGatherList
路径 B(次优) :不用预分配,让系统分配 SG 列表
路径 C(回退) :分配连续缓冲,拷贝数据后用物理地址连续的 MDL
MpProcessSGList 是 DMA 完成后的回调,把 SG 列表存到数据包里,然后调用微型端口的 SendHandler。MpFreeSGList 负责释放 SG 列表和相关的标记。
lwIP 核心协议栈文件
下面的文件是 lwIP 协议栈的标准实现,没有被修改。它们通过 sys_arch.c 和 rosmem.c 提供的 OS 抽象来获取系统服务。
TCP 实现——tcp.c, tcp_in.c, tcp_out.c
TCP 是 lwIP 里最复杂的部分。tcp.c 负责 PCB 管理、连接建立/关闭、回调机制。tcp_in.c 实现 TCP 状态机——从 SYN_SENT 到 ESTABLISHED 再到 TIME_WAIT,RFC 793 定义的 11 个状态全覆盖。tcp_out.c 负责发送数据、分段、重传和零窗口探测。
lwIP 的 TCP 实现了 Nagle 算法减少小包、选择性确认(通过 TCP_QUEUE_OOSEQ 支持乱序队列)、完整的拥塞控制(慢启动、拥塞避免、快速重传/恢复)、以及 TCP 时间戳(PAWS 防止序列号回绕)。
UDP 实现——udp.c
相对简单,但功能完整:创建/绑定/连接/发送/接收/回调,支持广播和 UDP Lite。
IP 层——ip.c, ip_addr.c, ip_frag.c
ip_input 负责校验和检查、选项处理、按协议分发给 TCP/UDP/ICMP。ip_output 构造 IP 头并路由。ip_frag 处理分片和重组。
ICMP——icmp.c
响应 Echo Request(ping),发送 Destination Unreachable 和 Time Exceeded。
ARP——etharp.c
实现 ARP 缓存、老化、请求/响应。要注意的是 lwIP 的 ARP 缓存是固定大小的数组,满了会覆盖最旧的条目。
DHCP——dhcp.c
完整的 DHCP 客户端状态机:INIT → SELECTING → REQUESTING → BOUND → RENEWING → REBINDING。支持 Discover/Offer/Request/Ack/Nak/Release 全套消息。
DNS——dns.c
DNS 客户端,支持 gethostbyname 和缓存,最多配 3 台 DNS 服务器。
内存与缓冲管理——mem.c, memp.c, pbuf.c
配了 MEM_LIBC_MALLOC=1 后,mem_malloc 直接调用 rosmem.c 的 malloc。pbuf.c 是驱动开发中最常用的 API——pbuf_alloc、pbuf_free、pbuf_header(调整载荷指针,用于加/删协议头)、pbuf_chain(链接多个 pbuf)。pbuf_alloced_custom 是实现零拷贝的关键,它创建一个引用外部缓冲的 pbuf,不拥有数据。
网络接口管理——netif.c, netifapi.c
netif_add/remove/set_up/set_down 是接口的基本操作。netifapi 系列是线程安全的封装,通过 tcpip_mbox 把操作消息发到 tcpip_thread 执行。
Socket API——sockets.c, netdb.c, netbuf.c
实现了标准的 BSD Socket 接口:socket/bind/listen/accept/connect/recv/send/close/select。底层通过 netconn API 与协议栈交互。
TCP/IP 线程模型——tcpip.c
tcpip_thread 是 lwIP 的心脏,它从 tcpip_mbox 接收消息并分发处理。tcpip_input 是网卡接收数据包的入口,tcpip_callback_with_block 用于向 tcpip_thread 投递回调。
其他辅助文件
api_lib.c 和 api_msg.c 是 netconn API 的实现。err.c 把 err_t 转成字符串。def.c 做字节序转换。inet_chksum.c 计算 IP/TCP/UDP/ICMP 校验和。init.c 初始化所有子系统。timers.c 管理 TCP 重传定时器、ARP 老化定时器、DHCP 续约定时器。
平台配置与头文件
lwipopts.h——lwIP 配置
这是协议栈的控制面板。MEM_LIBC_MALLOC=1 让 lwIP 用外部的 malloc 代替自有堆管理器。LWIP_COMPAT_MUTEX=1 用二进制信号量代替互斥体。协议方面 ARP、ICMP、DHCP、DNS、UDP、TCP 全开,IPv6 没开。TCP_MSS=1460,TCP_WND=8760(6×MSS),TCP_SND_BUF=17520(2×WND)。Socket 和 netconn API 都开了,统计和调试关了。
cc.h——平台类型定义
typedef unsigned char u8_t ;
typedef unsigned short u16_t ;
typedef unsigned long u32_t ;
#define BYTE_ORDER LITTLE_ENDIAN
#define LWIP_PLATFORM_DIAG(x) (DbgPrint x)
#define LWIP_PLATFORM_ASSERT(x) DbgPrint("ASSERT: %s\n" , x); DbgBreakPoint()
断言失败时 DbgBreakPoint 触发断点,配合 DbgView 调试非常方便。
sys_arch.h——系统抽象结构
定义了信号量(KEVENT)、邮箱(链表+自旋锁+事件)、保护级别(KIRQL)、线程句柄(u32_t)、以及自定义自旋锁。
perf.h, bpstruct.h, epstruct.h
perf.h 是空的性能测量桩。bpstruct.h/epstruct.h 成对使用,等价于 #pragma pack(push,1)/pack(pop),保证网络协议头结构体的内存布局和线缆上的字节流一致。
构建与部署
用 EWDK 的 sources 文件配置构建。链接 ndis.lib 和 ntoskrnl.lib,源文件包括移植层(lwip_driver.c、sys_arch.c 等)和 lwIP 核心(tcp.c、udp.c 等)。构建命令build -cZg输出 lwip_demo.sys。
安装用 sc create 注册为内核驱动,sc start 启动,sc stop 停止。调试输出用 DebugView 查看。 注意,这里是针对于windows 7的工具链而言,请不要在现代Windows10/11上运行
请记住是Windows 7 的EWDK,而非普通WDK 编译:msbuild lwip_demo.vcxproj /p:configuration=Release /p:platform=x64
风险与限制
当然,这个项目也有一些潜在的风险和限制。
首先也是最关键的,Kernel PatchGuard 兼容性问题。在 x64 Windows 10/11 上修改内核代码段会触发 CRITICAL_STRUCTURE_CORRUPTION 蓝屏。这个问题在目前的技术框架下无解,只能靠禁用 PatchGuard 或者用其他方式绕。
其次是全局自旋锁瓶颈。sys_arch.c 用了一个全局自旋锁保护临界区,多核高负载下可能成为瓶颈。
还有就是 NDIS 内部结构的版本依赖。ndisx.h 是逆向 Windows 7 得到的,新版本的 NDIS 结构偏移可能变了,需要重新逆向适配。
最后,单线程 tcpip_thread 和无 IPv6 支持也是需要考虑的限制。
总结
lwip_nt_minimal 展示了怎么把嵌入式 TCP/IP 栈移植到 Windows 内核里,怎么通过 API 挂钩拦截 NDIS 子系统,怎么在内核态做 DMA 数据传输,怎么逆向 Windows 的未文档化结构。这些技术组合在一起,实现了一个完整的、能通过物理网卡收发数据的内核态 TCP/IP 协议栈。
最有意思的是,我通过这个发出了一个ICMP Ping包,而wireshark完全没有反应
对于做驱动开发、安全研究或者内核网络编程的人来说,这个项目的代码是非常好的学习材料。 我还在研究这个项目如何适配windows 10/11,这是一个很难的项目
欢迎提出意见,附件是移植源代码
冰与火的战歌:Windows内核攻防实战高级班!从零到实战,融合AI与Windows内核攻防全技术栈,打造具备自动化能力的内核开发高手。
上传的附件: