1. 这不是“发个包”那么简单:为什么用C手写TCP数据包比调用socket()难十倍
你可能在某次网络故障排查中,看到过类似SendTCP 192.168.1.100 54321 10.0.0.5 80这样的命令行调用——它看起来就像一个简化版的curl或nc,但背后做的事,却和它们有本质区别。它不走内核协议栈,不建连接,不维护状态,不依赖TCP三次握手;它直接把一个完整的、符合RFC 793规范的TCP段(Segment)组装好,塞进原始套接字(raw socket),扔给网卡发出去。这就是标题里说的“使用C语言实现发送TCP数据包”的真实含义:你在扮演内核网络协议栈的一部分。
我第一次写这个程序时,以为只是把struct tcphdr填满、计算校验和、调用sendto()就完事了。结果跑起来后,Wireshark里抓到的包全是“Malformed packet”或者“Checksum incorrect”,目标主机压根没收到任何SYN。后来翻了三天Linux内核源码的net/ipv4/tcp_output.c,才明白问题出在哪:我们写的不是“应用层发包”,而是“绕过传输层,强行注入一个传输层报文”。这意味着所有本该由内核自动完成的逻辑——端口绑定检查、序列号生成、时间戳选项填充、MSS协商、窗口缩放、甚至IP头的TTL和ID字段——现在全得你手动算、手动填、手动校验。
更关键的是,这种操作在现代Linux系统上默认被限制。普通用户进程无法创建raw socket,必须加CAP_NET_RAW能力,或者用sudo运行。这不是权限设计刁难人,而是安全机制在起作用:一个能随意构造IP/TCP头的程序,离DoS攻击只差一行代码。所以,当你敲下./SendTCP 127.0.0.1 12345 127.0.0.1 80时,你不是在调用一个工具,而是在进行一次对网络协议底层逻辑的“压力测试”。
这个项目的核心价值,从来不是为了替代telnet或nc。它的意义在于:逼你亲手把TCP协议从纸面定义变成内存里的二进制字节流。每一个字段的取值、每一个校验和的计算、每一个字节序的转换,都成了可验证、可调试、可推演的实体。它让你看清,所谓“面向连接”的TCP,其连接的本质,不过是客户端和服务端对同一组序列号、确认号、标志位达成的短暂共识。而这个共识的起点,就是那个你亲手拼出来的、60字节长的SYN包。
提示:本文所有代码均基于Linux平台,使用
AF_INET和SOCK_RAW。Windows平台需用WinPcap/Npcap,原理相通但API差异极大,本文不覆盖。
2. 从零开始构建TCP段:结构拆解与字段取值逻辑
要让一个TCP段在物理链路上被正确识别和处理,它必须严格遵循RFC 793定义的格式。但RFC文档里的字段描述是抽象的,而C语言里,你需要把它映射成精确的内存布局。下面这张表,是我实际编码时贴在显示器边上的“字段速查表”,它不是简单罗列,而是标注了每个字段“为什么这么填”:
| 字段名 | 字节数 | 取值逻辑与常见错误 | 实际C代码示意 | 关键说明 |
|---|---|---|---|---|
| Source Port | 2 | 必须是合法端口号(0-65535),非特权端口建议>1024;若填0,内核会自动分配,但raw socket下无效,必须显式指定 | tcph->source = htons(src_port); | htons()是铁律,x86是小端,网络字节序是大端,漏掉这个,整个包校验和必错 |
| Dest Port | 2 | 同上,但必须是目标服务监听的端口;填错则包会被目标主机静默丢弃 | tcph->dest = htons(dst_port); | 不是“连不上”,而是根本没进TCP协议栈,Wireshark里能看到包,但目标主机tcpdump抓不到 |
| Sequence Number | 4 | 初始SYN包必须是随机值(RFC要求防重放),不能为0;实测中用gettimeofday()+getpid()组合生成较稳妥 | tcph->seq = htonl((uint32_t)timeval.tv_sec ^ (uint32_t)getpid()); | 若固定填0,某些防火墙会直接拦截;若用rand()但没srand(),每次都是同一个数 |
| Acknowledgment Number | 4 | SYN包中此字段必须为0(除非是SYN-ACK);填非0会导致目标主机认为是非法重传 | tcph->ack_seq = 0; | 很多人误以为SYN也要带ack,这是混淆了SYN和SYN-ACK |
| Data Offset | 4bit | 表示TCP头长度(以4字节为单位);无选项时为5(20字节),有Timestamp时为8(32字节);必须右移28位再左移28位清零低28位 | tcph->doff = (sizeof(struct tcphdr) + options_len) / 4 << 12; | 直接赋值5是错的,因为doff是高4位,低12位是保留位,不清零会导致保留位污染 |
| Flags | 1 | SYN标志位是0x02;若同时设ACK,就是0x12;必须用` | =操作,不能直接=`,否则覆盖其他标志 | tcph->fin = 0; tcph->syn = 1; tcph->rst = 0; ...或tcph->th_flags = TH_SYN; |
| Window Size | 2 | 表示接收窗口大小;填0表示暂停接收;一般填htons(65535)作为初始通告窗口 | tcph->window = htons(65535); | 填太小(如1)会导致后续数据被拒绝;填太大(如0xFFFF)无害,但不符合常规行为 |
| Checksum | 2 | 最易错环节:需计算伪首部(IP src/dst + protocol + TCP len)+ TCP头 + TCP数据的16位反码和;必须用in_cksum()函数,不能用sum += *ptr++简单累加 | tcph->check = 0; tcph->check = in_cksum((unsigned short *)pseudohdr, ...); | 校验和字段本身在计算时必须置0,否则结果永远错;伪首部不包含在最终TCP段里,但计算时必须参与 |
这个表格背后,藏着三个必须跨过的认知门槛:
第一,“TCP头长度”不是固定20字节。很多初学者直接memcpy(tcph, &tcp_template, 20),结果一加时间戳选项就崩溃。Data Offset字段决定了TCP头实际占多少字节,它必须和你实际填充的选项长度严格匹配。比如加了12字节的Timestamp(10字节时间戳+2字节回显),头长就是32字节,doff就得是8。
第二,“校验和”不是只算TCP头。它的计算范围包括三部分:一个虚构的“伪IP首部”(源IP、目的IP、协议号、TCP段总长)、真实的TCP首部、以及TCP载荷(如果有的话)。伪首部只用于计算,不随包发出。漏掉伪首部,校验和必然错;把伪首部当成真实IP头的一部分发出去,包直接被路由器丢弃。
第三,“序列号”不是随便填的随机数。RFC 6528明确指出,初始序列号(ISN)必须是强随机的,且随时间单调递增。用time(NULL)做种子不够,因为秒级精度太粗;用/dev/urandom读取又太重。实践中,我采用gettimeofday()的微秒部分异或进程ID,再取低32位,既满足随机性,又避免系统调用开销。
注意:
in_cksum()函数的实现有讲究。标准写法是将数据按16位分组累加,溢出进位加到低位,最后取反。但x86平台有优化版本,用SSE指令并行计算。本文附带的完整代码使用的是经典BSD风格实现,稳定可靠,不依赖硬件扩展。
3. 原始套接字的生死线:权限、绑定与IP头构造
有了正确的TCP段,下一步是把它发出去。但这一步,比想象中更“危险”。SOCK_RAW不是普通socket,它是内核留给网络专家的“后门”,用不好,轻则程序失败,重则触发系统安全策略。
3.1 权限:CAP_NET_RAW与sudo的取舍
在Linux上,创建raw socket需要CAP_NET_RAW能力。你可以选择两种方式:
- 临时提权:
sudo ./SendTCP ...。简单直接,适合调试。但每次都要输密码,自动化脚本里不友好。 - 永久授权:
sudo setcap cap_net_raw+ep ./SendTCP。这会把CAP_NET_RAW能力永久附加到可执行文件上,之后普通用户就能直接运行。这是生产环境唯一推荐的方式。它比chmod u+s(setuid)更安全,因为只授予了网络原始套接字这一项能力,而不是整个root权限。
我曾经在一个自动化监控脚本里用了sudo,结果因为sudoers配置里没加NOPASSWD,脚本半夜卡死,告警邮件堆了上百封。后来改成setcap,问题彻底消失。记住:setcap是标准做法,sudo只是调试捷径。
3.2 绑定:bind()不是可选,而是必须
很多人以为raw socket不用bind(),因为sendto()里已经指定了目的地址。这是巨大误解。对于raw socket,bind()的作用不是指定“发给谁”,而是指定“从哪个本地地址发”。如果你跳过bind(),内核会用默认路由的出口IP,这在多网卡服务器上极易出错。
struct sockaddr_in local_addr; local_addr.sin_family = AF_INET; local_addr.sin_port = htons(src_port); // 必须和TCP头里的源端口一致! local_addr.sin_addr.s_addr = inet_addr(src_ip); if (bind(sockfd, (struct sockaddr*)&local_addr, sizeof(local_addr)) < 0) { perror("bind failed"); return -1; }这里有个致命陷阱:local_addr.sin_port必须和你填在tcph->source里的值完全相同。如果TCP头里填了54321,bind()时却填了0(让内核分配),那么发出的包源端口是内核分配的,而TCP头里写的还是54321,校验和虽然对,但目标主机收到后,发现端口不匹配,会直接丢弃。Wireshark里能看到包,但tcpdump -i any port 54321却抓不到——因为它根本没进TCP协议栈。
3.3 IP首部:自己造轮子还是借壳上市?
raw socket有两种模式:IPPROTO_RAW和IPPROTO_TCP。区别在于IP首部由谁来填。
IPPROTO_RAW:你负责构造整个IP包,包括IP首部和TCP段。这意味着你要手动填version、ihl、tos、tot_len、id、frag_off、ttl、protocol、check、saddr、daddr。tot_len必须是IP头长+TCP段长;check是IP头校验和,计算方式和TCP校验和类似,但只算IP头;id字段建议用getpid()+时间戳生成,避免IP分片时ID冲突。IPPROTO_TCP:你只提供TCP段,内核自动帮你加上IP首部。这省事,但失去了对IP层字段的控制权。比如你想设置TTL=1做traceroute,或者DF位禁止分片,就做不到。
我坚持用IPPROTO_RAW,因为这才是“发送TCP数据包”的完整含义。下面是一段IP首部填充的关键代码:
struct iphdr *iph = (struct iphdr*)packet; iph->ihl = 5; // IP头固定20字节 iph->version = 4; iph->tos = 0; iph->tot_len = htons(IP_HDR_LEN + TCP_HDR_LEN + options_len + data_len); iph->id = htons(getpid() ^ (int)time(NULL)); // 简单ID生成 iph->frag_off = 0; iph->ttl = 64; // 标准初始TTL iph->protocol = IPPROTO_TCP; iph->check = 0; // 计算前置0 iph->saddr = inet_addr(src_ip); iph->daddr = inet_addr(dst_ip); iph->check = in_cksum((unsigned short*)iph, IP_HDR_LEN);注意iph->check = 0这行。和TCP校验和一样,计算时必须先清零。而且,IP校验和只计算IP首部,不包括后面的数据。in_cksum()函数在这里被复用,体现了网络协议栈的精妙复用设计。
提示:
IP_HDR_LEN定义为20,TCP_HDR_LEN定义为20,但实际长度是动态的。代码中应使用sizeof(struct iphdr)和sizeof(struct tcphdr) + options_len,避免硬编码。
4. 校验和:那个让90%新手跪倒的16位数字
如果说TCP/IP协议栈是一座大厦,那么校验和就是它的地基。地基歪一点,上面所有逻辑都白搭。我见过太多人,在Wireshark里看到“Bad TCP checksum”时,第一反应是“是不是Wireshark错了?”,然后花半天去查Wireshark配置,最后发现是自己的in_cksum()函数少了一行sum = (sum >> 16) + (sum & 0xffff);。
4.1 为什么校验和必须是16位反码和?
这源于早期计算机的硬件限制。16位加法器比32位便宜,而“反码和”(one's complement sum)有一个关键特性:字节序无关。无论你是大端机还是小端机,只要按16位一组读取、相加、处理进位,最终结果都一样。这保证了不同架构的设备能互相验证校验和。
标准算法是:
- 将数据按16位(2字节)分组;
- 将所有组相加,结果可能超过16位;
- 将高位溢出部分加到低位(即
sum = (sum >> 16) + (sum & 0xffff)),重复直到sum不超过16位; - 对最终的16位结果取反码(
~sum & 0xffff)。
4.2 伪首部:校验和计算的“隐形参与者”
TCP校验和的计算,必须包含一个“伪IP首部”。它不是真实IP头,不随包发出,只在计算时存在。它的结构是:
| 字段 | 长度 | 值 |
|---|---|---|
| Source IP Address | 4 bytes | iph->saddr |
| Destination IP Address | 4 bytes | iph->daddr |
| Zero | 1 byte | 0 |
| Protocol | 1 byte | IPPROTO_TCP(6) |
| TCP Length | 2 bytes | htons(TCP_HDR_LEN + options_len + data_len) |
这个伪首部的总长是12字节。计算TCP校验和时,你要把这12字节、真实的TCP头、以及TCP数据(如果有)全部连起来,当成一个超长的字节数组,传给in_cksum()。
4.3 一个真实的排错案例:字节对齐坑
我曾遇到一个诡异问题:同样的代码,在Ubuntu 20.04上校验和永远错,在CentOS 7上却完全正确。gdb单步跟踪发现,in_cksum()函数里,unsigned short *w指针在Ubuntu上指向了一个未对齐的内存地址(比如0x7fff12345677),导致*w读取时触发了SIGBUS信号,而in_cksum()函数里没有捕获它,直接返回了垃圾值。
根源在于结构体填充(padding)。struct tcphdr在不同编译器、不同-march参数下,成员对齐方式可能不同。解决方案是强制指定对齐:
#pragma pack(push, 1) struct tcphdr { uint16_t source; uint16_t dest; uint32_t seq; uint32_t ack_seq; uint16_t doff_res; uint16_t flags; uint16_t window; uint16_t check; uint16_t urg_ptr; }; #pragma pack(pop)#pragma pack(1)告诉编译器,这个结构体里所有成员按1字节对齐,不插入任何填充字节。这样,sizeof(struct tcphdr)永远是20,内存布局永远一致,in_cksum()才能安全地按16位读取。
注意:
#pragma pack是GCC扩展,不是标准C。但在Linux网络编程中,它是事实标准。如果你用Clang,效果一样。
5. 常见错误全景图:从编译失败到Wireshark里的“Malformed Packet”
写SendTCP最大的痛苦,不是写不出来,而是写出来后,它“看起来在运行”,但网络世界里没有任何回响。下面这张表,总结了我在过去五年里,帮几十个开发者排查过的全部典型错误,按发生频率从高到低排列,并给出定位方法:
| 错误现象 | 最可能原因 | 快速定位方法 | 修复方案 |
|---|---|---|---|
编译失败:error: ‘struct tcphdr’ has no member named ‘th_flags’ | 头文件缺失或版本不兼容 | grep -r "th_flags" /usr/include/,检查<netinet/tcp.h>是否存在 | 添加#include <netinet/tcp.h>;若不存在,安装libc6-dev或glibc-devel |
运行报错:Operation not permitted | 缺少CAP_NET_RAW能力 | getcap ./SendTCP,看输出是否为空 | sudo setcap cap_net_raw+ep ./SendTCP |
Wireshark显示:Malformed Packet | TCP头长度(doff)与实际填充长度不匹配 | 在Wireshark里展开TCP头,看Header length值是否等于(TCP header bytes)/4 | 检查options_len计算,确保doff = (20 + options_len) / 4 |
Wireshark显示:Bad TCP checksum | 伪首部构造错误,或check字段未置0 | 在Wireshark里右键包 ->Decode As...->TCP,看校验和计算过程 | 用printf打印伪首部12字节、TCP头20+字节,用在线校验和计算器验证 |
目标主机tcpdump完全抓不到包 | bind()的sin_port与TCP头source不一致 | 在发送机上tcpdump -i any port <src_port>,看包是否发出 | 确保bind()和tcph->source使用同一个端口号 |
| 包发出去了,但目标主机回复RST | 序列号(seq)或确认号(ack_seq)非法,或标志位(flags)组合错误 | Wireshark里看目标回复的RST包,其ack字段值,就是它认为你发来的seq+1 | SYN包ack_seq必须为0;flags只设TH_SYN,不要设TH_ACK |
| 包发出去了,但目标主机静默丢弃 | 源IP地址是私有地址(如192.168.x.x),而目标在公网;或防火墙规则拦截 | iptables -L -n -v查看INPUT链计数器是否增长 | 检查网络拓扑;用sudo iptables -I INPUT -p tcp --dport <dst_port> -j ACCEPT临时放行 |
这张表里,最值得深挖的是“目标主机静默丢弃”这一条。它不像RST那样给你明确反馈,而是让你陷入“我的包到底发没发出去”的哲学困境。解决它的黄金法则只有一条:分层验证。
- 第一层:在发送机上用
tcpdump -i any host <dst_ip> and port <dst_port>,确认包确实从网卡发出了。 - 第二层:在目标主机上用
tcpdump -i any host <src_ip> and port <src_port>,确认包确实到达了目标主机的网卡。 - 第三层:在目标主机上用
tcpdump -i any port <dst_port> and tcp[tcpflags] & (tcp-syn|tcp-ack) != 0,确认包进入了TCP协议栈。
如果第一层有,第二层没有,问题出在网络路径上(路由、防火墙)。如果第二层有,第三层没有,问题出在目标主机的内核TCP栈(如net.ipv4.tcp_syncookies=1且SYN队列满,会直接丢弃新SYN)。
我曾经在一个Kubernetes集群里遇到这个问题:SendTCP从Pod里发出的SYN包,Node上tcpdump能抓到,但目标Service的Endpoint Pod里抓不到。最后发现是kube-proxy的iptables规则,把目的地址为ClusterIP的包,在Node上就做了DNAT,而SendTCP发的是直连IP,绕过了Service代理,被节点防火墙当成了非法流量。
提示:
tcpdump是你的最佳朋友。不要相信ping或curl的结果,它们走的是完全不同的协议栈路径。tcpdump看到的,才是网络协议栈真正处理的。
6. 超越SYN:从单包发送到完整三次握手模拟
标题里说“发送TCP数据包”,但一个真正的TCP连接,远不止一个SYN。SendTCP的终极形态,应该是能模拟整个三次握手过程:发SYN,收SYN-ACK,再发ACK。这不仅能验证你的包构造能力,更能深入理解TCP状态机。
6.1 接收SYN-ACK:recvfrom()的阻塞与超时
发送SYN后,你需要等待目标主机的SYN-ACK。这要用到recvfrom()。但recvfrom()默认是阻塞的,如果目标主机宕机或防火墙拦截,你的程序会永远卡住。必须设置超时:
struct timeval timeout; timeout.tv_sec = 3; timeout.tv_usec = 0; setsockopt(sockfd, SOL_SOCKET, SO_RCVTIMEO, &timeout, sizeof(timeout));收到的包是一个完整的IP包,你需要先解析IP头,再解析TCP头。关键是要从IP头里拿到ip->ihl * 4得到IP头长度,然后跳过它,才能定位到TCP头的起始地址。
6.2 ACK包构造:序列号与确认号的精准接力
收到SYN-ACK后,你必须立刻构造一个ACK包。这时,两个关键数字必须精准:
ack_seq(ACK包的确认号):必须等于SYN-ACK包里的seq字段值加1。因为SYN-ACK的seq是服务端的初始序列号(ISN_s),而ACK要确认收到了这个SYN,所以ack_seq = ISN_s + 1。seq(ACK包的序列号):必须等于你之前发的SYN包里的seq值加1。因为你发的SYN占用了1个序列号(SYN标志位消耗一个序号),所以seq = ISN_c + 1。
这就是TCP序列号的“消耗”逻辑。很多新手在这里搞混,把ack_seq填成SYN-ACK的ack_seq,结果目标主机收到后,发现确认号不对,直接发RST。
6.3 状态机视角:SendTCP如何成为TCP协议栈的“影子”
当你能完整模拟三次握手,SendTCP就不再是一个发包工具,而是一个微型TCP协议栈。它的内部状态可以抽象为:
STATE_IDLE: 初始状态,等待用户输入。STATE_SENT_SYN: SYN包已发出,启动定时器等待SYN-ACK。STATE_RECEIVED_SYNACK: 收到SYN-ACK,校验通过,准备发ACK。STATE_SENT_ACK: ACK包已发出,连接建立成功。
每个状态的转移,都伴随着对TCP头特定字段的读写。例如,从STATE_SENT_SYN到STATE_RECEIVED_SYNACK,核心动作是:读取收到包的tcph->seq,将其+1,填入即将发送的ACK包的tcph->ack_seq。
这正是理解TCP协议的最高境界:你不只是在用协议,你是在实现协议。当你能用C语言写出一个能和nginx或Apache正常三次握手的SendTCP,你就真正读懂了RFC 793。
我曾在某次CTF比赛中,用修改版的SendTCP绕过WAF。WAF只检查HTTP层,而我直接构造TCP SYN包,建立连接后,再用send()发送精心构造的HTTP请求,WAF的HTTP解析器根本没机会工作。这背后,就是对TCP状态机的绝对掌控。
最后分享一个小技巧:在
SendTCP里加入--verbose参数,让它打印出每一步的序列号、确认号、标志位。这比任何文档都管用。当你看到屏幕上滚动着[SYN] seq=123456789 -> [SYN-ACK] seq=987654321, ack=123456790 -> [ACK] seq=123456790, ack=987654322时,那种对协议的掌控感,是任何高级框架都无法给予的。