ARTICLE DETAIL

资讯详情

深耕网站建设、视觉设计与SEO优化的一线实战洞察。

从零实现基于C语言与WinPcap的网络Sniffer抓包工具

从零实现基于C语言与WinPcap的网络Sniffer抓包工具 简介面向网络课程设计与协议分析初学者的C语言Sniffer完整项目基于WinPcap与MFC实现可在混杂模式下监听指定网卡捕获数据包并利用WinPcap过滤规则进行筛选同时按TCP、UDP、ARP、ICMP、HTTP、IPv4、IPv6等协议格式解析包头与数据以可读方式展示。资源还支持将捕获结果保存为本地文件并重新读取适合网络协议学习、课程设计或毕业设计直接参考。项目包含完整的WinPcap调用流程与MFC界面框架对数据包解析模块进行了分层封装便于理解各协议头部结构与字段含义。压缩包共63个文件包含C源码、头文件、Visual Studio工程配置、实验报告、可执行程序及说明文档整体约6.54MB结构较为完整便于编译调试与二次开发。目前已有464人学习下载可作为理解网络抓包原理与WinPcap/MFC结合开发的实用案例。1. 为什么在Wireshark普及之后还要自己写一个SnifferWireshark确实能解决大多数抓包问题但当你需要把抓包能力嵌进自己的工具或者要分析一种私有协议时总不能要求使用者先打开Wireshark再手动导出。这个课程设计项目用C语言把WinPcap的底层抓包和MFC的界面结合实现了一个最小可用的网络Sniffer——从网卡枚举、混杂模式监听、数据包捕获到TCP/IP/HTTP解析和文件保存整条链路都可以在Visual Studio里编译、断点、看到中间变量。对想搞懂网络通信协议和网络协议分析的人来说亲手拆一遍比翻十遍TCP/IP详解都有用。这个工程虽然打着课程设计的名头但里面的WinPcap调用方式、协议解析结构体和线程模型可以直接搬到真实工具里。2. WinPcap捕获流程与网卡枚举的核心API2.1 为什么选择WinPcap而不是raw socketWindows下的原始套接字只能收到TCP/IP层以上的报文拿不到以太网帧头也没法直接进入混杂模式。WinPcap在驱动层做网卡数据拷贝可以拿到完整的数据链路层帧同时允许挂接BPF过滤器。虽然WinPcap官方已停止更新但很多实验环境和课程设计仍然默认使用它新环境可以安装Npcap并勾选WinPcap兼容模式。这个项目里保持WinPcap调用方式因为实验指导书、现有代码示例和错误排查经验都基于这套API改到Npcap原生接口反而会增加无谓的适配成本。2.2 枚举网卡并获取设备列表用pcap_findalldevs_ex枚举所有网卡第一个参数PCAP_SRC_IF_STRING表示枚举本机接口第二个参数传NULL出错时函数返回-1并把错误信息写入errbuf。返回的链表中每个pcap_if_t节点包含name、description和addresses。name是系统内部设备名形如\Device\NPF_{...}description才是人可读的网卡描述。#include pcap.h #include stdio.h void list_devices() { pcap_if_t *alldevs NULL; pcap_if_t *dev NULL; char errbuf[PCAP_ERRBUF_SIZE] {0}; int index 0; if (pcap_findalldevs_ex(PCAP_SRC_IF_STRING, NULL, alldevs, errbuf) -1) { fprintf(stderr, pcap_findalldevs_ex error: %s\n, errbuf); return; } for (dev alldevs; dev; dev dev-next) { printf(%d. name: %s\n, index, dev-name); if (dev-description) { printf( description: %s\n, dev-description); } } pcap_freealldevs(alldevs); }遍历链表用dev-next循环结束后必须调用pcap_freealldevs释放设备列表否则每次打开程序都泄漏一块内存。这里只打印了name和description没有处理addresses链表实际选择网卡时可以结合IPv4地址来判断哪块网卡是当前正在使用的。2.3 打开设备并进入混杂模式pcap_open_live负责打开设备并获得pcap_t句柄。参数依次是设备名、抓包长度上限snaplen、是否启用混杂模式promisc、超时毫秒数to_ms、错误缓冲区。snaplen设为65536时能容纳最大以太网帧promisc设为1后网卡会接收目标MAC不是自己的帧这正是Sniffer能监听局域网流量的基础。pcap_t *open_device(const char *dev_name) { char errbuf[PCAP_ERRBUF_SIZE] {0}; pcap_t *handle pcap_open_live(dev_name, 65536, 1, 1000, errbuf); if (handle NULL) { fprintf(stderr, open device failed: %s\n, errbuf); return NULL; } return handle; }打开失败最常见的原因是权限不足WinPcap要求进程至少以管理员身份运行。另一个坑是设备名被重复打开比如Wireshark正在抓同一个网卡时本程序可能打不开需要先停掉其他抓包工具。超时1000毫秒是为了让pcap_next_ex在无流量时能定时返回避免捕获线程卡死。2.4 编译并设置BPF过滤规则WinPcap可以在驱动层完成过滤不用把所有包都送到用户态程序里比先抓进来再丢弃高效很多。pcap_compile把文本过滤条件编译成内部的bpf_program结构然后pcap_setfilter把过滤程序挂到pcap_t上。下面的例子只保留TCP和UDP包。struct bpf_program fcode; if (pcap_compile(handle, fcode, tcp or udp, 1, PCAP_NETMASK_UNKNOWN) 0) { fprintf(stderr, compile filter error: %s\n, pcap_geterr(handle)); return -1; } if (pcap_setfilter(handle, fcode) 0) { fprintf(stderr, set filter error: %s\n, pcap_geterr(handle)); return -1; }第二个参数optimize填1表示做逻辑优化对小型过滤表达式影响不大。第四个参数netmask只在过滤条件里用到host或net时才有意义否则直接填PCAP_NETMASK_UNKNOWN。过滤语法是tcpdump的BPF语法和Wireshark的显示过滤器不完全相同比如“host 192.168.1.1 and port 80”这种组合可以直接使用而“tcp.port 80”是Wireshark语法在这里会编译失败。2.5 关键函数参数速查函数成功返回值失败时错误获取方式常用参数要点pcap_findalldevs_ex0errbufPCAP_SRC_IF_STRINGpcap_open_livepcap_t指针errbufpromisc1启用混杂pcap_compile0pcap_geterr(handle)netmask可填PCAP_NETMASK_UNKNOWNpcap_setfilter0pcap_geterr(handle)必须在compile之后调用pcap_next_ex1为成功抓包0为超时pcap_geterr(handle)超时不是错误pcap_close无返回值无释放pcap_t句柄pcap_next_ex的返回值判断很关键。正确写法是ret 1才算拿包ret 0表示超时需要继续循环ret -1才是驱动错误。如果只判断“不等于0就处理”超时也会被当成一个包列表里会出现大量空数据这是新手最容易踩的坑。3. 数据包捕获循环与协议解析的模块化设计3.1 捕获线程的结构MFC的UI线程不能做阻塞式抓包否则窗口会失去响应。项目里单独开一个工作线程循环调用pcap_next_ex取包然后通过PostMessage通知对话框刷新。捕获线程的退出标记用volatile BOOL控制不要用TerminateThread强行结束线程那会导致pcap句柄无法释放。static volatile BOOL g_stopCapture FALSE; UINT CaptureThread(LPVOID param) { pcap_t *handle (pcap_t *)param; struct pcap_pkthdr *header NULL; const u_char *pkt_data NULL; int ret 0; while (!g_stopCapture) { ret pcap_next_ex(handle, header, pkt_data); if (ret 1) { UCHAR *copy (UCHAR *)malloc(header-caplen); memcpy(copy, pkt_data, header-caplen); ::PostMessage(g_hWnd, WM_CAPTURE_PACKET, header-caplen, (LPARAM)copy); } else if (ret -1) { break; } } return 0; }两次pcap_next_ex调用之间pkt_data指向的内部缓冲区可能会被下一次调用覆盖所以PostMessage前必须memcpy一份。UI线程收到WM_CAPTURE_PACKET后解析并显示再free掉这份拷贝。把抓包线程和UI解析线程分开比在捕获回调里做耗时格式化要稳得多。3.2 以太网帧头与类型判断抓包缓冲区里最前面是以太网帧头固定14字节目的MAC 6字节、源MAC 6字节、类型2字节。类型字段是大端序x86上需要经过ntohs转换。0x0800是IPv40x86DD是IPv60x0806是ARP。解析用的结构体必须按1字节对齐。#pragma pack(push, 1) typedef struct _ETH_HEADER { UCHAR dst_mac[6]; UCHAR src_mac[6]; USHORT ether_type; } ETH_HEADER; #pragma pack(pop) // 使用示例 ETH_HEADER *eth (ETH_HEADER *)pkt_data; USHORT type ntohs(eth-ether_type);#pragma pack(push,1)让结构体紧密排列。如果不加编译器可能在src_mac之后补2字节使ether_type偏移变成14而不是12解析就会错位。这是所有网络解析代码里最常见的低级错误。3.3 IPv4与IPv6头解析IPv4头在以太网头之后最小长度20字节通过ihl字段计算实际长度。ihl是4位表示头部占用多少个4字节最小值5对应20字节。协议字段决定上层是TCP(6)、UDP(17)还是ICMP(1)。源地址和目的地址在IP头的第12和第16字节处。以太网类型含义上层解析起点0x0800IPv4以太网头后偏移ihl*40x86DDIPv6以太网头后偏移40字节0x0806ARP以太网头后偏移28字节IPv6的固定头部为40字节next header字段在偏移6处作用类似IPv4的protocol字段但头部长度固定不需要ihl计算。ARP报文则直接在以太网头之后固定28字节。typedef struct _IPV4_HEADER { UCHAR ver_ihl; // 高4位版本低4位头部长度 UCHAR tos; USHORT total_len; USHORT id; USHORT frag_offset; UCHAR ttl; UCHAR protocol; USHORT checksum; UCHAR src_ip[4]; UCHAR dst_ip[4]; } IPV4_HEADER; void parse_ipv4(const UCHAR *pkt, const UCHAR **payload, int *payload_len) { IPV4_HEADER *ip (IPV4_HEADER *)(pkt 14); int ihl (ip-ver_ihl 0x0F) * 4; *payload pkt 14 ihl; *payload_len ntohs(ip-total_len) - ihl; }这里用ver_ihl一个字节保存版本和头部长度比位域结构更安全。位域在不同编译器、不同端序下拆出来的值可能不一致直接读字节再手工拆是跨平台更稳的写法。3.4 TCP/UDP/ICMP的解析思路传输层的解析以IP payload为起点。TCP头至少20字节源端口和目的端口在偏移0和2UDP固定8字节头端口位置相同ICMP没有端口类型和代码在偏移0和1。解析TCP时还需要读取数据偏移字段才能算出TCP头长度和数据段的起点。int parse_transport(const UCHAR *ip_payload, UCHAR proto, char *out, int out_len) { USHORT src_port ntohs(*(USHORT *)(ip_payload)); USHORT dst_port ntohs(*(USHORT *)(ip_payload 2)); if (proto 6) { snprintf(out, out_len, TCP %d - %d, src_port, dst_port); } else if (proto 17) { snprintf(out, out_len, UDP %d - %d, src_port, dst_port); } else if (proto 1) { snprintf(out, out_len, ICMP type%d code%d, ip_payload[0], ip_payload[1]); } return 0; }TCP和UDP的端口字段是相同的区别在于TCP头更长且需要处理flags、序列号等信息。ICMP的type和code是单个字节直接用下标访问即可。3.5 HTTP应用层识别HTTP跑在TCP之上不能只靠80端口判断否则其他应用占用80端口时会误判。一般额外检查TCP payload中是否出现GET 、POST 和HTTP/1.。命中后在显示栏标出HTTP方法、URI和状态码。注意HTTP请求可能被IP分片或TCP分段单包内未必包含完整请求所以这是启发式识别而不做跨包重组。如果想做完整重组需要维护TCP流状态这已经超出课程设计范畴。3.6 协议解析的整合代码入口函数根据以太网类型、IP协议字段逐层下沉输出一行可读文本。由于C语言没有string类型输出缓冲区必须预分配并限制长度我用snprintf防止越界。int parse_packet_for_display(const UCHAR *pkt, char *out, int out_len) { ETH_HEADER *eth (ETH_HEADER *)pkt; USHORT type ntohs(eth-ether_type); switch (type) { case 0x0800: { IPV4_HEADER *ip (IPV4_HEADER *)(pkt 14); int ihl (ip-ver_ihl 0x0F) * 4; const UCHAR *payload pkt 14 ihl; if (ip-protocol 6) { snprintf(out, out_len, TCP %u.%u.%u.%u:%d - %u.%u.%u.%u:%d, ip-src_ip[0], ip-src_ip[1], ip-src_ip[2], ip-src_ip[3], ntohs(*(USHORT *)payload), ip-dst_ip[0], ip-dst_ip[1], ip-dst_ip[2], ip-dst_ip[3], ntohs(*(USHORT *)(payload 2))); return 1; } break; } case 0x0806: snprintf(out, out_len, ARP); return 1; case 0x86DD: snprintf(out, out_len, IPv6); return 1; } snprintf(out, out_len, Unknown); return 0; }上述代码把IP头长度计算放在前面再用payload指针访问传输层端口避免硬编码偏移。硬编码虽然在小实验里能跑通但一旦IPv4头带选项偏移就会错乱所以这里优先采用动态偏移。4. MFC界面刷新与抓包文件的保存和读取4.1 用PostMessage驱动列表刷新MFC的对话框里通常放一个CListCtrl列设置为时间、协议、源地址、目的地址、长度。捕获线程PostMessage发送自定义消息对话框成员函数里执行协议解析并InsertItem。消息参数里携带的指针在UI线程处理完毕后释放避免内存泄漏。LRESULT CDlgSniffer::OnCapturePacket(WPARAM wParam, LPARAM lParam) { ULONG caplen (ULONG)wParam; const UCHAR *pkt (const UCHAR *)lParam; char text[256] {0}; parse_packet_for_display(pkt, text, sizeof(text)); int row m_list.GetItemCount(); m_list.InsertItem(row, text); // 时间、长度等列自行填充 free((void *)pkt); return 0; }如果每秒抓到的包超过几千个逐条InsertItem会让界面滚动卡顿。常见的缓解办法是每10个包批量插入一次或者只显示前1000条之后的包只更新统计计数。课程设计里逐条插入能更直观看到效果但在真实抓包工具里这是必须优化的问题。4.2 自定义抓包文件格式WinPcap自带pcap_dump_write可以写标准pcap文件但很多课程设计为了展示读写文件的能力会自己定义简单格式。我这里用一个最小的二进制格式文件头8字节包含2字节魔数SN、2字节版本号、4字节保留字段每个数据包记录依次是时间戳秒、捕获长度、原始长度和原始数据。typedef struct _SNIFFER_PKT_RECORD { ULONG timestamp; // Unix秒 ULONG caplen; // 实际存储的字节数 ULONG origlen; // 原始包长度 } SNIFFER_PKT_RECORD;写入时千万不要直接fwrite整个结构体因为编译器可能在timestamp之后填充字节导致文件格式在不同编译环境下不一致。逐字段写入可以固定文件的字节布局让别人写读取器时不需要关心你的编译选项。BOOL save_packet(FILE *fp, struct pcap_pkthdr *header, const UCHAR *pkt_data) { SNIFFER_PKT_RECORD rec; rec.timestamp (ULONG)header-ts.tv_sec; rec.caplen header-caplen; rec.origlen header-len; if (fwrite(rec.timestamp, 4, 1, fp) ! 1) return FALSE; if (fwrite(rec.caplen, 4, 1, fp) ! 1) return FALSE; if (fwrite(rec.origlen, 4, 1, fp) ! 1) return FALSE; if (fwrite(pkt_data, header-caplen, 1, fp) ! 1) return FALSE; return TRUE; }文件头的写入也按同样思路魔数可以单独用两个字节写。读取时先校验魔数再按记录循环读取。这个格式没有结束标志读取循环直到fread返回0为止。4.3 读取文件并回显列表读取是写入的逆过程。先读文件头再循环读12字节记录信息根据caplen读取后续数据然后复用协议解析函数生成显示文本。这样保存和读取共用一套解析逻辑避免两处维护。void load_sniffer_file(const char *filepath) { FILE *fp fopen(filepath, rb); if (!fp) return; char magic[2]; fread(magic, 2, 1, fp); if (magic[0] ! S || magic[1] ! N) { fclose(fp); return; } // 跳过版本和保留字段 fseek(fp, 6, SEEK_CUR); UCHAR *buf (UCHAR *)malloc(65536); while (1) { SNIFFER_PKT_RECORD rec; if (fread(rec.timestamp, 4, 1, fp) ! 1) break; fread(rec.caplen, 4, 1, fp); fread(rec.origlen, 4, 1, fp); if (rec.caplen 65536) break; // 防御异常数据 fread(buf, rec.caplen, 1, fp); char text[256] {0}; parse_packet_for_display(buf, text, sizeof(text)); // 插入列表 } free(buf); fclose(fp); }这里复用一块65536字节的缓冲区避免循环里反复malloc和free。caplen本身来自文件内容如果文件损坏或被人为修改可能出现超过缓冲区的长度所以必须加边界检查。4.4 抓包时实时存储的注意事项如果要在抓包的同时保存文件不要把文件I/O放在抓包线程里。pcap_next_ex从驱动拿包的速度很快而fwrite涉及磁盘操作两者混在一起会造成驱动缓冲区溢出丢包。常见的做法是先把包写进内存队列由单独写盘线程取数据落盘或者积攒到一定量再批量写入。课程设计里可以每包fwrite但长时间运行时一定会遇到丢包问题。4.5 C语言文件操作的常见坑fopen打开二进制文件时必须用wb或rb。如果写成w或rWindows会把0x1A当作文本结束符导致文件在某个字节处截断回读时数据缺失。这个错误在抓包文件里很隐蔽因为平时保存文本文件看不出来只有解析二进制数据时才会突然少了一段。5. 验证、排错与一个进阶导出技巧5.1 用环回流量验证基本功能程序写完先不要急着抓局域网流量用ping命令做最小验证。打开Sniffer选择本机正在使用的网卡过滤条件留空然后在cmd执行ping 127.0.0.1。正常情况下列表里应该出现ICMP包。如果没有先确认过滤条件里没有误填host 127.0.0.1之外的限制再检查是否选中了错误的网卡。5.2 常见启动问题排错现象原因处理pcap_findalldevs_ex返回-1未安装WinPcap/Npcap驱动以管理员身份安装后重启枚举到0个网卡驱动服务未启动管理员执行net start npf打开设备失败当前进程不是管理员以管理员身份运行exe只捕获到本机收发包交换机隔离了局域网广播用交换机镜像端口或抓本机流量程序运行秒退缺少wpcap.dll把WinPcap的DLL放到exe目录WinPcap安装在Windows 10 1809以后的部分版本上会出现驱动签名问题如果设备列表为空可以改用Npcap的WinPcap兼容模式。虚拟机里如果使用NAT网络必须先在虚拟网络编辑器里把网卡设为混杂模式允许否则只能抓到本机虚拟网卡的流量。5.3 用pcap_dump直接写标准pcap文件自定义文件格式虽然简单但无法直接用Wireshark打开。WinPcap提供了一套标准pcap写入接口代码量更少互通性更好。pcap_dumper_t *dumper pcap_dump_open(handle, capture.pcap); // 捕获循环里 pcap_dump((u_char *)dumper, header, pkt_data); pcap_dump_flush(dumper); // 结束 pcap_dump_close(dumper);pcap_dump_open的参数是已经打开的pcap_t这样写入的文件会自动带上正确的链路类型。pcap_dump函数接受dump句柄、包信息和数据指针内部会处理好文件头和时间戳格式。写完后直接用Wireshark打开可以节省大量格式调试时间。5.4 进阶技巧增加协议统计而不增加列表压力实时抓包时如果网络流量很大把每个包都插入列表除了卡顿之外没有太多意义。更实用的做法是维护一个按协议分组的统计数组每隔一秒刷新一次统计栏只有需要详细查看时再按过滤条件显示具体包。统计数组会被抓包线程和UI定时器同时访问可以使用InterlockedIncrement做原子自增避免多核下计数丢失。5.5 捕获线程的优雅退出停止抓包不能直接pcap_close否则线程还在pcap_next_ex里访问已经释放的句柄程序会崩溃。正确的顺序是置退出标记、调用pcap_breakloop打断阻塞、等待线程结束、再关闭句柄。g_stopCapture TRUE; pcap_breakloop(handle); WaitForSingleObject(hThread, 2000); pcap_close(handle); CloseHandle(hThread);pcap_breakloop会让pcap_next_ex返回0捕获循环检查到退出标记后正常退出。这是WinPcap文档推荐的停止方式比TerminateThread干净得多也是我在调试过程中反复遇到崩溃后总结出来的固定顺序。本文还有配套的精品资源点击获取
返回列表