☰
Sniffer源码解析:从libpcap抓包到协议解码实战
2026/10/9 9:11:18 网站建设 项目流程

简介:这是一份基于VC++实现的网络嗅探器Sniffer完整源代码,面向有一定C++基础、希望系统学习抓包原理和WinPCAP库应用的开发者。压缩包共21个文件,以h头文件与cpp源文件为主,辅以rc资源脚本、ico图标及工程配置文件,覆盖主程序、对话框界面、IP头部定义、自定义协议结构等核心模块。结合SnifferDlg、Sniffer、iphdr等源码,可系统了解从网卡选择、数据包捕获、过滤规则设置到IP/TCP/UDP头部解析的完整流程,并体会多线程捕包、界面交互等实用编程技巧。项目体积仅82KB,结构紧凑、便于阅读与二次开发,已有750人学习,适合希望从源码层深入掌握网络嗅探器实现细节的读者。

1. 网络嗅探器 Sniffer 源代码.zip:这份压缩包到底能拿来做什么

你手里这份"网络嗅探器Sniffer 源代码.zip",解压之后大概率是一堆 .c、.h、一个 Makefile 再加一个 README。先别急着关页面,这类源码是很多抓包工具的共同起点——它解决的问题非常具体:把经过网卡的数据包截下来,按以太网、IP、TCP 的格式把字节拆成能看懂的字段。对谁有用?一是想搞清楚 Wireshark 上方框里那些字段从哪来的学生,二是要在内网排查流量异常、做流量监控的运维,三是安全方向想从零练协议分析的入门者。一个反直觉的点:抓包本身并不难,难的从来是把字节流解析成协议字段——而这恰恰是这份源码里最值得读的部分。

2. 拆开 zip 之前:Sniffer 的抓包原理和 libpcap 选型

动手编译之前,建议先搞清楚一件事:Sniffer 源码里 90% 的"魔法"发生在你看不见的 libpcap 库中,而不是你自己写的那几十行代码里。如果不知道数据包从哪里来、BPF 过滤器在哪里生效,后面遇到"过滤规则没错却抓不到包"这种玄学问题,你连排查方向都没有。我一般会把下面这条路径先讲清楚,再让新手去看代码。

2.1 数据包从网卡到用户态缓冲区:一条绕开协议栈的旁路

普通网络应用收包,走的是网卡、内核协议栈、socket、recvfrom 这条正路。嗅探器走的是一条旁路:数据包到达网卡后,通过 DMA 写到内核的一块共享缓冲区,libpcap/pcap 在这块缓冲区上挂过滤器,只有命中的数据包被拷贝到用户态环形缓冲区,应用层再用 pcap_next_ex 或 pcap_loop 读取。这个设计的直接后果是:嗅探器不需要绑定任何端口,不占用连接,也不改系统路由表,所以 Wireshark 在正常抓包时对业务几乎无感。

但旁路也带来一个代价:如果用户态消费速度跟不上内核写入速度,内核缓冲区满时会直接丢包。很多源码在 pcap_open_live 里传的 timeout 和 snaplen 参数,就是用来调节这个矛盾的。理解这条路径后,再看 pcap_create、pcap_activate 这种分步式 API,就不会觉得它多余——每一步对应一个可以单独调节的旋钮。

2.2 为什么几乎所有 Sniffer 源码都绕不开 libpcap

第一是可移植性。libpcap 用同一套 C API 封装了 Linux、macOS、BSD 的抓包接口,Windows 侧用 Npcap 提供兼容层。源码里只要针对 _WIN32 处理一下头文件路径,就能跨平台编译,这也是 zip 里同时出现 Makefile 和 .sln 工程文件的原因。

第二是 BPF。过滤表达式并不是在用户态做字符串匹配,而是由 pcap_compile 把 "tcp port 80" 编译成一段微型虚拟机指令,再由 pcap_setfilter 下发给内核。所以源码里的过滤器写成字符串,而不是写成一堆 if (端口 == 80) 的判断。这个机制让过滤发生在内核态,速度快很多,但也意味着你必须按 BPF 的语法写表达式,不能用 Wireshark 显示过滤器的语法。

