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 数据包结构总览

为了让你更直观地理解数据包的结构,我画了一张图:

数据包结构总览 以太网帧头部(14字节) IP头部(20-60字节) TCP头部(20-60字节)或 UDP头部(8字节) 应用层数据(HTTP、FTP等) 原始socket可以捕获从IP头部开始的所有数据 设置 IP_HDRINCL 选项后,数据包包含完整的IP头部

13.10 避坑指南

我在实际项目中踩过不少坑,这里总结几个常见的:

  • 权限问题:原始socket需要root权限。我曾经在开发机上跑得好好的,部署到服务器上就报错,就是因为忘了给程序加sudo。
  • 字节序问题:网络字节序是大端,主机字节序可能是小端。记得用ntohs()ntohl()转换。
  • 头部长度计算:IP头部和TCP头部的长度字段单位不同,IP的ihl是4字节为单位,TCP的doff也是4字节为单位。别搞混了。
  • 缓冲区大小:建议用65536字节的缓冲区,这是以太网MTU的最大值,能保证收到完整的数据包。

个人经验:如果你只是想抓包分析,用libpcap库会更方便。但如果你想深入理解网络协议栈,自己写原始socket解析是很好的学习方式。我当年就是靠这个彻底搞懂了TCP三次握手的过程。

好了,原始socket的基本用法就这些。你想想看,掌握了这些,你就能自己写一个简单的抓包工具了。虽然比不上Wireshark那么强大,但理解底层原理的感觉,真的很爽。

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