
第5篇真的读到一个包了 —— WinPkFilter 绑卡与抓包实战一、第一次偷包的感觉我第一次从 NDIS 驱动里读到一个真实的数据包时已经在电脑前坐了整整一天。驱动装好了适配器枚举成功了Tunnel 模式设好了IOCTL 发出去了——但返回的 buffer 里全是零。又搞了两个小时终于发现是ETH_M_REQUEST的大小算错了EthPacket数组的起始地址偏移了 8 个字节。修正之后buffer 里终于出现了数据。我盯着那一串十六进制数字看了五分钟。45 00 00 3c 1c 46 40 00 40 06 ...——这是某个程序发出的一个 TCP SYN 包。它刚刚从网卡上被抓下来还没到达 TCP/IP 协议栈就被我截获了。如果你在中学时代拆过收音机大概能理解我当时的感受。一台正常运转的设备你打开了它的外壳看到了里面正在流动的电流——你没有破坏任何东西你只是偷看了一眼。程序员的快乐大多是这一类的。二、开工三步加载驱动、找网卡、设模式在开始读包之前有三件事必须做对。少做一件后面的全白费。第一步确认驱动在运行WinPkFilter SDK 的CNdisApi类封装了这个过程。核心操作非常直白CNdisApi api;if(!api.IsDriverLoaded()){// 驱动没在跑要么没装要么被卸载了// 大部分情况下需要以管理员权限手动安装}IsDriverLoaded()的底层其实就是尝试用CreateFile打开\\.\NDISRD这个设备文件。这是 Windows 驱动编程的惯例——每个驱动都会注册一个设备名用户态程序通过这个设备名跟驱动建立通信通道。如果你的程序跑不起来大概率是这一步就挂了。常见原因驱动未签名Windows 10/11 需要禁用驱动签名强制、杀毒软件拦截、或者之前装过不兼容的老版本。第二步获取适配器列表你的 Windows 电脑上可能有很多网卡比你想象的要多物理以太网卡比如 Intel I219-VWiFi 无线网卡比如 Intel AX200蓝牙生成的虚拟网卡Hyper-V 虚拟交换机VirtualBox Host-Only 网卡WSL 的虚拟网卡各种 VPN 客户端创建的 TAP 适配器WinPkFilter 提供了一个 IOCTLIOCTL_NDISRD_GET_TCPIP_INTERFACES返回所有当前绑定了 TCP/IP 协议的网卡。注意这个条件——只返回绑定了 TCP/IP的网卡。如果一个网卡没有绑定 TCP/IP比如一个纯 VPN 的 TAP 适配器没配 IP它就出现在列表里。返回的数据结构是TCP_AdapterListtypedefstruct_TCP_AdapterList{DWORD m_nAdapterCount;// 几个网卡UCHAR m_szAdapterNameList[32][256];// 网卡名GUID 格式HANDLE m_nAdapterHandle[32];// 网卡句柄后面操作全靠它UINT m_nAdapterMediumList[32];// 介质类型以太网/WiFi/...UCHAR m_czCurrentAddress[32][6];// 当前 MAC 地址USHORT m_usMTU[32];// MTU}TCP_AdapterList;拿到这个列表后你要选择一个正确的网卡来绑定。在 WiFi 模式下我们要找的是 WiFi Direct 虚拟适配器一个 GUID 以特定方式命名的网卡在有线模式下我们要找一个物理以太网适配器。这个选择逻辑在adapter.cpp的find_best_wifi_direct_adapter()和get_ethernet_adapters()里。选错了网卡会怎样你会抓到一堆跟你的热点完全无关的包手机永远拿不到 IPNAT 永远对不上。这是一个不会报错但完全不能工作的 bug非常难排查。第三步设置 Tunnel 模式拿到了网卡句柄之后需要告诉 WinPkFilter“对这个网卡我要接管它的一切流量。”WinPkFilter 支持多种模式Listen 模式拦截包并复制一份给我们但原包继续正常流转。适合流量监控场景。Tunnel 模式拦截包并阻止它继续流转我们决定这个包的命运——放行、修改、丢弃、或者自己回应。透明代理必须用 Tunnel 模式。因为如果你只是听TCP SYN 包而不阻止它Windows 协议栈也会听到这个 SYN 包发现目标端口没人监听然后发一个 RST 回去——连接就断了。设置 Tunnel 模式的 IOCTL 是IOCTL_NDISRD_SET_ADAPTER_MODE参数是一个ADAPTER_MODE结构体ADAPTER_MODE mode;mode.hAdapterHandleadapter_handle;// 网卡句柄mode.dwFlagsMSTCP_FLAG_SENT_TUNNEL// 拦截发出的包上行|MSTCP_FLAG_RECV_TUNNEL;// 拦截收到的包下行api.SetAdapterMode(mode);两个 Flag 同时设置意味着无论进来的还是出去的先过我这关。设置成功之后这个网卡就跟 Windows 的 TCP/IP 协议栈断开了——Windows 不再直接处理这个网卡的包。接管生效。三、批量读包一次 IOCTL 拉 512 个驱动模式设好了包就开始被拦截了。现在的问题是怎么把它们从内核态取出来WinPkFilter 提供了IOCTL_NDISRD_READ_PACKETS注意是复数用于批量读取。在发 IOCTL 之前需要准备好两样东西第一缓冲区数组。每个包需要一个INTERMEDIATE_BUFFER来存放。在我们的系统里这些缓冲区来自PacketPool——一个预分配了 32768 个 buffer 的对象池。IOCTL 会把你提供的空 buffer 填满数据。第二ETH_M_REQUEST请求结构体。它告诉驱动我要读几个包以及buffer 在哪// 伪代码准备批量读请求PETH_M_REQUEST requestmalloc(sizeof(ETH_M_REQUEST)511*sizeof(NDISRD_ETH_Packet));request-hAdapterHandleadapter_handle;request-dwPacketsNumber512;// 我要 512 个// 把 512 个空 buffer 的地址填进去for(inti0;i512;i){request-EthPacket[i].BufferPacketPool::instance().acquire();// 拿一个空 buffer}// 发 IOCTLDeviceIoControl(driver_handle,IOCTL_NDISRD_READ_PACKETS,request,request_size,// 输入我要用这些 buffer 收包request,request_size,// 输出驱动把数据填进相同的 bufferbytes_returned,NULL);// request-dwPacketsSuccess 告诉你实际读到了几个包几个细节值得注意输入和输出用的是同一块内存。这是 IOCTLMETHOD_BUFFERED的标准做法。驱动的输入 buffer 和输出 buffer 指的是用户态的同一块内存。你填好我要 512 个 buffer驱动收到后把数据写进你提供的 buffer然后返回。实际读到的包数可能远小于 512。如果当前没有多少包在排队dwPacketsSuccess可能是 0、10、50……取决于当前流量。在网络空闲的时候可能连续好几次 IOCTL 都返回 0 个包——这是正常的。ETH_M_REQUEST是一个变长结构体。EthPacket数组的大小在编译时不确定——看你传的dwPacketsNumber是多少。malloc的时候需要手动计算大小sizeof(ETH_M_REQUEST) (N-1) * sizeof(NDISRD_ETH_Packet)。算错了就是 buffer overflow 或者读到垃圾数据。在我们的实际实现中reader loop 大致是这样的voidreader_loop(){while(m_running){// 从对象池拿 512 个空 bufferRawPacket*bufs[512];size_t countPacketPool::instance().acquire_batch(bufs,512);// 填进请求for(size_t i0;icount;i)m_read_request-EthPacket[i].Bufferbufs[i];m_read_request-dwPacketsNumber(UINT)count;// 发 IOCTLDWORD returned;DeviceIoControl(...,IOCTL_NDISRD_READ_PACKETS,...);// 把读到的包分发到 Worker Queuefor(UINT i0;im_read_request-dwPacketsSuccess;i){auto*bufm_read_request-EthPacket[i].Buffer;// 按 hash(client_ip ^ client_port) 选一个 workeruint32_tworker_idget_target_worker(buf,...);m_worker_queues[worker_id]-push(buf);}}}这个 reader loop 跑在一个独立的线程里不干别的就是读包→分发给 worker。为什么不在 reader thread 里直接处理因为处理一个包可能要做 DNS 查询、GeoIP 查表、SOCKS5 握手——这些都是耗时操作。如果在 reader thread 里做IOCTL 的间隔就拉长了内核态的包队列就会堆积。所以 reader 只做一件事尽快把包从内核态捞出来。四、把包放回去——两种姿势读到的包有两种命运被消费我们处理了不需要放回去或者被透传我们处理不了或者不需要处理原样放回。放回去也有两个方向send_to_mstcp注入协议栈假装这个包是从网线上正常收到的交给 Windows 的 TCP/IP 协议栈处理。通常用于我们处理完下行流量后把结果喂给系统。send_to_adapter注入网卡假装这个包是 TCP/IP 协议栈正常发出去了通过网卡发送。通常用于我们构造的上行响应包比如伪造的 SYN-ACK。用错了方向会发生什么举个例子你拦截了一个手机发来的 DHCP DISCOVER这是一个上行广播包你的 DHCP 服务器构造了 OFFER 响应。这个响应应该用send_to_mstcp假装是从网线来的发给手机。如果你用了send_to_adapter假装是协议栈发出去的这个包可能被发到上游网络而不是回到手机——手机永远收不到 OFFER一直 DISCOVER直到超时。反过来如果你拦截了一个手机的 TCP SYN上行包你想伪造一个 SYN-ACK 返回给手机。这个 SYN-ACK 应该用send_to_mstcp注入——让手机以为是从目标服务器回来的。如果用send_to_adapter它会被当成一个我们主动发起的出站连接Windows 协议栈会尝试处理它然后因为各种原因丢弃。简单记忆法要让对方手机收到→send_to_mstcp模拟从网线进来的要让我们自己上网DNS 查询、SOCKS5 连接→ 正常走 socket不需要用 WinPkFilter五、一个真实的包逐字节解剖终于到了最激动人心的部分。你刚从驱动里读到了一个包。在INTERMEDIATE_BUFFER的m_IBuffer里躺着这么一串十六进制ff ff ff ff ff ff 12 34 56 78 9a bc 08 06 00 01 08 00 06 04 00 01 12 34 56 78 9a bc c0 a8 89 01 00 00 00 00 00 00 c0 a8 89 64这四十几个字节翻译成人话就是“谁的 IP 是 192.168.137.100告诉你邻居 192.168.137.1你的 MAC 地址是什么”——这是一个 ARP 请求。我们来逐字节拆解以太网头14 字节ff ff ff ff ff ff ← 目标 MAC全 F广播 12 34 56 78 9a bc ← 源 MAC发出请求的那台设备的 MAC 08 06 ← EtherType0x0806 ARPARP 载荷28 字节00 01 ← 硬件类型1 以太网 08 00 ← 协议类型0x0800 IPv4 06 ← 硬件地址长度6 字节MAC 04 ← 协议地址长度4 字节IPv4 00 01 ← 操作码1 ARP Request问 12 34 56 78 9a bc ← 发送者 MAC c0 a8 89 01 ← 发送者 IP192.168.137.1 00 00 00 00 00 00 ← 目标 MAC全零我不知道 c0 a8 89 64 ← 目标 IP192.168.137.100我在找谁这个包说明了什么有一台设备192.168.137.1也就是网关在问192.168.137.100 是谁告诉我 MAC 地址。这通常发生在新设备连上 WiFi 之后网关需要知道新设备的 MAC 地址才能把包发给它。一个 TCP SYN 包则长这样更复杂一些12 34 56 78 9a bc aa bb cc dd ee ff 08 00 ← 以太网头 45 00 00 3c ... ← IP 头20 字节 ... ← TCP 头20 字节IP 头的前几个字节45Version4IPv4Header Length5×420 字节没有选项00TOS/DSCP0普通优先级00 3c总长度60 字节20 IP 20 TCP 20 TCP Options后面还有 Identification、Flags、TTL、Protocol、Checksum、SrcIP、DstIPTCP 头的关键字段SrcPort、DstPort谁在跟谁说话SeqNum序列号TCP 的核心FlagsSYN发起连接、ACK确认、FIN结束、RST重置WinSize接收窗口大小在winpkfilter_driver.cpp里你可以看到我们定义了完整的IP_HEADER、TCP_HEADER、UDP_HEADER、ARP_HEADER结构体。这些定义用了#pragma pack(1)—— 确保编译器不插入任何对齐填充因为网络数据包是紧凑排列的。六、一个常见的坑Protocol 判断拿到一个以太网帧后第一件事是判断它是什么协议。代码里通常这样写uint16_tether_typentohs(*(uint16_t*)(packet12));if(ether_type0x0800)// IPv4handle_ipv4(packet);elseif(ether_type0x0806)// ARPhandle_arp(packet);elseif(ether_type0x86DD)// IPv6handle_ipv6(packet);这里有一个很容易忘记的细节ntohsNetwork to Host Short。以太网和 IP 协议都是大端big-endian而 x86 CPU 是小端little-endian。0x0800在网络上是大端的08 00两个字节在 x86 内存里是00 08。如果你忘了ntohs直接跟0x0800比较永远不会匹配——所有的包都会被当成未知协议跳过。同样的陷阱存在于 IP 头的端口字段、长度字段、校验和字段。网络编程的第一课任何来自或发往网络的多字节数值都要做字节序转换。忘了这点的代码跑在 x86 上会静默地产生错误结果跑在大端机器上反而正确——这就是为什么这个 bug 很难被发现你大概率没在大端机器上测试过。七、下一篇现在你知道了怎么从驱动里读到一个包也看到了一个真实的 ARP 请求长什么样。下一篇我们系统地解剖以太网帧、IP 头和 TCP/UDP 头——但不是看书本上的那套图示而是直接对着 C 的结构体定义讲讲完你就能自己写一个包解析器。下一篇叫《一个以太网帧的解剖课》我们会看到 IP 头里的 TTL 为什么是 64 而不是 128TCP 的 SYN 标志位为什么是 0x02以及一个协议栈读了半天的包为什么在我们的程序里只需要 3 次指针强转就能解析完。本文是《从0到1编写一个硬核软路由》系列的第五篇。上一篇第4篇NDIS驱动是什么鬼 | 下一篇第6篇一个以太网帧的解剖课