28、原始套接字:SOCK_RAW创建、IP_HDRINCL选项、构造IP数据包

说实话,讲到原始套接字这一章,我心里有点小激动。为什么?因为这是真正让你「触碰」网络协议底层的地方。前面我们用 TCP、UDP 写应用,说白了都是在应用层玩,内核帮你把 IP 头、MAC 头都封装好了。但原始套接字不一样——它把 IP 层的控制权交到你手里。

我记得刚入行那会儿,第一次用 SOCK_RAW 构造了一个 ICMP 包,看着 Wireshark 里抓到的包完全是自己手写的,那种感觉……嗯,就像你亲手组装了一台发动机,然后点火成功。

28.1 原始套接字是什么?

原始套接字(Raw Socket)是一种特殊的套接字类型。它允许你直接收发网络层的数据包,而不是像流套接字或数据报套接字那样只处理传输层的数据。

说白了,普通套接字你只能操作「数据载荷」,而原始套接字让你能操作「整个 IP 数据包」。包括 IP 头部、选项字段、甚至底层的链路层头部(取决于你创建时的协议参数)。

我个人的习惯是:当你需要实现标准协议栈不支持的功能时,就该考虑原始套接字了。比如:

  • 自定义的隧道协议
  • 网络扫描工具(如 ping、traceroute)
  • 数据包嗅探与分析
  • 构造特殊标志位的攻击测试包(仅限实验环境)
⚠️ 重要提醒: 原始套接字需要 root 权限才能创建。在 Linux 下,普通用户调用 socket(AF_INET, SOCK_RAW, protocol) 会返回 EPERM 错误。我在项目中遇到过几次因为权限问题导致程序崩溃的情况,排查了半天才发现是没加 sudo。

28.2 创建原始套接字

创建原始套接字的 API 和普通套接字一样,只是类型参数不同:

#include <sys/socket.h>
#include <netinet/ip.h>   // IP 头部结构
#include <netinet/ip_icmp.h> // ICMP 头部结构

int sockfd = socket(AF_INET, SOCK_RAW, protocol);

第三个参数 protocol 指定了你想要接收或发送的 IP 协议类型。常见的取值有:

协议宏 协议值 说明
IPPROTO_ICMP 1 ICMP 协议(ping 用的就是这个)
IPPROTO_TCP 6 TCP 协议
IPPROTO_UDP 17 UDP 协议
IPPROTO_RAW 255 原始 IP 包,需要自己构造完整头部

举个例子,如果你想创建一个能接收所有 ICMP 包的原始套接字:

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

这里有个细节:当你指定 IPPROTO_ICMP 时,内核会自动帮你填充 IP 头部,你只需要构造 ICMP 头部和数据。但如果你想完全控制 IP 头部,就需要用到下面这个选项。

28.3 IP_HDRINCL 选项

IP_HDRINCL 是原始套接字的一个关键选项。它的全称是「IP Header Include」,意思是「IP 头部由用户自己包含」。设置了这个选项后,你发送的数据必须包含完整的 IP 头部,内核不会再帮你自动填充。

为什么会需要这个?我举个例子:

有一次我需要构造一个带有特殊 IP 选项的数据包,用来测试网络设备的兼容性。如果不设置 IP_HDRINCL,内核会忽略你构造的 IP 头部,直接用自己的逻辑填充。那你的特殊选项就全白费了。

设置方法很简单:

int on = 1;
if (setsockopt(sock, IPPROTO_IP, IP_HDRINCL, &on, sizeof(on)) < 0) {
    perror("setsockopt IP_HDRINCL");
    close(sock);
    exit(1);
}
💡 个人经验: 设置 IP_HDRINCL 后,IP 头部的校验和(checksum)需要你自己计算。内核不会帮你算。我刚开始写的时候忘了这茬,抓包一看 checksum 全是 0x0000,包直接被路由器丢弃了。排查了半小时才反应过来。

28.4 构造 IP 数据包

好了,现在套接字已经准备好,我们来手写一个完整的 IP 数据包。IP 头部的结构定义在 <netinet/ip.h> 中:

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;         // 上层协议
    uint16_t check;           // 头部校验和
    uint32_t saddr;           // 源 IP 地址
    uint32_t daddr;           // 目的 IP 地址
    // 选项字段(可选)
};

下面是一个完整的构造示例,我们构造一个 ICMP Echo 请求包(就是 ping 包):

#include <stdio.h>
#include <string.h>
#include <unistd.h>
#include <sys/socket.h>
#include <netinet/ip.h>
#include <netinet/ip_icmp.h>
#include <arpa/inet.h>

