14、原始套接字与数据包捕获

说实话,网络编程做到一定深度,你一定会碰到一个坎——普通套接字不够用了。TCP/UDP 帮你把协议栈的细节都封装好了,你只管收发数据。但如果你想看网络里到底在传什么原始数据,想自己构造一个 IP 包,或者想抓包分析,那就得请出今天的主角:原始套接字libpcap

我个人习惯把原始套接字比作「网络世界的裸眼」。普通套接字像戴着滤镜看世界,而原始套接字让你直接看到最底层的比特流。嗯,这玩意儿威力大,但坑也多。我在项目中遇到过几次因为原始套接字权限问题导致的线上故障,后面我会专门讲。

14.1 原始套接字:让你亲手造一个 IP 包

先说说原始套接字是什么。你平时用 socket(AF_INET, SOCK_STREAM, 0) 创建的是 TCP 套接字,内核帮你处理了三次握手、序列号、校验和。但如果你用 SOCK_RAW,内核就撒手不管了——你得自己构造 IP 头、TCP 头,自己算校验和。

核心区别一句话:普通套接字是「点菜」,原始套接字是「自己买菜做饭」。

创建原始套接字

代码其实很简单,但有个大前提——你需要 root 权限。我记得第一次在开发机上跑原始套接字程序,直接报 EPERM,当时还以为是代码写错了,查了半天才发现是权限问题。

#include <sys/socket.h>
#include <netinet/ip.h>
#include <netinet/tcp.h>

int sock = socket(AF_INET, SOCK_RAW, IPPROTO_TCP);
if (sock < 0) {
    perror("socket");
    exit(1);
}

这里 IPPROTO_TCP 表示我们只捕获 TCP 协议的数据包。如果你想捕获所有 IP 包,可以用 IPPROTO_RAW 或者 IPPROTO_IP。不过要注意,IPPROTO_RAW 在 Linux 上需要自己构造 IP 头,而 IPPROTO_IP 则会让内核帮你填充 IP 头的一部分。

避坑指南:我曾经在生产环境的调试机上忘记关原始套接字程序,结果导致网卡中断风暴,整个机器响应变慢。原始套接字程序一定要加流量控制,别让它无限制地收包。

构造 IP 头和 TCP 头

构造头部是原始套接字编程的核心。说白了,你就是在内存里按协议规范填字节。IP 头长这样:

struct iphdr {
    unsigned int ihl:4;      // 头部长度
    unsigned int version:4;  // 版本号
    uint8_t tos;             // 服务类型
    uint16_t tot_len;        // 总长度
    uint16_t id;             // 标识符
    uint16_t frag_off;       // 分片偏移
    uint8_t ttl;             // 生存时间
    uint8_t protocol;        // 协议类型
    uint16_t check;          // 校验和
    uint32_t saddr;          // 源IP
    uint32_t daddr;          // 目的IP
};

TCP 头稍微复杂一点,因为有标志位和选项字段:

struct tcphdr {
    uint16_t source;         // 源端口
    uint16_t dest;           // 目的端口
    uint32_t seq;            // 序列号
    uint32_t ack_seq;        // 确认号
    unsigned int res1:4;     // 保留位
    unsigned int doff:4;     // 数据偏移
    unsigned int fin:1;      // FIN标志
    unsigned int syn:1;      // SYN标志
    unsigned int rst:1;      // RST标志
    unsigned int psh:1;      // PSH标志
    unsigned int ack:1;      // ACK标志
    unsigned int urg:1;      // URG标志
    uint16_t window;         // 窗口大小
    uint16_t check;          // 校验和
    uint16_t urg_ptr;        // 紧急指针
};

你想想看,这些字段加起来也就 40 字节左右,但填错一个位,包就废了。我建议你写代码的时候,对照着 RFC 文档一个一个字段填,别偷懒。

14.2 libpcap 库:抓包界的瑞士军刀

自己写原始套接字抓包虽然能学到东西,但说实话太累了。你要处理各种协议细节,还要考虑性能优化。这时候 libpcap 就派上用场了。libpcap 是 tcpdump 和 Wireshark 的底层库,它帮你屏蔽了不同操作系统上抓包的差异。

我个人习惯用 libpcap 做两件事:一是离线分析 pcap 文件,二是在线实时抓包。下面这张图展示了 libpcap 的工作流程:

libpcap 抓包流程 pcap_findalldevs() 查找可用网卡 pcap_open_live() 打开网卡设备 pcap_compile() 编译过滤规则 pcap_setfilter() 应用过滤规则 pcap_loop() / pcap_next() 循环捕获数据包 pcap_close() 关闭句柄释放资源 核心流程:查找设备 → 打开设备 → 编译过滤 → 应用过滤 → 循环抓包 → 关闭

libpcap 核心 API

libpcap 的 API 设计得很清晰,基本上就是上面流程图里的几个函数。我挑几个重点说一下:

函数 作用 注意事项
pcap_findalldevs() 列出所有可用的网络设备 返回的链表需要调用 pcap_freealldevs() 释放
pcap_open_live() 打开一个网卡设备开始抓包 第二个参数 snaplen 建议设 65535,避免丢包
pcap_compile() 编译 BPF 过滤表达式 表达式语法和 tcpdump 一样,比如 "tcp port 80"
pcap_loop() 循环抓包,回调函数处理每个包 第三个参数是回调函数,注意线程安全
pcap_next() 抓取下一个包(非阻塞) 适合简单场景,性能不如 pcap_loop

小技巧:BPF 过滤表达式非常强大。比如你想抓 HTTP 请求,可以用 "tcp port 80 and (tcp[13] & 2 != 0)" 只抓 SYN 包。我经常用这个组合来快速定位 TCP 连接建立的问题。

14.3 写一个简单的抓包程序

理论说完了,咱们动手写一个。这个程序会抓取所有 TCP 包,并打印出源 IP、目的 IP 和端口号。代码不长,但涵盖了 libpcap 的核心用法。

#include <pcap.h>
#include <netinet/ip.h>
#include <netinet/tcp.h>
#include <arpa/inet.h>

void packet_handler(u_char *user, const struct pcap_pkthdr *pkthdr, const u_char *packet) {
    struct iphdr *ip = (struct iphdr *)(packet + 14); // 跳过以太网头
    struct tcphdr *tcp = (struct tcphdr *)(packet + 14 + ip->ihl * 4);
    
    char src_ip[16], dst_ip[16];
    inet_ntop(AF_INET, &ip->saddr, src_ip, sizeof(src_ip));
    inet_ntop(AF_INET, &ip->daddr, dst_ip, sizeof(dst_ip));
    
    printf("%s:%d -> %s:%d\n", 
           src_ip, ntohs(tcp->source),
           dst_ip, ntohs(tcp->dest));
}

int main() {
    char errbuf[PCAP_ERRBUF_SIZE];
    pcap_t *handle;
    struct bpf_program fp;
    char filter_exp[] = "tcp";
    bpf_u_int32 net;
    
    // 打开网卡
    handle = pcap_open_live("eth0", BUFSIZ, 1, 1000, errbuf);
    if (handle == NULL) {
        fprintf(stderr, "打开网卡失败: %s\n", errbuf);
        return 1;
    }
    
    // 编译并应用过滤规则
    if (pcap_compile(handle, &fp, filter_exp, 0, net) == -1) {
        fprintf(stderr, "编译过滤规则失败\n");
        return 1;
    }
    if (pcap_setfilter(handle, &fp) == -1) {
        fprintf(stderr, "应用过滤规则失败\n");
        return 1;
    }
    
    printf("开始抓包...\n");
    pcap_loop(handle, 10, packet_handler, NULL);
    
    pcap_close(handle);
    return 0;
}

这段代码有几个地方要注意:

  • 以太网头偏移:我直接写了 14 字节,这是标准以太网帧头长度。如果你在 loopback 接口上抓包,这个偏移可能是 0 或者 4,不同系统不一样。
  • IP 头长度:ip->ihl * 4 是因为 IHL 字段的单位是 4 字节。这个坑我踩过——有一次 IHL 是 5,但我忘了乘 4,结果 TCP 头解析全错了。
  • 字节序:端口号用 ntohs() 转换,IP 地址用 inet_ntop()。网络字节序是大端,而 x86 是小端,不转换就是乱码。

避坑指南:我曾经在生产环境上跑这个程序,结果发现抓到的包全是乱码。排查了半天,发现是网卡开启了 TSO(TCP 分段卸载)和 GRO(通用接收卸载)。这些硬件优化会把多个小包合并成大包,导致 libpcap 抓到的包长度和实际不符。解决办法是在抓包前关闭这些特性:ethtool -K eth0 tso off gro off

14.4 原始套接字 vs libpcap:怎么选?

很多初学者会问:既然 libpcap 这么好用,为什么还要学原始套接字?我的看法是:场景不同,工具不同

场景 推荐工具 原因
抓包分析、网络监控 libpcap API 封装完善,跨平台,性能好
构造自定义协议包 原始套接字 可以完全控制每个字节
高性能数据包处理 libpcap + 零拷贝 原始套接字在内核态有性能瓶颈
网络安全工具(如 Nmap) 原始套接字 需要发送特殊构造的包

说白了,libpcap 是「拿来就用」,原始套接字是「自己造轮子」。如果你只是想做网络监控,libpcap 足够了。但如果你想深入理解 TCP/IP 协议栈,或者做安全研究,那原始套接字是绕不开的。

14.5 本章小结

嗯,这一章内容不少。我们从原始套接字的创建讲起,手把手构造了 IP 头和 TCP 头,然后介绍了 libpcap 的抓包流程,最后写了一个完整的抓包程序。核心就三点:

  • 原始套接字让你直接操作网络层和传输层头部,但需要 root 权限
  • libpcap 封装了底层抓包细节,提供 BPF 过滤和回调机制
  • 实际开发中,根据场景选择合适的工具——监控用 libpcap,构造包用原始套接字

我记得刚学原始套接字那会儿,总觉得这东西太底层,用不上。直到后来做 DDoS 攻击溯源,需要分析畸形包,才发现原始套接字有多香。技术这东西,多学一点总没坏处。


公众号:蓝海资料掘金营,微信deep3321