第三是 pcap 文件格式。文件头 24 字节,后面每包一个 16 字节包头加数据。这个格式是整个抓包生态的交换语言,tcpdump、Wireshark、tshark 都能读能写。源码里如果有一个离线读取功能,本质上就是解析这个格式,而不是重新发明一套。

2.3 一份 Sniffer 源码包最常见的目录结构和文件职责

这类 zip 里的源码目录通常不会太复杂,我见过的大多数组织方式是下面这样,具体以你手里的压缩包实际内容为准:

sniffer/ ├── Makefile # 构建与清理入口 ├── README.md # 平台依赖、编译方式、功能说明 ├── src/ │ ├── main.c # 命令行参数、主循环、退出清理 │ ├── capture.c # pcap 打开设备、设置过滤器、读取数据 │ ├── capture.h │ ├── decode_eth.c # 以太网帧头解析 │ ├── decode_ip.c # IP 头解析与分片判断 │ ├── decode_tcp.c # TCP/UDP 头解析 │ ├── output.c # 文本输出、hex dump │ └── util.c # 字节序转换、时间戳格式化 └── test/ └── sample.pcap # 离线抓包样本,用于回归测试

main.c 只保留参数解析和主循环,capture 负责从 pcap 拿包,decode 负责拆包,output 负责展示。把"抓包""解析""展示"三层隔离开,是这类代码最值得保留的设计:换网卡接口、加协议解析、改输出格式,都只动其中一层。我拿到任何 Sniffer 源码,第一件事就是把目录结构抄到笔记里,标出每个文件的输入和输出,这样后面改动时才知道该改谁。

另外建议你从这一刻起就把它纳入源代码管理。不是 Git 也可以,哪怕只是每次改之前复制一份 .bak,都比直接改完发现回不去强。离线 sample.pcap 就是这个流程里的后悔药。

3. 把 Sniffer 跑起来:从编译到抓到第一个包

跑起来比想象中容易,但中间有三个拦路虎:依赖库缺失、权限不够、过滤器表达式写错。下面按我实际操作时从零到一的过程走一遍,每一步都顺手做验证,而不是等到最后一起报错。

3.1 先装 libpcap 开发包,再看 Makefile

Debian/Ubuntu 上先装开发包:

sudo apt update sudo apt install libpcap-dev gcc make

macOS 不用装额外依赖,系统自带 libpcap,装好 Xcode Command Line Tools 即可。Windows 则要安装 Npcap,再下载 Npcap SDK,把 include 和 lib 路径指到 SDK 目录。

最小 Makefile 长这样,兼容性比手动写路径好:

CC = gcc CFLAGS = -Wall -O2 -I./include $(shell pcap-config --cflags) LDFLAGS = $(shell pcap-config --libs) TARGET = sniffer SRCS = src/main.c src/capture.c src/decode_eth.c src/decode_ip.c src/decode_tcp.c src/output.c src/util.c OBJS = $(SRCS:.c=.o) $(TARGET): $(OBJS) $(CC) -o $@ $(OBJS) $(LDFLAGS) clean: rm -f $(OBJS) $(TARGET) run: $(TARGET) sudo ./$(TARGET) -i eth0 -f "tcp port 80"

pcap-config 会帮你自动找头文件和库路径,不同发行版的 libpcap 头文件位置差异很大,写死 -I/usr/include/pcap 迟早翻车。CFLAGS 里的 -O2 够用了,解析代码不建议开更高优化,调试时编译器可能把回调里的代码重排,让堆栈信息不好读。run 伪目标里带了 sudo,因为实时抓包需要 root 或 CAP_NET_RAW 权限,这点后面避坑章节还要展开。

3.2 打开设备、设置过滤器和抓包回调:最小可跑代码

用一个最小的 main.c 打通全流程,这段代码可以直接替换 zip 里的 main.c 验证环境:

#include <pcap.h> #include <stdio.h> static void on_packet(u_char *user, const struct pcap_pkthdr *h, const u_char *data) { /* h->len 是原始包长度,h->caplen 是实际捕获长度 */ printf("%ld.%06ld caplen=%u len=%u\n", (long)h->ts.tv_sec, (long)h->ts.tv_usec, h->caplen, h->len); } int main(int argc, char **argv) { char errbuf[PCAP_ERRBUF_SIZE]; char *dev = argv[1]; pcap_t *handle = pcap_open_live(dev, 65535, 1, 1000, errbuf); if (!handle) { fprintf(stderr, "open %s failed: %s\n", dev, errbuf); return 1; } struct bpf_program fp; if (pcap_compile(handle, &fp, "tcp port 80", 1, PCAP_NETMASK_UNKNOWN) < 0) { fprintf(stderr, "compile filter: %s\n", pcap_geterr(handle)); return 2; } if (pcap_setfilter(handle, &fp) < 0) { fprintf(stderr, "set filter: %s\n", pcap_geterr(handle)); return 3; } pcap_loop(handle, -1, on_packet, NULL); pcap_close(handle); return 0; }

pcap_open_live 的四个参数依次是设备名、snaplen、混杂模式开关、超时毫秒。snaplen 65535 是为了容纳最大 MTU 加链路头,如果你只关心协议头而不是 payload,改成 256 性能会好很多。混杂模式传 1,表示不管目标 MAC 是不是本机,只要经过网卡就收。timeout 传 1000,是告诉内核用户态最多等 1 秒,别一直憋着不算超时。

pcap_compile 的第三个参数是过滤表达式,第四个参数 1 表示开启优化,第五个传 PCAP_NETMASK_UNKNOWN 让库自己猜。pcap_loop 的 -1 表示一直抓,直到出错或 Ctrl+C。这里最关键的一点:不要在回调里做耗时操作,比如同步磁盘写、printf 大段内容。回调跑得慢,缓冲区积压,丢包就从这里开始。

3.3 用离线 pcap 包调试:为什么要保留下线模式

没有 root、没有空闲网卡,也一样能调试。把 pcap_open_live 换成 pcap_open_offline,后面所有代码一字不改:

pcap_t *handle = pcap_open_offline("test/sample.pcap", errbuf); if (!handle) { fprintf(stderr, "open pcap file: %s\n", errbuf); return 1; }

pcap_open_offline 打开的是 tcpdump/Wireshark 抓好的文件,返回的还是同一个 pcap_t 句柄,所以 pcap_compile、pcap_setfilter、pcap_loop 全部通用。三个区别要记牢:时间戳取自文件里的抓包时间,不再是当前时间;caplen 可能小于 len,说明原始抓包时因为网络繁忙截断了尾部字节;离线模式不会丢包,便于反复对比同样的输入输出。

这也引出一个重要习惯:用离线模式做回归测试。改完解析代码,拿同一个 sample.pcap 跑一遍,用 diff 对比前后的文本输出,比每次打开 Wireshark 肉眼翻包快得多。很多成熟的 Sniffer 源码里保留 -r 参数,就是 tcpdump 的习惯——-r 表示读文件。

4. 解析层才是这份代码的大头:从 14 字节以太网头到 TCP 标志位

如果你把这份源码从头捋一遍,会发现抓包部分只占三分之一,剩下全是解析。原因不难理解:抓包只是把数据从网卡"拿"下来,解析才是把0x4500003c...变成IP src=192.168.1.1 dst=8.8.8.8的过程。这一章按实际解析顺序拆开讲,全部基于一个前提:数据包是一块连续内存,第一个字节就是以太网帧头。

4.1 以太网帧头:14 字节起点和 802.1Q 的意外插入

以太网帧头固定 14 字节:前 6 字节目标 MAC,6 到 11 字节源 MAC,12 到 13 字节是上层协议类型。0x0800 表示 IPv4,0x0806 表示 ARP,0x86DD 表示 IPv6。很多 Sniffer 源码在 14 字节之后直接按 IP 头解析,遇到 VLAN 就废了:

/* buf 指向以太网帧首字节 */ int off = 14; /* 默认以太网头长度 */ /* 0x8100 是 802.1Q VLAN 标签,后面还跟着 4 字节 */ if ((buf[12] << 8 | buf[13]) == 0x8100) { off = 18; /* IP 头整体后移 4 字节 */ }