// 计算校验和的辅助函数
unsigned short checksum(void *b, int len) {
    unsigned short *buf = b;
    unsigned int sum = 0;
    unsigned short result;

    for (sum = 0; len > 1; len -= 2)
        sum += *buf++;
    if (len == 1)
        sum += *(unsigned char *)buf;
    sum = (sum >> 16) + (sum & 0xFFFF);
    sum += (sum >> 16);
    result = ~sum;
    return result;
}

int main() {
    int sock = socket(AF_INET, SOCK_RAW, IPPROTO_ICMP);
    if (sock < 0) {
        perror("socket");
        return 1;
    }

    // 设置 IP_HDRINCL
    int on = 1;
    setsockopt(sock, IPPROTO_IP, IP_HDRINCL, &on, sizeof(on));

    // 构造 IP 头部
    char packet[4096];
    struct iphdr *ip = (struct iphdr *)packet;
    struct icmphdr *icmp = (struct icmphdr *)(packet + sizeof(struct iphdr));

    memset(packet, 0, sizeof(packet));

    ip->version = 4;
    ip->ihl = 5;  // 5 * 4 = 20 字节,无选项
    ip->tos = 0;
    ip->tot_len = htons(sizeof(struct iphdr) + sizeof(struct icmphdr));
    ip->id = htons(12345);
    ip->frag_off = 0;
    ip->ttl = 64;
    ip->protocol = IPPROTO_ICMP;
    ip->saddr = inet_addr("192.168.1.100");  // 源 IP
    ip->daddr = inet_addr("8.8.8.8");        // 目的 IP
    ip->check = 0;  // 先置零,再计算
    ip->check = checksum((unsigned short *)packet, sizeof(struct iphdr));

    // 构造 ICMP 头部
    icmp->type = ICMP_ECHO;
    icmp->code = 0;
    icmp->checksum = 0;
    icmp->un.echo.id = htons(1000);
    icmp->un.echo.sequence = htons(1);
    icmp->checksum = checksum((unsigned short *)icmp, sizeof(struct icmphdr));

    // 发送
    struct sockaddr_in dest;
    dest.sin_family = AF_INET;
    dest.sin_addr.s_addr = ip->daddr;

    if (sendto(sock, packet, ntohs(ip->tot_len), 0,
               (struct sockaddr *)&dest, sizeof(dest)) < 0) {
        perror("sendto");
    } else {
        printf("ICMP Echo request sent successfully!\n");
    }

    close(sock);
    return 0;
}
🔑 核心要点:
  • IP 头部中的多字节字段(tot_len、id、saddr、daddr 等)需要使用 htons()htonl() 转换为网络字节序
  • 校验和计算时,先把 check 字段置零,计算完再赋值
  • 发送时使用 sendto(),目标地址结构体中的 IP 必须与 IP 头部的 daddr 一致

28.5 知识体系总览

下面这张图帮你理清原始套接字的核心逻辑:

原始套接字核心知识体系 socket(AF_INET, SOCK_RAW, protocol) setsockopt(IP_HDRINCL) 可选 构造 IP 头部 + 传输层头部 + 数据载荷 IP 头部:版本、长度、TTL、校验和 传输层:ICMP/TCP/UDP 头部 数据载荷:自定义内容

28.6 避坑指南

我在项目中踩过不少原始套接字的坑,这里挑几个典型的分享给你:

  • 校验和必须正确:我曾经构造了一个包,IP 头部和 ICMP 头部都写对了,但校验和算错了。结果包发出去石沉大海。记住:IP 头部的校验和只覆盖 IP 头部本身,而 ICMP/TCP/UDP 的校验和覆盖头部+数据。
  • 字节序别搞反:IP 地址、端口号、长度字段都是网络字节序(大端)。我见过有人直接把本机 IP 的整数值赋给 saddr,结果包发到了火星上。
  • IP_HDRINCL 不是必须的:如果你只是想发一个标准的 ICMP 包,可以不设置 IP_HDRINCL,让内核帮你填充 IP 头部。只有当你需要自定义 IP 选项、修改 TTL 到特殊值、或者伪造源 IP 时,才需要设置这个选项。
  • 接收数据包时注意过滤:原始套接字会收到所有匹配协议的数据包,包括发往其他进程的。你需要根据源 IP、目的端口等字段自行过滤。我在写一个抓包工具时就因为没做过滤,结果收到了自己发出去的包,形成了死循环。
📌 调试小技巧: 用 Wireshark 抓包验证你构造的数据包。我每次写完原始套接字的代码,第一件事就是开 Wireshark 看包结构对不对。这比看代码快多了。

嗯,原始套接字的内容就讲到这里。你想想看,掌握了这些,你就能自己实现一个 ping 命令、一个 traceroute、甚至一个简单的网络嗅探器。下一章我们会继续深入,看看如何用原始套接字实现更高级的网络功能。


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