首页
社区
课程
招聘
[原创]实现一个EDR不可见的网络通信(将lwip移植到nt内核中)
发表于: 4天前 455

[原创]实现一个EDR不可见的网络通信(将lwip移植到nt内核中)

4天前
455

实现一个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();              // 启动 lwIP 协议栈
    NDIS_InitializeInterface();     // 初始化 NDIS 接口
    ProtocolInitializeSubsystem();  // 创建 Lookaside 列表
    Status = NdisSetHooks();        // 安装 NDIS 挂钩
    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)
{
    //Maybe ciallo is a name better than calloc
    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;  // 以太网要 ARP
        netif->name[0] = 'e'; netif->name[1] = 'n';
        break;
    case NdisMediumWan:
        netif->name[0] = 'w'; netif->name[1] = 'a';  // WAN 用 wa
        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));  // 清除 CR0.WP

    RtlCopyMemory(Addr, Data, Size);

    __writecr0(cr0);                           // 恢复 CR0.WP
    __faststorefence();
    KeInvalidateRangeAllCaches(Addr, Size);
}
static VOID WriteProtected(PVOID Addr, UCHAR *Data, ULONG Size)
{
    ULONG_PTR cr0 = __readcr0();
    __writecr0(cr0 & ~((ULONG_PTR)0x10000));  // 清除 CR0.WP

    RtlCopyMemory(Addr, Data, Size);

    __writecr0(cr0);                           // 恢复 CR0.WP
    __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);  // 先喂给 lwIP

    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;        // DMA 适配器
    PNPAGED_LOOKASIDE_LIST SGListLookasideList; // SG 列表 Lookaside
    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 实现了三级降级策略:

  1. 路径 A(最优):用 Lookaside 列表预分配的 SG 缓冲 + BuildScatterGatherList
  2. 路径 B(次优):不用预分配,让系统分配 SG 列表
  3. 路径 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内核攻防全技术栈,打造具备自动化能力的内核开发高手。

上传的附件:
收藏
免费 2
打赏
分享
最新回复 (7)
雪    币: 5203
活跃值: (5967)
能力值: ( LV4,RANK:50 )
在线值:
发帖
回帖
粉丝
2
七八年前用ndis filter做过类似的东西。。。
4天前
0
雪    币: 3130
活跃值: (2811)
能力值: ( LV6,RANK:80 )
在线值:
发帖
回帖
粉丝
3
fengyunabc 七八年前用ndis filter做过类似的东西。。。
师傅你那个东西的协议处理是自己写的吗,还是像这个用的lwip
4天前
0
雪    币: 5203
活跃值: (5967)
能力值: ( LV4,RANK:50 )
在线值:
发帖
回帖
粉丝
4
TurkeybraNC 师傅你那个东西的协议处理是自己写的吗,还是像这个用的lwip
自己搞的,效果肯定没有lwip好,不过能稳定双向通信。
4天前
0
雪    币: 3081
活跃值: (2794)
能力值: ( LV6,RANK:80 )
在线值:
发帖
回帖
粉丝
5
15小时前
0
雪    币: 6442
活跃值: (8388)
能力值: ( LV2,RANK:10 )
在线值:
发帖
回帖
粉丝
6
lwip  能移植到vt host里面不。
14小时前
0
雪    币: 3130
活跃值: (2811)
能力值: ( LV6,RANK:80 )
在线值:
发帖
回帖
粉丝
7
木志本柯 lwip 能移植到vt host里面不。
建议直接移植freertos到host中,vmm作为freertos的一个高优先级任务处理vm事务,而lwip作为freertos另一个任务跑,以此实现host的独立网络通信,不过网卡驱动有的写了
12小时前
0
雪    币: 6442
活跃值: (8388)
能力值: ( LV2,RANK:10 )
在线值:
发帖
回帖
粉丝
8
TurkeybraNC 建议直接移植freertos到host中,vmm作为freertos的一个高优先级任务处理vm事务,而lwip作为freertos另一个任务跑,以此实现host的独立网络通信,不过网卡驱动有的写了
lwip只是tcp/ip协议栈,还需要编写一个下层的网卡驱动是吧?
6小时前
0
游客
登录 | 注册 方可回帖
返回