这里有第一个容易翻车的地方:如果网卡或交换机启用了 VLAN,不解 8100 标签直接解析,你会看到 IP 头从 14 字节开始,读到的版本号往往是 0x81 或乱码,整包解析全乱。另外,x86 平台上直接强转一个 struct ether_header 是可以的,但一加 #pragma pack 又容易错位。更迁移的做法是用偏移量加手动移位,代码可读性差一点,但换到 ARM 和 MIPS 都不用改。

4.2 IP 头:IHL 决定真正起点,分片字段别漏

IP 头从 off 处开始,第一字节高 4 位是版本号,低 4 位是 IHL,单位是 32 位字。常见值 5 表示 20 字节,如果带了选项字段就是 6、7。总长度字段在第 2 到第 3 字节,单位是字节,包含整个 IP 头加载荷:

int ver = (buf[off] >> 4) & 0x0f; int ihl = (buf[off] & 0x0f) * 4; int total_len = (buf[off + 2] << 8) | buf[off + 3]; uint8_t proto = buf[off + 9]; /* 6=TCP, 17=UDP, 1=ICMP */ /* 分片偏移在偏移 6-7,低 13 位才是真正的分片偏移 */ int frag_off = ((buf[off + 6] & 0x1f) << 8) | buf[off + 7]; int mf = (buf[off + 6] & 0x20) != 0; /* MF=1 表示还有后续分片 */

总长度是大端字节序,直接位移拼接就是正确值,不需要调 ntohs。这里新手容易疑惑:为什么端口要字节序转换,这个字段就不用?因为你是按字节读再拼,而不是把整个多字节值按小端强转,本质上你已经手动做了大端转换。分片字段是很多源码忽略的地方:如果 frag_off 不为 0,说明这不是分片的第一个包,后面没有完整的 TCP 头,拿它去解析端口和标志位会读到乱数据。解析 L4 之前一定要先判断 frag_off == 0。

4.3 TCP:20 字节基线和标志位拼图

TCP 头基线是 20 字节,源端口在偏移 0 到 1,目标端口在偏移 2 到 3,序列号在 4 到 7,确认号在 8 到 11。第 12 字节的高 4 位是数据偏移,单位也是 32 位字,也就是 TCP 头长度:

int tcp_off = off + ihl; /* TCP 起始位置 */ int tcp_hdr_len = (buf[tcp_off + 12] >> 4) * 4; uint16_t sport = (buf[tcp_off] << 8) | buf[tcp_off + 1]; uint16_t dport = (buf[tcp_off + 2] << 8) | buf[tcp_off + 3]; uint8_t flags = buf[tcp_off + 13]; /* FIN=0x01 SYN=0x02 ... */

标志位分布在第 13 字节:bit0 是 FIN,bit1 是 SYN,bit2 是 RST,bit3 是 PSH,bit4 是 ACK,bit5 是 URG。写代码时用常量而不是裸数字:

#define TCP_FLAG_SYN 0x02 #define TCP_FLAG_ACK 0x10 #define TCP_FLAG_RST 0x04 #define TCP_FLAG_FIN 0x01

判断一个包是否 HTTP 请求,最直接的办法是检查 flags 里同时有 PSH 和 ACK,再看 payload 开头是不是 "GET "、"POST "。但注意,这只是"挨个包看"的做法,不等于 HTTP 流重组。TCP 可能把一次请求拆成两个包,也可能一个包里装了两条请求,真正的重组需要按序列号把数据拼起来,那是另一个工作量,源码里如果没写,别指望靠过滤器解决。

4.4 把十六进制流变成可读协议行:一个 16 字节对齐的 dump 技巧

解析字段后,输出层通常需要一个 hex dump 方便对照 Wireshark。下面这段按 16 字节对齐打印,每行同时给偏移和 ASCII 列:

void hex_dump(const u_char *buf, int len) { for (int i = 0; i < len; i += 16) { printf("%04x ", i); for (int j = 0; j < 16; j++) { if (i + j < len) printf("%02x ", buf[i + j]); else printf(" "); } printf(" "); for (int j = 0; j < 16; j++) { if (i + j < len) putchar(isprint(buf[i + j]) ? buf[i + j] : '.'); else putchar(' '); } putchar('\n'); } }

