原始套接字:从底层开始造数据包
说实话,很多搞了几年网络编程的朋友,一听到「原始套接字」就有点发怵。我当年刚接触时也一样,总觉得这东西太底层、太复杂。但后来在做一个自定义协议的项目时,被逼着啃了下来——嗯,从那以后,我发现原始套接字其实没那么神秘,反而特别有意思。
今天我们就来聊聊原始套接字的创建、IP_HDRINCL 选项,以及怎么亲手构造一个 IP 数据包。说白了,就是让你能像操作系统一样,自己拼装网络数据。
什么是原始套接字?
普通的套接字(比如 SOCK_STREAM 或 SOCK_DGRAM),你只管发数据,协议头(TCP/UDP 头、IP 头)由内核帮你搞定。但原始套接字不一样——它让你直接操作 IP 层,甚至链路层。
我个人的理解是:原始套接字 = 你接管了协议栈的部分控制权。你可以自己构造 IP 头、自己计算校验和、自己决定分片策略。当然,这也意味着你要为错误负责。
核心能力:
- 可以发送自定义的 IP 数据包(包括伪造源地址)
- 可以接收所有 IP 数据包(不只是发给本机的)
- 可以绕过 TCP/UDP 协议栈,直接操作传输层
创建原始套接字
创建原始套接字的 API 和普通套接字一样,只是参数不同。看代码:
#include <sys/socket.h>
#include <netinet/ip.h> // IP 头结构
#include <netinet/udp.h> // UDP 头结构
int sockfd = socket(AF_INET, SOCK_RAW, protocol);
这里的关键是第三个参数 protocol。它指定了你想要处理的协议类型。常见的值有:
| 协议 | protocol 值 | 说明 |
|---|---|---|
| ICMP | IPPROTO_ICMP (1) | ping 用的协议 |
| TCP | IPPROTO_TCP (6) | 可以构造 TCP 包 |
| UDP | IPPROTO_UDP (17) | 可以构造 UDP 包 |
| IP | IPPROTO_RAW (255) | 自己处理所有 IP 协议 |
注意:创建原始套接字需要 root 权限。我在项目中遇到过,第一次在普通用户下运行,直接返回 EPERM 错误。所以记得用 sudo 或者 setuid。
IP_HDRINCL 选项:谁负责 IP 头?
默认情况下,即使你用了原始套接字,内核还是会帮你填充 IP 头的一部分(比如源地址、校验和)。但如果你设置了 IP_HDRINCL 选项,内核就完全放手了——IP 头全部由你负责。
我曾经在一个安全工具项目中踩过这个坑:没设置 IP_HDRINCL,结果我构造的 IP 头被内核覆盖了,源地址死活改不了。后来查了半天文档才找到原因。
int optval = 1;
if (setsockopt(sockfd, IPPROTO_IP, IP_HDRINCL, &optval, sizeof(optval)) < 0) {
perror("setsockopt IP_HDRINCL failed");
exit(1);
}
设置了这个选项后,你发送的数据必须包含完整的 IP 头。内核只帮你做两件事:
- 计算 IP 头校验和(如果你把校验和字段设为 0)
- 填充目标 MAC 地址(通过 ARP)
其他所有字段——版本、头长度、服务类型、总长度、标识、标志、片偏移、TTL、协议、源地址、目标地址——全部由你指定。
我的建议:除非你特别清楚自己在做什么,否则最好设置 IP_HDRINCL。这样你能完全控制数据包,调试起来也更直观。不设置的话,内核的「好心帮忙」反而容易让你困惑。
构造 IP 数据包:手把手教你拼装
好了,现在我们来亲手构造一个 IP 数据包。我会以 UDP 为例,因为 UDP 头比 TCP 简单,适合入门。
先看 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; // 总长度(IP头 + 数据)
uint16_t id; // 标识
uint16_t frag_off; // 标志 + 片偏移
uint8_t ttl; // 生存时间
uint8_t protocol; // 上层协议(UDP=17)
uint16_t check; // 校验和(可设为0让内核计算)
uint32_t saddr; // 源地址
uint32_t daddr; // 目标地址
};
然后是 UDP 头(定义在 <netinet/udp.h>):
struct udphdr {
uint16_t source; // 源端口
uint16_t dest; // 目标端口
uint16_t len; // UDP 长度(UDP头 + 数据)
uint16_t check; // UDP 校验和(可选)
};
构造数据包的完整流程,我画了一张图帮你理解:
下面是一个完整的代码示例,演示如何构造并发送一个 UDP 数据包:
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <unistd.h>
#include <sys/socket.h>
#include <netinet/ip.h>
#include <netinet/udp.h>
#include <arpa/inet.h>
// 计算校验和的辅助函数
unsigned short checksum(unsigned short *buf, int len) {
unsigned long sum = 0;
while (len > 1) {
sum += *buf++;
len -= 2;
}
if (len) sum += *(unsigned char *)buf;
sum = (sum >> 16) + (sum & 0xFFFF);
sum += (sum >> 16);
return (unsigned short)(~sum);
}
int main() {
// 1. 创建原始套接字
int sockfd = socket(AF_INET, SOCK_RAW, IPPROTO_RAW);
if (sockfd < 0) {
perror("socket");
return 1;
}
// 2. 设置 IP_HDRINCL
int optval = 1;
setsockopt(sockfd, IPPROTO_IP, IP_HDRINCL, &optval, sizeof(optval));
// 3. 构造 IP 头
struct iphdr *iph = (struct iphdr *)malloc(sizeof(struct iphdr));
iph->version = 4;
iph->ihl = 5; // 20 字节
iph->tos = 0;
iph->tot_len = htons(sizeof(struct iphdr) + sizeof(struct udphdr) + 12);
iph->id = htons(54321);
iph->frag_off = 0;
iph->ttl = 64;
iph->protocol = IPPROTO_UDP;
iph->check = 0; // 让内核计算
iph->saddr = inet_addr("192.168.1.100");
iph->daddr = inet_addr("192.168.1.1");
// 4. 构造 UDP 头
struct udphdr *udph = (struct udphdr *)(iph + 1);
udph->source = htons(12345);
udph->dest = htons(80);
udph->len = htons(sizeof(struct udphdr) + 12);
udph->check = 0; // UDP 校验和可选
// 5. 填充数据
char *payload = (char *)(udph + 1);
strcpy(payload, "Hello, Raw!");
// 6. 发送
struct sockaddr_in dest;
dest.sin_family = AF_INET;
dest.sin_addr.s_addr = iph->daddr;
if (sendto(sockfd, iph, ntohs(iph->tot_len), 0,
(struct sockaddr *)&dest, sizeof(dest)) < 0) {
perror("sendto");
} else {
printf("数据包发送成功!\n");
}
// 7. 清理
free(iph);
close(sockfd);
return 0;
}
避坑指南:我曾经犯过一个低级错误——忘记把 IP 头的总长度字段转换成网络字节序(htons)。结果抓包一看,总长度变成了 0x0014(实际应该是 0x0024),接收端直接丢弃了数据包。记住:所有多字节字段都要用 htons/htonl 转换。
构造数据包时的常见陷阱
你想想看,既然我们绕过了内核协议栈,那很多「理所当然」的事情就得自己操心了。我总结几个常见问题:
- 校验和:IP 头校验和可以交给内核(设为 0),但 UDP/TCP 校验和需要自己算。不过 UDP 校验和是可选的(设为 0 表示不校验),但 TCP 不行。
- 字节序:IP 地址、端口号、长度字段,全部要用网络字节序。我刚开始时经常搞混,后来养成了习惯:
htonl用于 32 位,htons用于 16 位。 - 数据包长度:IP 头的
tot_len必须等于 IP 头 + UDP 头 + 数据的实际长度。少一个字节,接收端就可能解析失败。 - 分片:如果数据包超过 MTU(通常 1500 字节),需要自己处理分片。内核不会帮你做这件事。
重要提醒:原始套接字可以伪造源 IP 地址。这在某些场景下是合法的(比如网络测试工具),但也可以被用于 IP 欺骗攻击。请务必在合法授权的环境中使用,不要用来做坏事。
实战经验分享
我记得有一次,公司需要测试一个防火墙的 UDP 包过滤规则。用普通的 UDP 套接字发数据,防火墙规则生效后,sendto 返回成功但收不到响应——你根本不知道是防火墙拦了,还是对端没回。
后来我用原始套接字构造了带特殊 TTL 和 ID 的 UDP 包,同时在另一台机器上抓包。通过对比发出的包和抓到的包,我就能确认防火墙是否真的丢弃了数据包。说白了,原始套接字让我有了「上帝视角」。
另外,如果你在做网络协议分析工具(比如自己写个 tcpdump),原始套接字也是必不可少的。你可以设置 IP_HDRINCL 为 0,然后接收所有 IP 数据包,自己解析协议头。
总结
原始套接字的核心就三件事:创建、设置 IP_HDRINCL、构造数据包。听起来简单,但每个环节都有细节要注意。我个人觉得,掌握原始套接字最大的价值不是「能发自定义包」,而是让你真正理解了 IP 协议栈的工作原理。
当你亲手拼出一个 IP 头,看着它在 wireshark 里被正确解析时,那种成就感——嗯,比用高级 API 调接口爽多了。
公众号:蓝海资料掘金营,微信deep3321