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 核心 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