左侧偏移列的作用是快速定位字段:比如以太网头结束后偏移就是 14,你打印第一行就能看到 IP 头起点。ASCII 列专门用来找 HTTP 头里的 "Host:"、"User-Agent:" 这类字符串,比盯着十六进制数字看效率高得多。调试时建议把这个 dump 重定向到文件,然后和 Wireshark 的 "Bytes" 视图逐字节对比,能找出不少边界错位。

5. Sniffer 源码调试避坑 5 例:抓不到包时先看这五处

前面把主干跑通了,接下来是血泪经验。以下五个坑按出现频率排序,每一条都按"现象、原因、解决"的思路写。我自己在这里面栽过最久的,是第二条——过滤表达式编译通过但一个包都没有,卡了两个小时才意识到是语法问题。

5.1 抓不到包:混杂模式没生效,root 给了也一样

现象:用 root 运行程序,显示打开设备成功,但 pcap 回调一次都不触发;同一条命令换 tcpdump 也一样。

原因:混杂模式只是让网卡接收非本机 MAC 的包,但现在的网络环境很多是交换机隔离端口,非本机流量根本到不了你的网卡。云主机和虚拟机里尤其明显,单纯开混杂模式抓不到同一网段其他机器。另外,有些网卡驱动需要系统层面先启用,再叠加 pcap 的开关。

解决:先用ip -s link show eth0确认网卡状态,再开混杂模式验证:

sudo ip link set eth0 promisc on sudo tcpdump -i eth0 -c 10

如果 tcpdump 也抓不到,问题就不在源码。要验证代码逻辑,最简单的办法是抓环回口:./sniffer lo,然后另一个终端 ping 127.0.0.1,lo 接口不会受交换机隔离影响,是验证抓包主循环最快的路径。

5.2 过滤表达式编译成功但 0 包:BPF 语法和 Wireshark 语法不是一回事

现象:pcap_compile 返回 0,过滤器安装成功,就是没有包。检查表达式写的是tcp.port == 80。

原因:libpcap 的 BPF 表达式是 tcpdump 风格,字段之间用空格和关键字连接,不支持 Wireshark 显示过滤器的tcp.port == 80写法。BPF 里判断端口是tcp port 80,组合条件是tcp port 80 and host 192.168.1.1,比较访问是len >= 60。

解决:把表达式全部改成 tcpdump 语法。想在 BPF 里做字节偏移匹配,比如找 SYN 包,用tcp[13] & 0x02 != 0这种写法。另外记得,libpcap 编译表达式时不会报"字段不存在",它会当作普通偏移处理,所以在 BPF 里引用了不存在的层时,编译照样成功,运行时 0 包。遇到编译成功但 0 包的情况,第一步不是查代码,是用简单表达式ip或tcp port 80去二分定位是过滤器的问题还是抓包的问题。

5.3 明明过滤了 tcp port 80,只看到握手没有 HTTP 内容

现象:抓包结果是 SYN、SYN-ACK、ACK 三种包都有,但回调打印的 payload 长度全是 0,看不到 "GET / HTTP/1.1"。

原因:TCP 握手包本来就没有 payload,这不是 bug。真正的 HTTP 请求数据包通常带有 PSH+ACK 标志,而且如果一个 HTTP 请求太大,会被 TCP 拆成多个包,只有某些包有 payload。如果访问的是 HTTPS,那 443 端口的 payload 全是加密内容,在 URL 和 Host 字段的位置只有密文。

解决:在回调里判断 payload 长度,为 0 的包直接跳过,减少输出噪音。要看 HTTP 明文内容,对 tcp 80 端口抓包后用tcpdump -A -r sample.pcap或者程序里把 TCP 流按序列号重组,再在重组后的流里搜 "Host:"。想抓 HTTPS 内容,唯一合理方案是让业务端点做 SSL 卸载,或者在设备上安装根证书做中间人,这事已经超出 Sniffer 源码本身的职责。

5.4 IP 和端口打印成天文数字:字节序翻车

现象:过滤 tcp port 8080,程序打出来的目标端口却是 36767,源 IP 变成16.172.1.1这种奇怪的数字。

