ARTICLE DETAIL

资讯详情

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

用C语言实现网络Sniffer:raw socket抓包与协议解析实战

用C语言实现网络Sniffer:raw socket抓包与协议解析实战 简介基于C语言实现的网络嗅探器课程设计项目面向网络编程学习者、信息安全专业学生以及需要完成抓包类课程设计的开发者。项目以WinPcap与MFC为双核心实现在混杂模式下对网卡数据包的捕获、过滤与解析支持TCP、UDP、ARP、ICMP、HTTP、IPv4、IPv6等协议可按协议格式可视化展示包头与数据并支持本地保存和读取。压缩包共63个文件包含C源码、MFC界面资源、Visual Studio工程配置、可执行程序、实验报告与说明文档大小约6.54MB。已有464人学习。对希望快速上手WinPcap开发或完善Sniffer功能的读者资源提供了可直接编译运行的示例代码、协议解析框架和界面设计思路有助于理解数据包从捕获到解析的完整流程并方便在此基础上扩展更多协议或功能。1. 一个网络 Sniffer 值不值得用 C 语言重写取决于你想不想看懂每一比特抓包工具人人会用但“抓到包”和“看懂包”之间隔着一整条协议栈。用 C 语言实现网络 Sniffer本质是在链路层开一条旁路通道把网卡收到的原始以太网帧原样交给用户态程序再用指针和结构体把一个帧拆成 MAC 地址、IP 头、TCP/UDP 端口。这里从 raw socket 讲起覆盖混杂模式、抓包主循环和三层头部解析全程不依赖第三方库单文件即可编译运行。适合已经写过不少多文件 C 程序、但第一次接触网络编程的读者——跑通之后再回看 tcpdump 的输出会有一种非常清晰的坐标感。2. 网络 Sniffer 工作原理数据包在哪一层被截获为什么选 C 语言2.1 从网卡到用户态raw socket 插入的位置先理清一条数据包的默认路径。网卡收到以太网帧后驱动把它放入内核的 sk_buff随后协议栈依次处理链路层、IP 层、传输层最后把纯应用数据交给 TCP 或 UDP 套接字。也就是说用普通 socket 编程你拿到的是去掉所有协议头之后的内容连 HTTP 请求的 header 都看不到更别说以太网头本身。抓包要做的是在协议栈“消化”这个帧之前把副本截下来。Linux 上的常见做法是用 raw socket它有两个层次可选socket 类型截获层次能看到的内容AF_INET SOCK_RAW IPPROTO_TCPL3IP 头 TCP 头 负载无以太网头AF_PACKET SOCK_RAW htons(ETH_P_ALL)L2完整以太网帧AF_PACKET SOCK_DGRAML2去掉链路头后的原始负载AF_INET 的 raw socket 只能按协议类型过滤拿不到 MAC 地址和 EtherTypeAF_PACKET 才是真正的链路层通道。后面的实现全部使用 AF_PACKET因为它返回的是内核还没来得及重组的数据帧最接近网线上的真实形态。2.2 为什么不直接用 libpcaplibpcap 是 tcpdump、Wireshark 底层的抓包引擎负责设备打开、BPF 过滤器和跨平台移植。生产环境的抓包工具十有八九是 libpcap 加回调函数那一套。但这里故意不用它原因是学习的颗粒度不同。一是调用链更短。libpcap 封装了 mmap 和内核过滤器抓包变成注册回调很容易把“协议解析”这一层的重要性掩盖掉。二是缓冲区要自己管。raw socket 的 recvfrom 每次返回一个帧缓冲区截断、短帧、字节序都得自己处理这些恰恰是 Sniffer 的核心难点。三是接口语义不同。libpcap 的 pcap_next_ex 返回带时间戳的 pcap 头而 AF_PACKET 直接给裸数据解析起点完全不同。对想做正式工具的人来说libpcap 依然是更稳妥的底座对想把协议吃透的人来说裸 socket 走一遍是绕不开的功课。2.3 C 语言的几个不可替代点为什么这类工具历史上都落在 C 语言上而不是托管语言原因集中在内存布局映射、指针算术和运行开销三处。2.3.1 结构体与内存布局直接对应以太网头、IP 头都是固定或半固定的二进制布局C 语言可以把结构体指针直接投射到缓冲区上一行代码完成字段映射struct eth_hdr *eth (struct eth_hdr *)buf; /* 直接把帧头当作结构体读 */Python 里做同样的事要按字节格式逐个拆包import struct dst, src, etype struct.unpack(!6s6sH, buf[:14])代码量差一个数量级而且 C 版本后续访问字段是零开销的。2.3.2 指针算术处理变长头部IP 头带选项时长度不固定真正的数据偏移是 14 字节以太网头加 IHL 乘以 4。用指针偏移计算实现起来非常直接。同时 C 语言指针也是边界检查的落点——解析每个字段前先确认偏移加上字段长度没有越过整帧长度这是数据包解析里内存安全的唯一防线后面章节会专门演示。2.3.3 无运行时开销的字节序与内存管理网络字节序是大端常见的 x86、ARM 主机是小端抓包解析里到处都是 ntohs、ntohl。C 语言的这些转换是编译期内联的几条指令。在内存管理上抓包程序不需要 GC 停顿不存在包到达高峰时被垃圾回收卡住的问题C 语言手动的内存管理反而让缓冲区的生命周期完全可控。Sniffer 用 C 写最大的代价是安全性缓冲区越界和野指针会直接崩溃所以长度校验必须成为习惯。3. 用 socket 编程在 C 语言里跑通最小抓包循环3.1 创建 raw socket 的最小代码一个命令行工具的核心只需要三步创建套接字、打开混杂模式、循环接收。先看创建部分#include stdio.h #include string.h #include unistd.h #include sys/socket.h #include arpa/inet.h #include net/if.h #include linux/if_packet.h #include linux/if_ether.h int main(void) { int sock socket(AF_PACKET, SOCK_RAW, htons(ETH_P_ALL)); if (sock 0) { perror(socket); return 1; } printf(raw socket fd%d\n, sock); close(sock); return 0; }三个参数逐个说明AF_PACKET 表示在链路层工作对应的地址结构是 sockaddr_llSOCK_RAW 表示把完整帧连头带尾交给用户态不做任何切割htons(ETH_P_ALL) 是协议过滤器告诉内核这个套接字接收所有协议类型IPv4、IPv6、ARP 都会进来。只想收 IP 包把第三个参数改成 htons(ETH_P_IP) 即可。编译运行注意权限sudo gcc sniff.c -o sniff sudo ./sniff普通用户调用 socket 会直接收到 EPERMperror 输出 Operation not permitted。原因在于创建原始套接字需要 CAP_NET_RAW 能力这也是所有抓包类工具都需要特权的原因。3.2 打开混杂模式否则只能看到自己的流量默认情况下网卡只把目标 MAC 是自己的帧、广播帧和组播帧交给驱动。不开混杂模式Sniffer 只能抓到本机收发的内容。要让网卡把同网段其他主机的帧也送上来需要加入 PACKET_MR_PROMISC 组播成员struct packet_mreq mreq; memset(mreq, 0, sizeof(mreq)); mreq.mr_ifindex if_nametoindex(eth0); /* 接口名转内核索引 */ mreq.mr_type PACKET_MR_PROMISC; if (setsockopt(sock, SOL_PACKET, PACKET_ADD_MEMBERSHIP, mreq, sizeof(mreq)) 0) { perror(PACKET_ADD_MEMBERSHIP); return 1; }字段含义mr_ifindex 用 if_nametoindex 把 eth0 转成内核接口编号mr_type 指定操作类型PACKET_MR_PROMISC 表示进入混杂模式。程序退出时内核会自动清理这个成员关系不用手动调用 PACKET_DROP_MEMBERSHIP。注意混杂模式对整个网口生效同网卡上的所有进程都会收到额外的帧中断在共享机器上做实验前要确认这台设备是你自己在用。3.3 主循环recvfrom 读帧丢包从这开始抓包循环很直接一个阻塞式的 recvfrom 就能转起来#define BUF_SIZE 65536 unsigned char buf[BUF_SIZE]; while (1) { ssize_t len recvfrom(sock, buf, sizeof(buf), 0, NULL, NULL); if (len 0) { perror(recvfrom); break; } printf(captured %zd bytes\n, len); }recvfrom 默认阻塞内核每送上来一个帧就返回一次buf 开到 65536 是为容纳最大以太网帧1518 字节并留出余量。返回值存在三种典型情况返回值含义与处理len 0正常收到一帧len 是帧字节数len -1 且 errno EINTR被信号打断continue 重试即可len -1 且 errno ENOBUFS内核接收队列溢出帧已被丢弃ENOBUFS 是最隐蔽的坑程序不报错只是数对不上。包到达速率超过处理速度时socket 接收队列满新帧被直接丢弃。缓解方案在第 5 章给出。另外还要说明这个循环没有 bind 到具体网卡AF_PACKET 套接字默认绑定第一个非回环接口多网卡机器上想指定 eth1需要用 sockaddr_ll 做 bind把 sll_ifindex 设为目标网卡的索引。4. 用 C 语言结构体按以太网、IP、TCP/UDP 头做字段级解析4.1 以太网帧头14 字节定长的第一步帧头固定 14 字节目的 MAC 6 字节、源 MAC 6 字节、EtherType 2 字节。直接定义一个结构体对齐到缓冲区struct eth_hdr { unsigned char dst[6]; unsigned char src[6]; unsigned short ether_type; } __attribute__((packed)); struct eth_hdr *eth (struct eth_hdr *)buf; unsigned short type ntohs(eth-ether_type);attribute((packed)) 的作用是禁止编译器插入对齐填充字节。虽然这三个字段的天然对齐恰好不会产生 padding但写成 packed 是一种防御性习惯防止以后调整字段顺序时翻车。MAC 地址是字节数组直接按 %02x 打印不需要字节序转换EtherType 是 16 位整数按网络字节序存储必须 ntohs。常见取值0x0800 是 IPv40x0806 是 ARP0x86DD 是 IPv6。4.2 IPv4 头IHL 决定后续偏移IP 头最少 20 字节带选项时最长 60 字节。第一个字节的高 4 位是版本号低 4 位是头长度 IHL单位是 32 位字所以真正的头长度是(ver_ihl 0x0F) * 4struct ip_hdr { unsigned char ver_ihl; unsigned char tos; unsigned short total_len; unsigned short ident; unsigned short frag_off; unsigned char ttl; unsigned char protocol; unsigned short checksum; unsigned int src_ip; unsigned int dst_ip; } __attribute__((packed)); struct ip_hdr *ip (struct ip_hdr *)(buf 14); int ihl (ip-ver_ihl 0x0F) * 4;这里最常见的错是直接用sizeof(struct ip_hdr)当偏移。虽然当前定义下恰好是 20但只要有人加字段或漏写 packedsizeof 就会大于实际头长TCP 头解析会集体错位。必须用 IHL 字段计算TCP/UDP 的起始位置就是buf 14 ihl。protocol 字段是传输层协议号6 是 TCP17 是 UDP1 是 ICMP。4.3 取出四元组并打印连接信息下面把前面几节连成一个能用的解析函数。解析后输出源 IP、源端口到目的 IP、目的端口的四元组void parse_ip(const unsigned char *frame, size_t len) { if (len 14 20) return; /* 帧长不足直接放弃 */ struct ip_hdr *ip (struct ip_hdr *)(frame 14); int ihl (ip-ver_ihl 0x0F) * 4; if (len 14 ihl) return; char src[INET_ADDRSTRLEN], dst[INET_ADDRSTRLEN]; inet_ntop(AF_INET, ip-src_ip, src, sizeof(src)); inet_ntop(AF_INET, ip-dst_ip, dst, sizeof(dst)); if (ip-protocol 6 len 14 ihl 20) { struct tcp_hdr *tcp (struct tcp_hdr *)(frame 14 ihl); printf(%s:%-5u - %s:%-5u TCP\n, src, ntohs(tcp-src_port), dst, ntohs(tcp-dst_port)); } }inet_ntop 把内存里的 4 字节网络序 IP 转成点分十进制它的输入本就是网络字节序不需要 ntohl。端口转换则必须做否则大小端颠倒打印出来是一串无规律的大数字。函数开头两条长度检查是解析器的生命线——抓包缓冲区里永远不保证是你期待的结构长度校验不过就直接 return。TCP 头的定义如下struct tcp_hdr { unsigned short src_port; unsigned short dst_port; unsigned int seq; unsigned int ack; unsigned char offset_res; unsigned char flags; unsigned short window; unsigned short checksum; unsigned short urg_ptr; } __attribute__((packed));UDP 头只有 8 字节src_port、dst_port、length、checksum 各占 2 字节解析逻辑完全一致。在循环里对每个帧调用 parse_ip就是一个约 60 行、能工作的最小嗅探器。4.4 字节序问题汇总字段类型存储方式处理方式MAC 地址字节数组直接打印不转换EtherType、端口、长度16 位大端ntohsIP 地址32 位大端inet_ntop / ntohlTCP seq、ack32 位大端ntohl还有一个容易忽略的细节ARP 包的头结构和 IP 完全不同解析前要先按 EtherType 分派用 switch 把 0x0800、0x0806、0x86DD 拆到各自的解析函数。混在一起读的代码遇到 ARP 会输出一堆垃圾字段排查起来非常浪费时间。5. 网络 Sniffer 的过滤器、接收缓冲和验证技巧5.1 在解析前加轻量过滤全量解析所有包代价不小尤其是高流量时。BPF 是更专业的做法但入门阶段可以在 recvfrom 之后加两行判断把无关流量挡在循环外。比如只看某个 IPunsigned int target; inet_pton(AF_INET, 192.168.1.1, target); /* target 已经是网络字节序可直接和 ip-src_ip 比较 */ if (memcmp(ip-src_ip, target, 4) ! 0 memcmp(ip-dst_ip, target, 4) ! 0) continue;这里两个 4 字节比较都在同一字节序下进行不用额外转换。要按端口过滤写法类似先 ntohs 再比较。过滤放在解析之前比先完整解析再判断能省下大量 CPU。5.2 放大接收队列降低丢包率默认套接字接收缓冲通常只有几百 KB高流量下内核队列很快耗尽。常见做法是启动时调大 SO_RCVBUFint rcvbuf 4 * 1024 * 1024; setsockopt(sock, SOL_SOCKET, SO_RCVBUF, rcvbuf, sizeof(rcvbuf));注意内核会把设置值翻倍4 MB 实际变成 8 MB同时受/proc/sys/net/core/rmem_max上限约束超出部分会被静默截断。另一个实用技巧是接收线程只做 recvfrom解析和打印交给另一个线程避免终端 IO 拖慢接收速度。5.3 用 tcpdump 和 ping 做对照验证跑通之后建议按三个步骤验证结果一个终端启动编译好的程序另一个终端执行ping -c 3 网关地址Sniffer 应输出目标 IP 的 ICMP 记录。执行 curl 请求一个 HTTP 站点观察 TCP 四元组和 80、443 端口是否出现。同一网卡上并行启动tcpdump -i eth0 -c 20对比两者的帧计数和协议分布数量一致说明循环没有漏帧。提示用 loopback 接口验证会发现 AF_PACKET 在 lo 上抓不到以太网帧因为回环接口没有链路层头这是正常现象不是代码 bug。最后提醒一句混杂模式能收到同网段非本机的流量这只能在你自己管理的设备或实验环境里进行生产网络上要先确认有相应的授权。把这个过滤器改成只收特定端口、再把输出切到文件就是一个可以长时间挂在后台的流量统计工具比每次重编代码实用得多。本文还有配套的精品资源点击获取
返回列表