13、原始socket与数据包捕获:创建原始socket,解析IP/TCP/UDP头部
说实话,做网络编程这么多年,我一直觉得普通socket就像坐高铁——你只管买票上车,至于铁轨怎么铺、信号怎么传,跟你没关系。但原始socket不一样,它就像给你一辆越野车,还附赠一张地图,让你自己决定怎么走。
我记得刚入行那会儿,有个项目需要自己抓包分析网络流量。当时我第一反应是用libpcap,但后来发现环境受限,只能自己动手写。嗯,就是那次经历,让我彻底搞懂了IP头、TCP头这些底层结构。
13.1 原始socket是什么?
原始socket(Raw Socket)允许你直接收发网络层的数据包。普通socket只给你应用层数据,而原始socket给你完整的IP数据包,包括头部信息。
说白了,就是你能看到网络传输的「裸数据」。这在做网络监控、协议分析、安全工具时特别有用。
核心区别:
- 普通socket:只处理应用层数据(HTTP、FTP等)
- 原始socket:可以处理IP层及以上的所有数据
13.2 创建原始socket
创建原始socket需要root权限,这一点要记住。我在项目中就吃过这个亏——程序写好了,一运行报权限错误,折腾了半天才发现是忘了加sudo。
#include <sys/socket.h>
#include <netinet/ip.h>
#include <netinet/tcp.h>
#include <netinet/udp.h>
int raw_sock = socket(AF_INET, SOCK_RAW, IPPROTO_TCP);
if (raw_sock < 0) {
perror("socket creation failed");
exit(1);
}
第三个参数指定协议类型:
IPPROTO_TCP:只捕获TCP数据包IPPROTO_UDP:只捕获UDP数据包IPPROTO_ICMP:只捕获ICMP数据包IPPROTO_RAW:捕获所有IP数据包
小技巧:如果你想捕获所有协议的数据包,可以用 IPPROTO_RAW,但要注意这样需要自己构造IP头部。
13.3 设置IP头包含选项
默认情况下,原始socket收到的数据包是不包含IP头部的。你需要设置一个socket选项来获取完整的IP头。
int one = 1;
if (setsockopt(raw_sock, IPPROTO_IP, IP_HDRINCL, &one, sizeof(one)) < 0) {
perror("setsockopt failed");
close(raw_sock);
exit(1);
}
设置了IP_HDRINCL之后,收到的数据包就包含完整的IP头部了。我个人习惯在创建socket后立刻设置这个选项,免得后面忘了。
13.4 接收数据包
接收数据包用recvfrom()函数,跟普通socket一样。但缓冲区要足够大,因为一个数据包可能包含完整的IP头+TCP头+应用数据。
unsigned char buffer[65536];
struct sockaddr_in src_addr;
socklen_t addr_len = sizeof(src_addr);
int data_size = recvfrom(raw_sock, buffer, sizeof(buffer), 0,
(struct sockaddr*)&src_addr, &addr_len);
if (data_size < 0) {
perror("recvfrom failed");
close(raw_sock);
exit(1);
}
printf("Received %d bytes from %s\n", data_size, inet_ntoa(src_addr.sin_addr));
13.5 解析IP头部
IP头部的结构是固定的,我直接贴出结构体定义:
struct iphdr {
unsigned int ihl:4; // 头部长度(4字节为单位)
unsigned int version:4; // IP版本(IPv4为4)
uint8_t tos; // 服务类型
uint16_t tot_len; // 总长度
uint16_t id; // 标识符
uint16_t frag_off; // 分片偏移
uint8_t ttl; // 生存时间
uint8_t protocol; // 上层协议(TCP=6, UDP=17)
uint16_t check; // 头部校验和
uint32_t saddr; // 源IP地址
uint32_t daddr; // 目的IP地址
};
解析的时候,直接把缓冲区指针强转成struct iphdr*就行:
struct iphdr *ip_header = (struct iphdr*)buffer;
printf("IP Version: %d\n", ip_header->version);
printf("Header Length: %d bytes\n", ip_header->ihl * 4);
printf("Total Length: %d\n", ntohs(ip_header->tot_len));
printf("Protocol: %d\n", ip_header->protocol);
struct in_addr src, dst;
src.s_addr = ip_header->saddr;
dst.s_addr = ip_header->daddr;
printf("Source IP: %s\n", inet_ntoa(src));
printf("Dest IP: %s\n", inet_ntoa(dst));
注意:IP头部长度字段ihl的单位是4字节,所以实际长度要乘以4。我曾经在解析时忘了这个,结果后面TCP头部的位置全算错了,排查了半天才发现。
13.6 解析TCP头部
TCP头部紧跟在IP头部后面。IP头部的长度决定了TCP头部的起始位置。
struct tcphdr {
uint16_t source; // 源端口
uint16_t dest; // 目的端口
uint32_t seq; // 序列号
uint32_t ack_seq; // 确认号
uint16_t res1:4; // 保留位
uint16_t doff:4; // 数据偏移(头部长度)
uint16_t fin:1; // FIN标志
uint16_t syn:1; // SYN标志
uint16_t rst:1; // RST标志
uint16_t psh:1; // PSH标志
uint16_t ack:1; // ACK标志
uint16_t urg:1; // URG标志
uint16_t res2:2; // 保留位
uint16_t window; // 窗口大小
uint16_t check; // 校验和
uint16_t urg_ptr; // 紧急指针
};
解析代码:
int ip_header_len = ip_header->ihl * 4;
struct tcphdr *tcp_header = (struct tcphdr*)(buffer + ip_header_len);
printf("Source Port: %d\n", ntohs(tcp_header->source));
printf("Dest Port: %d\n", ntohs(tcp_header->dest));
printf("Sequence Number: %u\n", ntohl(tcp_header->seq));
printf("ACK Number: %u\n", ntohl(tcp_header->ack_seq));
printf("Flags: %s%s%s%s%s%s\n",
tcp_header->urg ? "URG " : "",
tcp_header->ack ? "ACK " : "",
tcp_header->psh ? "PSH " : "",
tcp_header->rst ? "RST " : "",
tcp_header->syn ? "SYN " : "",
tcp_header->fin ? "FIN " : "");
13.7 解析UDP头部
UDP头部比TCP简单多了,就4个字段:
struct udphdr {
uint16_t source; // 源端口
uint16_t dest; // 目的端口
uint16_t len; // UDP长度(头部+数据)
uint16_t check; // 校验和
};
解析方式跟TCP类似:
struct udphdr *udp_header = (struct udphdr*)(buffer + ip_header_len);
printf("Source Port: %d\n", ntohs(udp_header->source));
printf("Dest Port: %d\n", ntohs(udp_header->dest));
printf("UDP Length: %d\n", ntohs(udp_header->len));
13.8 完整示例:数据包嗅探器
下面是一个完整的例子,可以捕获并解析TCP和UDP数据包:
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <unistd.h>
#include <sys/socket.h>
#include <netinet/ip.h>
#include <netinet/tcp.h>
#include <netinet/udp.h>
#include <arpa/inet.h>
int main() {
int raw_sock = socket(AF_INET, SOCK_RAW, IPPROTO_RAW);
if (raw_sock < 0) {
perror("socket");
return 1;
}
int one = 1;
if (setsockopt(raw_sock, IPPROTO_IP, IP_HDRINCL, &one, sizeof(one)) < 0) {
perror("setsockopt");
close(raw_sock);
return 1;
}
unsigned char buffer[65536];
struct sockaddr_in src_addr;
socklen_t addr_len = sizeof(src_addr);
printf("Starting packet capture...\n");
while (1) {
int data_size = recvfrom(raw_sock, buffer, sizeof(buffer), 0,
(struct sockaddr*)&src_addr, &addr_len);
if (data_size < 0) {
perror("recvfrom");
break;
}
struct iphdr *ip = (struct iphdr*)buffer;
int ip_header_len = ip->ihl * 4;
printf("\n=== Packet captured ===\n");
printf("Source IP: %s\n", inet_ntoa(*(struct in_addr*)&ip->saddr));
printf("Dest IP: %s\n", inet_ntoa(*(struct in_addr*)&ip->daddr));
if (ip->protocol == 6) { // TCP
struct tcphdr *tcp = (struct tcphdr*)(buffer + ip_header_len);
printf("Protocol: TCP\n");
printf("Source Port: %d\n", ntohs(tcp->source));
printf("Dest Port: %d\n", ntohs(tcp->dest));
printf("Flags: %s%s%s%s%s%s\n",
tcp->urg ? "URG " : "",
tcp->ack ? "ACK " : "",
tcp->psh ? "PSH " : "",
tcp->rst ? "RST " : "",
tcp->syn ? "SYN " : "",
tcp->fin ? "FIN " : "");
} else if (ip->protocol == 17) { // UDP
struct udphdr *udp = (struct udphdr*)(buffer + ip_header_len);
printf("Protocol: UDP\n");
printf("Source Port: %d\n", ntohs(udp->source));
printf("Dest Port: %d\n", ntohs(udp->dest));
printf("UDP Length: %d\n", ntohs(udp->len));
} else {
printf("Protocol: %d (other)\n", ip->protocol);
}
}
close(raw_sock);
return 0;
}
13.9 数据包结构总览
为了让你更直观地理解数据包的结构,我画了一张图:
13.10 避坑指南
我在实际项目中踩过不少坑,这里总结几个常见的:
- 权限问题:原始socket需要root权限。我曾经在开发机上跑得好好的,部署到服务器上就报错,就是因为忘了给程序加sudo。
- 字节序问题:网络字节序是大端,主机字节序可能是小端。记得用
ntohs()和ntohl()转换。 - 头部长度计算:IP头部和TCP头部的长度字段单位不同,IP的
ihl是4字节为单位,TCP的doff也是4字节为单位。别搞混了。 - 缓冲区大小:建议用65536字节的缓冲区,这是以太网MTU的最大值,能保证收到完整的数据包。
个人经验:如果你只是想抓包分析,用libpcap库会更方便。但如果你想深入理解网络协议栈,自己写原始socket解析是很好的学习方式。我当年就是靠这个彻底搞懂了TCP三次握手的过程。
好了,原始socket的基本用法就这些。你想想看,掌握了这些,你就能自己写一个简单的抓包工具了。虽然比不上Wireshark那么强大,但理解底层原理的感觉,真的很爽。