原因:网络字节序是大端,x86 是小端。如果把两个字节看成一个 uint16_t 直接强转,0x1f90(8080)会被读成0x901f(36767)。这是解析代码里最常见的翻车现场,也是新手眼里最像"玄学"的问题,其实完全可推理。

解决:所有多字节数值字段,统一用手动移位拼接,或者用 ntohs/ntohl。两个字节用(buf[i] << 8) | buf[i + 1],四个字节用(buf[i] << 24) | (buf[i+1] << 16) | (buf[i+2] << 8) | buf[i+3]。IP 地址不要转 int,逐字节用%u.%u.%u.%u打印就不会错。还有一个隐蔽点:如果源码用了 struct iphdr 这类结构体强转,必须在结构体定义前加#pragma pack(1),否则编译器按 4 字节对齐会在 IP 头中间插入填充,所有字段整体错位。

5.5 流量一大就丢包,caplen 和 len 不相等

现象:抓包文件里部分包 caplen 明显小于 len;调用 pcap_stats 看 ps_drop 不为 0;抓 100 万包时时间戳出现不连贯。

原因:内核从网卡拷贝到抓包缓冲区时,缓冲区满了,尾部字节被截断或者整包丢弃。snaplen 开太大、回调处理太慢、内核 ring buffer 太小,都会触发。

解决:改用分步式 API 做精细控制,而不是 pcap_open_live 一行到底:

pcap_t *handle = pcap_create(dev, errbuf); pcap_set_snaplen(handle, 512); /* 只留协议头 */ pcap_set_promisc(handle, 1); pcap_set_timeout(handle, 1000); pcap_set_buffer_size(handle, 16 * 1024 * 1024); /* 内核缓冲 16MB */ pcap_activate(handle);

snaplen 从 65535 减到 512,只要不关心应用层内容,抓包吞吐能成倍上升。回调里不要做 fwrite、printf 大段输出这类阻塞操作,把数据拷到自己的队列,解析放到另一个线程。如果还在丢包,就该考虑共存包方案或 AF_PACKET 零拷贝,但那是另一个量级的问题,和这份 zip 的定位不匹配。

6. 把这份源码变成自己的工具:最小解析插件、流量统计和离线回归

到了这一步,你已经能抓包、能看字段、能排坑。接下来要做的是把这份网络嗅探器 Sniffer 源代码从"能跑"改成"自己的工具"。最常见的方式是加一个插件化解析器接口,让主循环不关心具体协议逻辑,只要把包分发给注册的解析函数。

最小接口可以设计成这样:

typedef struct sniffer_plugin { const char *name; uint16_t ether_type; /* 0x0800 表示 IP */ uint8_t ip_proto; /* 6=TCP, 17=UDP, 0 表示不限制 */ int (*on_packet)(const u_char *pkt, int len, const struct pcap_pkthdr *h); } sniffer_plugin_t;

在 pcap 回调里遍历全局插件数组,先比对 ether_type 和 ip_proto,命中再调用 on_packet。这样加一个识别 BT 流量的插件,或者加一个按源 IP 统计的插件,都不需要改主循环和 Makefile,只是新增一个 .c 文件、注册一个结构体。

流量统计也建议做成插件思路。按源 IP 做哈希桶,每个包在回调里把 h->len 累加到对应桶里,注意用 len 而不是 caplen,因为 len 才代表真实包长。统计结果每 10 秒打印一次 Top 10,就能很直观地看到内网谁在跑大流量。这套做法做出来以后,验证方式也很简单:先跑 test/sample.pcap 离线回归,diff 旧输出,再上真实网卡试抓。

我自己在这个方向上的最后一课,是输出缓冲。有一次抓包结果重定向到文件后,明明抓了几万个包,文件却只有几百行,最后发现 printf 全缓冲没刷新。现在我在 main 函数第一行就加setvbuf(stdout, NULL, _IONBF, 0),不加这个,短短几十秒的抓包都能让你误判成丢包。另一个教训是每次改完代码都跑一遍 sample.pcap 做回归对比,确认这次改动没有让已有的解析逻辑翻车,再上真实流量。这两条习惯比任何技巧都省时间,希望帮到你。

本文还有配套的精品资源,点击获取

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询