简介:这份华中科技大学计算机网络安全实验源码包,面向学习网络协议分析、抓包嗅探与ARP中间人攻击原理的高校学生及安全入门者,提供了基于Winpcap的数据分析器完整实现,可用于课程实验复现、抓包工具开发参考与局域网安全现象验证。包内共27个文件,包含7个cpp源文件、8个头文件及Qt界面文件、工程配置与界面截图,源码与资源文件配合,便于直接编译阅读;压缩包约293KB,体量轻便。目前已有2815人学习,具备一定参考基础。读者可从中获得数据包捕获线程、协议解析、ARP欺骗与发包线程等模块的组织方式,结合主窗口与ARP欺骗对话框的界面代码,理解嗅探器从抓包、解析到攻击演示的完整链路,并借鉴头文件划分与工程结构,为后续网络安全实验和工具开发提供可复用的代码骨架。
1. 网线一插就开始丢包:这套 WinPcap 数据包嗅探实验到底练的是什么
第一次跑这个实验是在一台双网卡的台式机上,程序打印出七八个设备名,我随手选了序号最靠前的那一个就点开始,结果十分钟一个包都没抓到——后来才明白选中的是一块虚拟网卡。这个场景大概能说明这类网络安全实验的真实难点:抓包 API 本身只有五六个函数,难的是搞清楚「数据包从网卡到你的结构体」这条链路上每一层发生了什么。这份实验工程做的事情很朴素:用 WinPcap 打开一块物理网卡,置于混杂模式,按 BPF 表达式过滤后把原始帧交给回调,再逐层剥开以太网头、IP 头、TCP/UDP 头,把 MAC、IP、端口、标志位、载荷长度打印成一行可读记录。它适合正在做课程设计的学生,也适合想给自研工具加一个轻量抓包能力、又不想引入重型依赖的后端开发。
2. 工程结构与开发环境:驱动、SDK、VS 工程三者的链接关系
这套代码在 Windows 上跑,绕不开一个事实:用户态程序碰不到网卡原始帧,中间必须有一层内核驱动把帧搬上来。WinPcap 就是这么个两层结构——底下的驱动负责从 NDIS 挂钩子收帧,上面的wpcap.dll用一套类 libpcap 的接口把它包装成pcap_t句柄。实验代码只跟wpcap.dll打交道,所以工程配置的关键就是让编译器和链接器都能找到这套东西,一处对不上就是一堆 LNK2019。
2.1 驱动安装与 VS 工程的三个链接点
先装驱动再谈编译。免费方案里常见两种选择:一是原版 WinPcap 驱动,二是 Npcap,后者安装时勾选「WinPcap API 兼容模式」,旧代码基本不用改就能跑。装完之后在C:\Windows\System32\Npcap\或C:\Windows\System32\下能看到wpcap.dll和Packet.dll,有这两个文件说明驱动层就位了。
工程侧要做三件事,用代码写死比在 VS 属性页里点选更省事,尤其是换机器、交作业、发同事时不会因为属性丢了而重新翻车:
// sniff.h —— 所有源文件统一包含这个头,避免到处重复链接指令 #include <winsock2.h> // 必须排在 windows.h 之前,否则 winsock 结构冲突 #include <pcap.h> // WinPcap 主头文件 #pragma comment(lib, "wpcap.lib") // 抓包 API #pragma comment(lib, "ws2_32.lib") // inet_ntoa / ntohs 这些要用#pragma comment(lib, ...)的作用等价于在链接器附加依赖项里加库名,好处是写在头文件里跟着源码走。注意winsock2.h一定要在windows.h之前包含,如果哪个第三方头间接拉进了老版winsock.h,你会看到redefinition of struct timeval之类的报错,这是 Windows 网络编程的经典一坑。
剩下两件事是包含路径和平台匹配。头文件目录指向 SDK 的Include,库目录指向Lib;64 位工程就必须配x64目录下的.lib,32 位配x86,混了就是LNK2019: 无法解析的外部符号 pcap_open_live,因为函数名虽然一样,但导出符号的机器字长不匹配。我一般还会在工程属性里把字符集设成「使用多字节字符集」,原因是pcap_findalldevs的描述字段在某些设备上返回的是 ANSI 字符串,走 Unicode 的话打印出来是乱码。
2.2 目录结构与各模块职责
源码规模不大,但分层还算清楚,读的时候按这个顺序走不会迷路:
| 路径 | 职责 |
|---|---|
src/main.c | 程序入口,设备枚举、选择菜单、启动抓包循环 |
src/capture.c | 封装pcap_open_live、过滤器编译、pcap_loop调用 |
src/decode.c | 协议解析:以太网、ARP、IP、TCP、UDP、ICMP |
src/output.c | 格式化打印、写日志文件、简单统计计数 |
include/proto.h | 各层协议头结构体定义,全部 1 字节对齐 |
include/sniff.h | 公共头,库链接指令、常量、函数声明 |
pcap/ | SDK 头文件与库的本地副本,方便打包带走 |
关键设计点是把「抓」和「解」拆开:capture.c只关心句柄生命周期和回调注册,decode.c接收(const u_char*, u_int)这样一对裸指针加长度,完全不认识pcap_t。这个边界划清楚之后,二次开发想换抓包后端(比如换成 Npcap 的pcap_next_ex轮询模式)只需要改capture.c,解析代码一行不动。
全局状态集中在一个结构体里,避免到处散落 extern 变量:
// output.h —— 统计上下文,回调里只增不减地累加 typedef struct { unsigned long total_pkts; // 总帧数 unsigned long ip_pkts; // IPv4 帧数 unsigned long tcp_pkts; // TCP 段数 unsigned long udp_pkts; // UDP 段数 unsigned long arp_pkts; // ARP 帧数 unsigned long long bytes; // 累计字节数 FILE *logfp; // 日志文件句柄,NULL 表示不落盘 } sniff_stat_t;把统计放在结构体里传指针而不是用一堆全局变量,好处是将来要做多网卡并行抓包时,每块网卡一个上下文,互不干扰。这也是很多课程代码里容易被忽略的地方——单网卡跑得好好的,一扩展到双网卡就数字串了。
3. 核心链路拆解:从设备枚举到回调解析的一次完整抓包
抓包主流程只有五步:找设备、开设备、设过滤、进循环、出回调。每一步都有个容易忽略的参数,参数错了程序不报错但行为不对,属于典型的「静默失败」。
3.1 设备枚举与适配器打开
pcap_findalldevs返回一个链表,每个节点代表一块网卡。链表里的name字段是设备标识(形如\Device\NPF_{GUID}),description是人能看懂的名字。注意name才是要传给打开函数的,别传 description。
// main.c —— 枚举网卡并让用户选,选完返回 name 的副本 pcap_if_t *alldevs = NULL, *d = NULL; char errbuf[PCAP_ERRBUF_SIZE] = {0}; if (pcap_findalldevs(&alldevs, errbuf) == -1) { fprintf(stderr, "枚举设备失败: %s\n", errbuf); // 通常是驱动没装 return -1; } int idx = 0; for (d = alldevs; d != NULL; d = d->next) { printf("%d. %s", ++idx, d->name); if (d->description) printf(" [%s]", d->description); printf("\n"); } // 用户输入序号后打开对应设备 pcap_t *adhandle = pcap_open_live( chosen->name, // 设备名 65536, // snaplen:单帧最大抓取字节 1, // promisc:1=混杂模式,收所有经过网卡的帧 1000, // to_ms:读超时,毫秒 errbuf); if (adhandle == NULL) { fprintf(stderr, "打开设备失败: %s\n", errbuf); pcap_freealldevs(alldevs); // 链表必须释放,否则泄漏 return -1; } pcap_freealldevs(alldevs); // 打开后即可释放,句柄已独立四个参数的取值理由:snaplen给 65536 是为了完整收下整个 IP 包(超过 MTU 的帧在网卡层就被重组了,但抓包点看到的是分片),给 96 这种小值只能抓头,后面解析载荷时会拿到半截数据;promisc置 1 是实验要求的混杂模式,能收到同一广播域内其他主机的帧,置 0 则只收目的地址是自己的帧,两者抓到的数量差一个量级;to_ms设 1000 表示读不到包时最多阻塞一秒就返回,设 0 会变成无超时一直等,Ctrl+C 都不好退出。
pcap_freealldevs这行别省。链表是库内部 malloc 出来的,每跑一次枚举不释放就漏一次,长期运行的工具会慢慢吃掉内存。
3.2 过滤器:BPF 表达式怎么编译、怎么生效
抓到全量流量再在代码里 if-else 筛,是新手最常见的写法,代价是每个包都要从内核拷到用户态,流量一大 CPU 就满。正确做法是把过滤条件下推给驱动,用pcap_compile把一串 BPF 表达式编译成字节码,pcap_setfilter装进内核,之后只有匹配的帧才会上来。
// capture.c —— 编译并安装过滤器 struct bpf_program fcode; // 表达式语法和 tcpdump 一样:tcp port 80 / udp / arp / host 10.0.0.5 const char *filter_exp = "tcp or udp or arp"; if (pcap_compile(adhandle, &fcode, filter_exp, 1, PCAP_NETMASK_UNKNOWN) < 0) { fprintf(stderr, "过滤器编译失败: %s\n", pcap_geterr(adhandle)); return -1; } if (pcap_setfilter(adhandle, &fcode) < 0) { fprintf(stderr, "过滤器安装失败: %s\n", pcap_geterr(adhandle)); pcap_freecode(&fcode); return -1; } pcap_freecode(&fcode); // 编译产物装进内核后即可释放本地副本pcap_compile的第四个参数是 optimize 标志,1 表示让编译器做优化,生成的字节码更短、执行更快。第五个参数是网络掩码,在表达式里出现broadcast这类关键字时才用得上;没有组网上下文的话传PCAP_NETMASK_UNKNOWN,传 0 在某些版本上会让net 192.168.1.0/24这种写法判断出错。
pcap_freecode必须在setfilter之后调,不能提前。我见过把释放写到安装前面去的代码,编译产物被释放后再拿去安装,结果是随机崩溃,症状诡异到你怀疑人生。
表达式本身值得单独记一下:tcp port 80只看 80 端口的 TCP 段;udp and not port 53排除 DNS;host 192.168.1.100双向匹配某个 IP;ip proto 6等价于只抓 TCP。写过滤器的一个血泪经验是:先不设过滤跑一遍确认能抓到包,再逐步收紧表达式,反过来排查很难定位到底是表达式写错还是链路断。
3.3 抓包循环与回调函数的执行上下文
pcap_loop是个阻塞调用,内部不停地从驱动环形缓冲区取帧,每取到一个就回调一次,直到抓满指定包数或按 Ctrl+C。回调运行在调用pcap_loop的那个线程上,这点很重要——如果主线程是 UI 线程,回调里做任何耗时操作都会卡界面。
// capture.c —— 启动循环,cnt 为 -1 表示无限抓,直到 pcap_breakloop int rc = pcap_loop(adhandle, -1, packet_handler, (u_char *)&g_stat); if (rc == -1) { fprintf(stderr, "抓包循环异常: %s\n", pcap_geterr(adhandle)); } else if (rc == -2) { printf("循环被外部中断,正常退出\n"); // pcap_breakloop 的返回值 } // 回调签名固定,user 就是 pcap_loop 传进去的第四个参数 void packet_handler(u_char *user, const struct pcap_pkthdr *hdr, const u_char *pkt) { sniff_stat_t *st = (sniff_stat_t *)user; st->total_pkts++; st->bytes += hdr->len; // len 是帧在网卡上的真实长度 // caplen 是实际抓到的长度,可能小于 len(snaplen 截断时) decode_frame(pkt, hdr->caplen, hdr, st); }pcap_pkthdr里有三个字段常被混用:ts是内核打的时间戳,精度到微秒,做延迟分析就靠它;caplen是这次抓到的字节数;len是帧在链路上的原始长度。当snaplen小于帧长时caplen < len,解析时如果按len去读指针,就会越界读到别人的内存——这是崩溃和「解析出乱码 MAC」的主要原因。稳妥写法是解析函数一律以caplen作为边界,每一层剥头之前先判断剩余长度够不够。
另外,pkt指向的缓冲区在回调返回后就会被库复用。想留着以后慢慢分析,必须自己 memcpy 一份,或者当场写进pcap_dump文件。
3.4 协议头结构体与字节序:两个必踩的点
三层协议头都是二进制布局,C 结构体要严格对上,两个细节决定成败:一是#pragma pack(1)取消对齐填充,二是ntohs/ntohl做网络序到主机序的转换。漏掉任何一个,解析出来的端口号会是 20480 这种离谱数值。
// proto.h —— 全部按 1 字节对齐,字段顺序和 RFC 保持一致 #pragma pack(push, 1) typedef struct { u_char dst[6]; // 目的 MAC u_char src[6]; // 源 MAC u_short type; // 0x0800=IPv4, 0x0806=ARP, 0x86DD=IPv6 } eth_hdr_t; typedef struct { u_char ver_ihl; // 高 4 位版本,低 4 位首部长度(单位 4 字节) u_char tos; u_short total_len; // 整个 IP 包长度,含首部 u_short id; u_short flags_fo; // 3 位标志 + 13 位片偏移 u_char ttl; u_char proto; // 6=TCP, 17=UDP, 1=ICMP u_short checksum; u_int src; // 网络序 u_int dst; } ip_hdr_t; #pragma pack(pop)ver_ihl是一个字节塞两个字段,取值用位运算拆:ver = ver_ihl >> 4,ihl = (ver_ihl & 0x0f) * 4。ihl单位是 4 字节,最小 5 即 20 字节,带选项时能到 60 字节,所以算 TCP 头位置不能硬编码 20。
type、total_len、proto之外的端口字段全部是网络字节序(大端),x86 是小端,必须ntohs转。src、dst是 32 位,用ntohl之后塞进in_addr再inet_ntoa。这一步的写法有个小技巧:inet_ntoa返回的是静态缓冲区指针,一行里调两次会导致两个地址打印成同一个,得分开存。
4. 协议解析进阶:把裸字节翻译成可读字段
剥完以太网头之后是分叉点:type是 0x0806 走 ARP,0x0800 走 IPv4,其余直接丢弃。IPv4 内部再按proto分到 TCP、UDP、ICMP。每一层的入口都要先校验剩余长度,这是防止越界读的唯一手段。
4.1 以太网帧与 ARP 的解析
ARP 帧结构简单但字段多,是理解「协议头层层嵌套」的好样本。它没有 IP 头,以太网 type 直接指向 ARP 载荷,内容是一对 (IP, MAC) 的映射请求或应答。
// decode.c —— 以太网分发 void decode_frame(const u_char *pkt, u_int caplen, const struct pcap_pkthdr *hdr, sniff_stat_t *st) { if (caplen < sizeof(eth_hdr_t)) return; // 连以太网头都不完整 const eth_hdr_t *eth = (const eth_hdr_t *)pkt; u_short type = ntohs(eth->type); if (type == 0x0806) { st->arp_pkts++; decode_arp(pkt + sizeof(eth_hdr_t), caplen - sizeof(eth_hdr_t), st); } else if (type == 0x0800) { st->ip_pkts++; decode_ipv4(pkt + sizeof(eth_hdr_t), caplen - sizeof(eth_hdr_t), st); } }pkt + sizeof(eth_hdr_t)这个偏移写法比硬编码 14 好,将来结构体改了不用同步改数字。caplen - sizeof(...)传下去的是「本层可用长度」,每一层都这么减,到 TCP 载荷那一层剩下的就是真正的应用数据长度。
ARP 解析里我用inet_ntop而不是inet_ntoa,因为前者支持传入自己的缓冲区,多线程或一次打印多个地址时不会互相覆盖:
// decode.c —— ARP 请求/应答 typedef struct { u_short hw_type; // 1 = 以太网 u_short proto_type; // 0x0800 = IPv4 u_char hw_len; // 6 u_char proto_len; // 4 u_short opcode; // 1=请求 2=应答 u_char sha[6]; // 发送方 MAC u_int spa; // 发送方 IP u_char tha[6]; // 目标 MAC u_int tpa; // 目标 IP } arp_hdr_t; void decode_arp(const u_char *p, u_int len, sniff_stat_t *st) { if (len < sizeof(arp_hdr_t)) return; const arp_hdr_t *arp = (const arp_hdr_t *)p; char sip[INET_ADDRSTRLEN], tip[INET_ADDRSTRLEN]; inet_ntop(AF_INET, &arp->spa, sip, sizeof(sip)); inet_ntop(AF_INET, &arp->tpa, tip, sizeof(tip)); const char *op = (ntohs(arp->opcode) == 1) ? "who-has" : "is-at"; printf("[ARP] %s %s -> %s\n", sip, op, tip); }注意inet_ntop的第二个参数直接传&arp->spa就行,不需要ntohl——它接收的是网络序地址,内部自己转。这一点和printf("%08x", ntohl(...))的用法相反,混着记很容易多转一次,打出来的地址变成1.0.0.127这种镜像结果。
4.2 IP 分片与 TCP 载荷定位
IP 层要处理两件事:首部长度可变,以及分片。首部长度从ver_ihl算,分片看flags_fo的低 13 位和 MF 标志。
// decode.c —— IPv4 头解析,定位上层协议起点 void decode_ipv4(const u_char *p, u_int len, sniff_stat_t *st) { if (len < sizeof(ip_hdr_t)) return; const ip_hdr_t *ip = (const ip_hdr_t *)p; u_int ihl = (ip->ver_ihl & 0x0f) * 4; // 首部字节数,20~60 u_short total = ntohs(ip->total_len); // IP 包总长 u_short frag = ntohs(ip->flags_fo) & 0x1fff; // 片偏移 int mf = (ntohs(ip->flags_fo) & 0x2000) != 0; // 还有后续分片 if (ihl < 20 || total < ihl || len < total) return; // 结构非法或截断 if (frag != 0 || mf) { // 分片包不在这里重组 printf("[IPv4] 分片 offset=%u mf=%d,跳过\n", frag, mf); return; } const u_char *l4 = p + ihl; // 传输层起点 u_int l4len = total - ihl; // 传输层可用长度 switch (ip->proto) { case 6: st->tcp_pkts++; decode_tcp(l4, l4len, ip, st); break; case 17: st->udp_pkts++; decode_udp(l4, l4len, ip, st); break; case 1: printf("[ICMP] %u 字节\n", l4len); break; default: break; } }分片处理这块,实验代码的取舍是「跳过非首片」。理由是做嗅探演示时,重组分片要额外维护一张以 (src, dst, id, proto) 为键的表,还要处理超时和乱序,复杂度陡增;而实际流量里分片占比很低,跳过不影响演示效果。真要抓完整载荷,正确做法是分片全部落盘成 pcap,事后用分析器重组,而不是在回调里现做——回调是热路径,任何额外开销都会被放大。
total < ihl这个校验不能省。畸形包、TSO/LRO 卸载产生的怪异帧、snaplen 截断,都可能让total_len大于实际可用长度。少这一行,指针就越界了,症状是偶发崩溃或者打印出一段乱码。
TCP 头解析出来的字段里,最有信息量的是标志位和载荷长度:
// decode.c —— TCP 段,重点是标志位组合与载荷长度 typedef struct { u_short sport, dport; u_int seq, ack; u_char off; // 高 4 位是首部长度(单位 4 字节) u_char flags; // CWR ECE URG ACK PSH RST SYN FIN u_short win, sum, urp; } tcp_hdr_t; void decode_tcp(const u_char *p, u_int len, const ip_hdr_t *ip, sniff_stat_t *st) { if (len < sizeof(tcp_hdr_t)) return; const tcp_hdr_t *tcp = (const tcp_hdr_t *)p; u_int thl = (tcp->off >> 4) * 4; if (thl < 20 || len < thl) return; u_short sport = ntohs(tcp->sport); u_short dport = ntohs(tcp->dport); u_int payload = len - thl; // 应用层数据长度 // 标志位解码:SYN+ACK 是建连的第二段,RST 是异常断开 char flags[8] = {0}; int n = 0; if (tcp->flags & 0x02) flags[n++] = 'S'; if (tcp->flags & 0x10) flags[n++] = 'A'; if (tcp->flags & 0x01) flags[n++] = 'F'; if (tcp->flags & 0x04) flags[n++] = 'R'; if (tcp->flags & 0x08) flags[n++] = 'P'; printf("[TCP] %u -> %u flags=%s payload=%u\n", sport, dport, flags[0] ? flags : "-", payload); }payload == 0的段是纯 ACK,大量出现在正常通信里,统计时单列出来更有意义。R标志单独出现往往意味着端口没开或连接被拒,做异常检测时这是个很实用的信号。
4.3 输出格式化与 pcap 落盘
控制台打印在调试时够用,但流量一大就成瓶颈,stdout 的锁会把回调拖慢。更实用的组合是:控制台只打摘要(每 N 个包一行),全量数据写进 pcap 文件,事后用工具慢慢看。
// capture.c —— 边抓边落盘,这是最省心的调试姿势 pcap_dumper_t *dumper = pcap_dump_open(adhandle, "capture.pcap"); if (dumper == NULL) { fprintf(stderr, "创建 pcap 文件失败: %s\n", pcap_geterr(adhandle)); } // 回调中追加写,dump 的第二个参数就是 loop 传过来的 user 指针 static void packet_handler(u_char *user, const struct pcap_pkthdr *hdr, const u_char *pkt) { pcap_dumper_t *d = (pcap_dumper_t *)user; pcap_dump((u_char *)d, hdr, pkt); // 写盘比 printf 快得多 } // 退出时关句柄,不关的话文件尾会缺数据 pcap_dump_close(dumper); pcap_close(adhandle);pcap_dump写出的文件是标准 pcap 格式,能被任何同类工具打开,这一点对交实验报告特别有用——老师不用跑你的程序,直接看抓包文件就能验证。文件头里会记录snaplen和链路类型,所以别拿不同snaplen的两次抓包直接拼接,部分读取器会拒绝解析。
5. 常见问题排查:抓不到、抓不全、解析错的几类翻车现场
调试这类程序,症状和根因之间隔了好几层,按「现象 → 原因 → 解决」的顺序对着看能省很多时间。
现象一:pcap_findalldevs返回空链表,或者列表里只有几个奇怪的虚拟设备。原因通常是驱动没装好,或者装了但版本和系统不匹配;另一种情况是程序没以管理员权限运行,部分设备信息读不出来。解决办法是先确认wpcap.dll存在于系统目录,卸载重装驱动并勾选兼容模式,然后用管理员身份启动程序,再看列表里有没有\Device\NPF_开头的真实网卡。
现象二:编译通过但链接报LNK2019: 无法解析的外部符号 _pcap_open_live。原因基本是库和工程的位数对不上,或者wpcap.lib根本没加进去。确认工程是 x64 就把库目录指到Lib\x64,是 Win32 就指Lib;如果用#pragma comment(lib, ...),检查头文件有没有被真正包含进来——有些源文件只包含了proto.h,链接指令在sniff.h里,就漏了。
现象三:过滤器设了,结果一个包都抓不到;去掉过滤立刻就有。原因是表达式写错或掩码参数不对。pcap_compile失败时不会自动提示,必须检查返回值并打印pcap_geterr。另一个隐蔽原因是拿tcp port 80去抓非 80 端口的流量,看起来在跑其实全被内核丢了。排查办法是先用""(空表达式,等价于不过滤)跑一遍,再逐段加条件。
现象四:IP 地址打出来是反的,端口号是 20480 这种大数。这是字节序没转。端口、长度、校验和全是 16 位网络序,要ntohs;IP 地址是 32 位,要ntohl后再交给inet_ntoa。反过来的错误也存在:给inet_ntop传了已经ntohl过的值,地址会变成镜像形态。判断方法很简单,看地址是不是「合理网段」,127.0.0.1打成1.0.0.127就是多转了一次。
现象五:程序跑几分钟就卡死,或者显示抓到的包数远少于实际流量。原因是回调里做了耗时操作,printf每行都刷一次屏幕,在千兆流量下直接把回调堵住,驱动层的环形缓冲区满了就丢包。解决办法是把打印抽出来,回调里只做计数和 memcpy,另起一个线程或定时器批量输出;同时可以用pcap_setbuff把内核缓冲区调大,默认值在高流量下偏小。
列表里这几条是我在做这个实验和后来几次改代码时反复撞上的,尤其第五条,本地跑小流量完全正常,一换到实验室的共享网段就原形毕露。
6. 二次开发:在实验框架上加 HTTP 还原与流量统计
这套代码的可扩展性取决于第 2 章划的那条边界——decode.c只认裸指针和长度。想加应用层解析,就在 TCP 分支里接一个状态机;想做统计面板,就给sniff_stat_t加字段,回调里累加,主循环定时打印。下面这套改法我实际用过,改动量在两三百行以内。
第一步是给 TCP 载荷加一个轻量 HTTP 嗅探。思路很直接:端口是 80 且载荷非空,就检查载荷前几个字节是不是GET、POST、HTTP/1.这些特征串,命中就把请求行或响应状态行截出来。
// decode.c —— 在 decode_tcp 的 payload 分支里调用 static void try_http(const u_char *payload, u_int plen, u_short sport, u_short dport) { if (plen < 5 || (sport != 80 && dport != 80)) return; // 只看载荷开头,避免在中间件里误命中 if (memcmp(payload, "GET ", 4) == 0 || memcmp(payload, "POST ", 5) == 0 || memcmp(payload, "HTTP/1.", 7) == 0) { u_int show = plen < 120 ? plen : 120; // 截断,防止打印二进制体 char line[128] = {0}; memcpy(line, payload, show); for (u_int i = 0; i < show; i++) // 换行统一成空格,一行一条 if (line[i] == '\r' || line[i] == '\n') line[i] = ' '; printf("[HTTP] %s\n", line); } }这段代码的取舍要说明白:不做 TCP 流重组,只看单个段。好处是实现简单、不需要维护会话表,能覆盖大部分小请求;坏处是遇到大响应被切成多段、或者跨段的长 URL 会漏。教学场景下这个精度够用,真要做流量分析就得引入重组逻辑,那是另一个量级的工作。
第二步是把统计维度做宽。原来的sniff_stat_t只有计数,加两张小表就够看:按目标端口汇总的包数,以及按源 IP 汇总的字节数。
| 新增字段 | 类型 | 用途 |
|---|---|---|
port_hits[65536] | unsigned int* | 按端口计数,堆上分配 |
top_talkers | 定长数组 | 简单 LRU,记录字节数最多的 N 个源 IP |
first_ts/last_ts | struct timeval | 算平均速率和抓包时长 |
http_reqs | unsigned long | HTTP 请求行命中数 |
端口表 65536 个unsigned int是 256KB,直接放栈上会爆掉默认 1MB 的线程栈,得calloc出来。这个坑我踩过一次,症状是程序启动瞬间崩溃、断点打在main第一行都进不去,后来才反应过来是栈溢出。
第三步是加一个定时输出。别在回调里打印统计,用pcap_loop之前起一个线程,每秒读一次全局上下文打印摘要,读的时候加锁或者用volatile计数 + 原子递增都行,单写多读的场景下用InterlockedIncrement比加锁省事。
验证改造成果的办法很朴素:打开浏览器随便访问几个网页,同时用另一个成熟抓包工具抓同一块网卡,两边对比 HTTP 请求行数量、TCP 段数、字节总数。数字对不上就说明解析漏了分支,通常是 IPv6 没处理(type == 0x86DD直接进了 default)、或者端口判断写死了 80 漏了 8080。
最后说个习惯。从那以后我每次动解析结构体,都强制走一遍「用畸形包自测」:拿之前存下的 pcap 文件回放,故意把caplen截短、把total_len改大,确认程序不崩、只是跳过。抓包程序面对的是完全不可信的数据,任何「这个字段不会这么怪」的假设最后都会变成线上的偶发崩溃。希望帮到你。
本文还有配套的精品资源,点击获取