简介:面向网络课程设计与协议分析初学者的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普及之后还要自己写一个Sniffer
Wireshark确实能解决大多数抓包问题,但当你需要把抓包能力嵌进自己的工具,或者要分析一种私有协议时,总不能要求使用者先打开Wireshark再手动导出。这个课程设计项目用C语言把WinPcap的底层抓包和MFC的界面结合,实现了一个最小可用的网络Sniffer——从网卡枚举、混杂模式监听、数据包捕获到TCP/IP/HTTP解析和文件保存,整条链路都可以在Visual Studio里编译、断点、看到中间变量。对想搞懂网络通信协议和网络协议分析的人来说,亲手拆一遍比翻十遍TCP/IP详解都有用。这个工程虽然打着课程设计的名头,但里面的WinPcap调用方式、协议解析结构体和线程模型,可以直接搬到真实工具里。
2. WinPcap捕获流程与网卡枚举的核心API
2.1 为什么选择WinPcap而不是raw socket
Windows下的原始套接字只能收到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_ex | 0 | errbuf | PCAP_SRC_IF_STRING |
| pcap_open_live | pcap_t指针 | errbuf | promisc=1启用混杂 |
| pcap_compile | 0 | pcap_geterr(handle) | netmask可填PCAP_NETMASK_UNKNOWN |
| pcap_setfilter | 0 | pcap_geterr(handle) | 必须在compile之后调用 |
| pcap_next_ex | 1为成功抓包,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是IPv4,0x86DD是IPv6,0x0806是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字节处。
| 以太网类型 | 含义 | 上层解析起点 |
|---|---|---|
| 0x0800 | IPv4 | 以太网头后偏移ihl*4 |
| 0x86DD | IPv6 | 以太网头后偏移40字节 |
| 0x0806 | ARP | 以太网头后偏移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和2;UDP固定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"或"r",Windows会把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干净得多,也是我在调试过程中反复遇到崩溃后总结出来的固定顺序。
本文还有配套的精品资源,点击获取