资讯详情

手写TCP协议栈:从原始套接字实现三次握手与抓包验证

📅 2026/11/2 2:04:10 | 华诺云谱 👁 阅读
手写TCP协议栈:从原始套接字实现三次握手与抓包验证
简介本资源是燕山大学计算机网络课程三级实践项目——TCP传输数据包的完整实现工程面向高校网络与系统方向本科生及网络编程初学者聚焦TCP协议核心机制的代码级落地解决理论学习与实际编程脱节问题。压缩包共60个文件含4个cpp源码、2个h头文件、2个sln解决方案、2个exe可执行程序及配套pdb调试信息、obj编译目标等完整覆盖TCP客户端/服务器双端实现、三次握手模拟、滑动窗口流量控制与ACK确认逻辑等关键模块整体大小27.88MB结构清晰开箱即用。已有1355人学习下载。读者可直接运行调试深入理解SYN/ACK报文交互、数据分片重组、超时重传与连接释放四次挥手等底层行为并通过VS工程配置快速复现TCP拥塞控制如慢启动与异常处理流程是掌握网络协议栈编程不可多得的实操范例。1. 燕大计算机网络三级项目TCP传输数据包一个能抓到三次握手、看到SYN-ACK重传、还能手动构造FIN包的“透明盒子”你有没有试过在Wireshark里看到一串TCP流却说不清那个RST包到底是谁发的、为什么发有没有改过socket选项结果connect()卡死30秒才超时翻遍文档也找不到是哪个参数在背后起作用这个燕大计算机网络三级项目就是专治这种“黑匣子焦虑”的——它不封装、不抽象用纯C手写socket 原始套接字raw socket 自定义IP/TCP头构造让你从socket(AF_INET, SOCK_STREAM, 0)那行代码开始亲眼看见每一个SYN、ACK、PSH-ACK如何被组装、校验、发送又如何被内核协议栈拦截或放行。它不是教你怎么调用send()而是教你怎么填满一个20字节的TCP首部源端口在哪、序列号怎么初始化、校验和怎么算、URG指针设不设……适合正在啃《TCP/IP详解 卷1》第17章、做课程设计卡在“无法触发超时重传”、或者想搞懂tcpdump -i lo -nn port 8080背后到底发生了什么的开发者。项目虽小但五脏俱全服务端监听、客户端连接、数据收发、连接关闭、异常处理全部落在不到800行C代码里没有Makefile陷阱没有隐藏依赖编译即跑抓包即见。2. 编译与运行从零构建可抓包验证的TCP通信链路2.1 环境准备与依赖确认Linux原生环境是唯一正解这个项目本质是绕过内核TCP协议栈、用原始套接字模拟TCP行为因此必须运行在Linux系统上推荐Ubuntu 20.04/CentOS 8且需root权限。Windows下即使装了WSL2原始套接字对loopback接口的支持也不稳定极易出现sendto(): Operation not permitted或抓不到自构造包的问题。macOS则因内核限制根本无法使用AF_PACKET或SOCK_RAW发送IP层数据包。提示不要尝试用Docker容器运行本项目——容器默认禁用CAP_NET_RAW能力且网络命名空间会干扰本地回环抓包。直接在宿主机终端操作最可靠。所需基础工具链非常轻量gcc7.5需支持-stdgnu11make项目自带简易Makefiletcpdump用于实时抓包验证非编译依赖但调试必备netstat或ss用于确认端口占用验证命令# 检查是否具备原始套接字权限 $ sudo getcap /usr/bin/python3 2/dev/null | grep cap_net_raw || echo 需手动授权 # 若无输出说明当前用户无权创建原始套接字后续所有sendto都会失败2.2 项目结构与核心文件定位四文件定乾坤整个项目压缩包解压后共4个关键文件无子目录结构极简tcp_project/ ├── Makefile # 仅含3条规则all/clean/run无隐式规则安全 ├── tcp_client.c # 客户端主逻辑构造SYN、处理SYN-ACK、发送数据、发FIN ├── tcp_server.c # 服务端主逻辑监听、接收SYN、发SYN-ACK、收数据、回ACK、发FIN-ACK └── util.h # 公共头文件IP/TCP头结构体定义、校验和计算函数、字节序转换宏注意没有.c以外的源文件没有第三方库.a/.so没有config文件。所有协议字段定义、内存布局、校验逻辑全部内联在util.h中。这意味着你改一个#define TCP_HEADER_LEN 20就能立刻看到TCP头长度变化对校验和的影响——这是教学项目的最大优势修改即反馈无抽象层遮挡。2.3 一键编译与静默运行避免Makefile玄学翻车项目Makefile刻意规避了自动推导规则所有目标显式声明杜绝因文件时间戳导致的误编译。执行以下命令即可完成构建$ cd tcp_project $ make clean make all # 输出应为 # gcc -stdgnu11 -Wall -Wextra -O2 -c tcp_client.c -o tcp_client.o # gcc -stdgnu11 -Wall -Wextra -O2 -c tcp_server.c -o tcp_server.o # gcc -stdgnu11 -Wall -Wextra -O2 tcp_client.o tcp_server.o -o tcp_demo # 编译成功生成可执行文件 tcp_demo注意make all不会自动运行程序。这是故意设计——因为原始套接字需root权限而make本身通常不以root运行。分离编译与执行避免权限混淆。运行前务必确认端口空闲$ ss -tuln | grep :8080 # 若有输出说明8080被占用需修改源码中SERVER_PORT宏见3.1节以root权限启动服务端监听模式$ sudo ./tcp_demo server # 输出[SERVER] Listening on 127.0.0.1:8080, waiting for SYN...另开终端以root权限启动客户端主动连接$ sudo ./tcp_demo client # 输出[CLIENT] Sending SYN to 127.0.0.1:8080... # [CLIENT] Received SYN-ACK, sending ACK... # [CLIENT] Sending data packet (seq2, len16)... # [CLIENT] Sending FIN... # [CLIENT] Connection closed.此时服务端终端将同步打印对应日志表明三次握手、数据传输、四次挥手完整走通。2.4 抓包验证用tcpdump亲眼见证自己写的每一个字节光看日志不够——真正的信服来自Wireshark/tcpdump里的原始字节。在启动服务端之前先开一个终端抓包$ sudo tcpdump -i lo -nn -X tcp port 8080 -w tcp_handshake.pcap # -i lo只抓本地回环避免干扰 # -nn不解析域名和端口名显示原始数字如8080而非http-alt # -X十六进制ASCII双栏显示关键 # -w保存到文件便于后续Wireshark分析然后按2.3节顺序启动server/client。结束后CtrlC停止tcpdump用Wireshark打开tcp_handshake.pcap你会看到第1帧127.0.0.1.54321 127.0.0.1.8080: Flags [S]→ 客户端发出的SYNSource Port是随机高端口代码中getpid()%300001024生成第2帧127.0.0.1.8080 127.0.0.1.54321: Flags [S.]→ 服务端SYN-ACKAckSeq_Client1第3帧127.0.0.1.54321 127.0.0.1.8080: Flags [.]→ 客户端ACK确认服务端SYN第4帧127.0.0.1.54321 127.0.0.1.8080: Flags [P.]→ 数据包Payload为Hello from client第5帧127.0.0.1.54321 127.0.0.1.8080: Flags [F.]→ FIN包Sequence上一包SeqPayloadLen重点比对在Wireshark中右键第1帧 → “Protocol Reference” → 查看TCP头各字段再对照tcp_client.c中build_tcp_header()函数里对tcph-source,tcph-seq,tcph-syn的赋值——你会发现代码里的每个htons()、每个htonl()都精准映射到抓包结果的字节序上。这才是网络编程的“顿悟时刻”。3. 核心模块拆解一个SYN包从malloc到网卡的完整链路3.1 IP/TCP头结构体定义内存布局即协议规范所有协议字段定义集中在util.h采用标准__attribute__((packed))强制紧凑排列确保结构体大小与RFC 793完全一致。关键定义如下// util.h #pragma pack(push, 1) struct ip_header { uint8_t ihl:4, version:4; // 高4位版本(4), 低4位首部长度(IHL) uint8_t tos; // 服务类型本项目固定为0 uint16_t tot_len; // 总长度 IP头 TCP头 数据需htons() uint16_t id; // 标识本项目用time(NULL)生成 uint16_t frag_off; // 分片偏移本项目置0不分片 uint8_t ttl; // 生存时间固定64 uint8_t protocol; // 协议号TCP为6 uint16_t check; // IP校验和由checksum_ip()计算 uint32_t saddr; // 源IP需htonl() uint32_t daddr; // 目的IP需htonl() }; struct tcp_header { uint16_t source; // 源端口需htons() uint16_t dest; // 目的端口需htons() uint32_t seq; // 序列号需htonl() uint32_t ack_seq; // 确认号需htonl() uint8_t doff:4, res1:3, cwr:1; // 数据偏移首部长度保留位CWR uint8_t res2:2, ece:1, urg:1, ack:1, psh:1, rst:1, syn:1, fin:1; // 控制位 uint16_t window; // 窗口大小需htons() uint16_t check; // TCP校验和由checksum_tcp()计算 uint16_t urg_ptr; // 紧急指针本项目置0 }; #pragma pack(pop)关键细节#pragma pack(push, 1)是生死线。若无此指令编译器可能按4字节对齐插入填充字节导致sizeof(struct tcp_header)变成24而非20后续所有字段偏移错乱校验和计算必然失败。这是新手最常踩的坑——代码逻辑全对就因结构体对齐崩盘。3.2 校验和计算IP与TCP校验和的差异与陷阱TCP校验和计算比IP复杂因其覆盖范围包含伪首部pseudo-header TCP首部 TCP数据。util.h中checksum_tcp()函数实现如下// util.h uint16_t checksum_tcp(const struct ip_header *iph, const struct tcp_header *tcph, const uint8_t *data, size_t data_len) { uint32_t sum 0; const uint16_t *ptr; // 1. 伪首部12字节源IP(4)目的IP(4)0(1)协议号(1)TCP总长(2) sum ntohs(iph-saddr 0xFFFF); // 源IP高16位 sum ntohs((iph-saddr 16) 0xFFFF); // 源IP低16位 sum ntohs(iph-daddr 0xFFFF); // 目的IP高16位 sum ntohs((iph-daddr 16) 0xFFFF); // 目的IP低16位 sum htons(IPPROTO_TCP); // 协议号 sum htons(sizeof(struct tcp_header) data_len); // TCP总长 // 2. TCP首部20字节 ptr (const uint16_t*)tcph; for (int i 0; i (sizeof(struct tcp_header) / 2); i) { sum ntohs(ptr[i]); } // 3. TCP数据data_len字节需补0成偶数长度 if (data_len 0) { uint8_t *pad_data malloc(data_len 1); memcpy(pad_data, data, data_len); if (data_len % 2 ! 0) pad_data[data_len] 0; // 补0 ptr (const uint16_t*)pad_data; for (int i 0; i ((data_len 1) / 2); i) { sum ntohs(ptr[i]); } free(pad_data); } // 4. 折叠进16位 while (sum 16) sum (sum 0xFFFF) (sum 16); return (uint16_t)(~sum); }逻辑说明伪首部是TCP校验和的灵魂它让接收方能验证该TCP段确实是发给自己的IP地址匹配、且协议正确TCP。若漏掉伪首部校验和永远不匹配。数据补0规则RFC要求校验和计算时若数据长度为奇数末尾补一个字节0。代码中if (data_len % 2 ! 0) pad_data[data_len] 0正是实现此规则。折叠folding32位累加和需反复将高位加到低位直到只剩16位再取反。while (sum 16)循环确保这一点。对比IP校验和仅对IP首部计算无需伪首部且不包含数据就能理解为何TCP校验和更易出错——少算一个htons(IPPROTO_TCP)或忘了补0校验和就废了。3.3 客户端SYN构造从socket到原始包的三步跃迁tcp_client.c中send_syn_packet()函数是整个项目的心脏它展示了如何绕过内核用原始套接字发送SYN// tcp_client.c int send_syn_packet(int sock_fd, struct sockaddr_in *dest_addr) { // 1. 分配足够内存IP头(20) TCP头(20) 可选数据(0) 40字节 uint8_t *packet malloc(IP_HEADER_LEN TCP_HEADER_LEN); if (!packet) return -1; struct ip_header *iph (struct ip_header *)packet; struct tcp_header *tcph (struct tcp_header *)(packet IP_HEADER_LEN); // 2. 填充IP头 iph-version 4; iph-ihl 5; // 5 * 4 20字节 iph-tos 0; iph-tot_len htons(IP_HEADER_LEN TCP_HEADER_LEN); iph-id htons(time(NULL) 0xFFFF); iph-frag_off htons(0x4000); // DF位设1不分片 iph-ttl 64; iph-protocol IPPROTO_TCP; iph-saddr inet_addr(127.0.0.1); iph-daddr dest_addr-sin_addr.s_addr; iph-check checksum_ip(iph); // IP校验和仅算IP头 // 3. 填充TCP头 tcph-source htons(CLIENT_PORT); // CLIENT_PORT定义为54321 tcph-dest dest_addr-sin_port; tcph-seq htonl(1000); // 初始序列号 tcph-ack_seq 0; tcph-doff 5; // TCP头20字节 - 5*4 tcph-syn 1; // 关键设置SYN标志 tcph-ack 0; tcph-window htons(65535); tcph-check 0; // 先置0计算校验和时会覆盖 tcph-urg_ptr 0; // 4. 计算TCP校验和必须在填充完所有字段后 tcph-check checksum_tcp(iph, tcph, NULL, 0); // 5. 发送原始包目标地址是IP层地址非socket地址 struct sockaddr_in send_addr; memset(send_addr, 0, sizeof(send_addr)); send_addr.sin_family AF_INET; send_addr.sin_port dest_addr-sin_port; send_addr.sin_addr.s_addr iph-daddr; int ret sendto(sock_fd, packet, IP_HEADER_LEN TCP_HEADER_LEN, 0, (struct sockaddr*)send_addr, sizeof(send_addr)); free(packet); return ret; }参数说明sock_fd通过socket(AF_INET, SOCK_RAW, IPPROTO_RAW)创建的原始套接字不是SOCK_STREAM。dest_addr服务端地址用于提取目的IP/端口。CLIENT_PORT硬编码为54321避免与系统端口冲突。sendto()的send_addr参数必须是AF_INET地址且sin_port必须与TCP头中tcph-dest一致否则内核丢弃。这短短50行代码完成了从应用层到链路层的跨越。它不依赖connect()不触发内核状态机纯粹是“我造我发我看结果”。4. 避坑指南那些让我重装三次系统的TCP血泪经验4.1 现象sendto(): Invalid argument错误但所有参数检查都通过原因IPPROTO_RAW套接字要求sendto()的目标地址sin_port必须为0而代码中错误地赋了dest_addr-sin_port。原始套接字发送时端口信息已由TCP头tcph-dest携带sendto()的地址结构体仅用于指定目的IP端口字段必须清零否则内核校验失败。解决将send_addr.sin_port dest_addr-sin_port;改为send_addr.sin_port 0;并确保tcph-dest已正确设置。4.2 现象Wireshark能看到SYN包但服务端recvfrom()收不到原因Linux内核默认会拦截发往本机的TCP包即使目的端口未监听防止原始套接字绕过防火墙。tcp_server.c中服务端虽用原始套接字监听但内核仍可能丢弃SYN包。解决临时关闭内核的TCP RST响应允许原始套接字接管$ sudo sysctl -w net.ipv4.tcp_rmem4096 131072 6291456 $ sudo sysctl -w net.ipv4.conf.all.rp_filter0 $ sudo sysctl -w net.ipv4.conf.lo.rp_filter0 # 最关键禁用内核对本机SYN的自动响应 $ echo 1 | sudo tee /proc/sys/net/ipv4/tcp_syncookies $ echo 0 | sudo tee /proc/sys/net/ipv4/tcp_abort_on_overflow4.3 现象校验和始终为0x0000Wireshark标红提示Bad checksum原因checksum_tcp()中伪首部的TCP总长字段误用了htons(sizeof(struct tcp_header))而实际应为htons(sizeof(struct tcp_header) data_len)。当发送SYNdata_len0时看似正确但一旦发送数据包长度错误导致校验和失效。解决严格按RFC在计算校验和前动态计算tcp_total_len sizeof(struct tcp_header) data_len并htons()后填入伪首部。4.4 现象客户端发完SYN后服务端收到两个完全相同的SYN包原因tcp_client.c中send_syn_packet()调用后未usleep(10000)等待导致快速重传逻辑代码中未实现但系统可能触发或tcpdump捕获到重复帧。更可能是sendto()返回成功后程序立即退出而内核尚未完成ARP解析导致包被缓存重发。解决在sendto()后添加usleep(50000)50ms确保包真正发出或在服务端recvfrom()后添加printf(Received SYN\n)并fflush(stdout)确认是真实接收而非抓包误判。4.5 现象修改CLIENT_PORT为1024以下端口bind()失败报Permission denied原因Linux规定1024以下端口为特权端口只有root用户才能绑定。但本项目客户端使用原始套接字bind()并非必需——sendto()可直接指定源IP/端口。强行bind()低权端口会失败。解决删除客户端中所有bind()调用源端口由tcph-source字段控制无需系统绑定。若坚持用bind()则必须以root运行且端口号≥1024。5. 二次开发实战从“能跑”到“能改”的三个关键改造点5.1 改造点一支持自定义初始序列号ISN注入复现TCP序列号预测攻击场景课程设计常要求演示TCP ISN可预测性漏洞。原项目固定tcph-seq htonl(1000)过于理想化。我们将其升级为基于时间戳PID的弱随机种子并暴露为命令行参数// tcp_client.c 新增全局变量 uint32_t g_isn_seed 0; // 在main()中解析参数 if (argc 2 strcmp(argv[1], client) 0) { if (argc 4 strcmp(argv[2], -isn) 0) { g_isn_seed strtoul(argv[3], NULL, 0); } else { g_isn_seed time(NULL) ^ getpid(); // 默认弱随机 } } // 修改send_syn_packet()中seq赋值 tcph-seq htonl(g_isn_seed);验证方法启动服务端sudo ./tcp_demo server客户端两次运行sudo ./tcp_demo client -isn 12345和sudo ./tcp_demo client -isn 12346用tcpdump抓包对比两帧SYN的Seq Number字段确认其与输入参数一致。进阶编写脚本循环发送-isn 1000到1010的10个连接观察服务端是否接受其中部分连接模拟攻击者猜测ISN。这比单纯讲“ISN是递增的”直观百倍。5.2 改造点二增加超时重传机制让FIN包在丢包时自动重发原项目发送FIN后即结束无法体现TCP的可靠性。我们在tcp_client.c中加入简单重传逻辑// tcp_client.c 新增重传结构 #define MAX_RETRIES 3 #define RETRY_TIMEOUT_MS 1000 int send_fin_with_retry(int sock_fd, struct sockaddr_in *dest_addr, uint32_t seq, uint32_t ack) { for (int i 0; i MAX_RETRIES; i) { if (send_fin_packet(sock_fd, dest_addr, seq, ack) 0) { printf([CLIENT] FIN sent (retry %d)\n, i); return 0; // 成功 } if (i MAX_RETRIES) { printf([CLIENT] FIN lost, retrying in %d ms...\n, RETRY_TIMEOUT_MS); usleep(RETRY_TIMEOUT_MS * 1000); } } printf([CLIENT] FIN failed after %d retries\n, MAX_RETRIES); return -1; }关键改造send_fin_packet()需支持传入seq和ack参数以便重传时保持序列号连续。重传间隔RETRY_TIMEOUT_MS设为1000ms符合TCP初始RTORetransmission Timeout典型值。重传次数MAX_RETRIES3与Linux默认net.ipv4.tcp_retries215形成对比突出教学简化性。验证在发送FIN前用iptables丢弃第一个FIN包$ sudo iptables -A OUTPUT -p tcp --tcp-flags FIN FIN -d 127.0.0.1 --dport 8080 -j DROP $ sudo ./tcp_demo client # 应看到FIN lost, retrying...日志且tcpdump中出现3个FIN包 $ sudo iptables -F # 清理规则5.3 改造点三扩展为双向数据通道支持服务端主动推送数据原项目是单向client→server但真实TCP是全双工。我们改造tcp_server.c使其在收到客户端数据后能构造并发送一个带Payload的TCP包回客户端// tcp_server.c 新增函数 int send_data_to_client(int sock_fd, struct sockaddr_in *client_addr, uint32_t seq, uint32_t ack, const char *data, size_t len) { uint8_t *packet malloc(IP_HEADER_LEN TCP_HEADER_LEN len); // ... 分配内存填充IP头同send_syn_packet... struct tcp_header *tcph (struct tcp_header *)(packet IP_HEADER_LEN); tcph-source htons(SERVER_PORT); // 8080 tcph-dest client_addr-sin_port; tcph-seq htonl(seq); // 服务端初始seq2000 tcph-ack_seq htonl(ack); // ack client_seq 16Hello长度 tcph-doff 5 (len 0 ? 1 : 0); // 若有数据TCP头可能含选项此处简化为1 tcph-psh 1; // 设置PSH标志提示立即交付 tcph-ack 1; tcph-window htons(65535); // 复制数据到TCP头后 memcpy(packet IP_HEADER_LEN TCP_HEADER_LEN, data, len); // 计算TCP校验和含数据 tcph-check checksum_tcp(iph, tcph, (uint8_t*)data, len); // 发送 struct sockaddr_in send_addr; memset(send_addr, 0, sizeof(send_addr)); send_addr.sin_family AF_INET; send_addr.sin_port 0; // 原始套接字端口置0 send_addr.sin_addr.s_addr iph-daddr; int ret sendto(sock_fd, packet, IP_HEADER_LEN TCP_HEADER_LEN len, 0, (struct sockaddr*)send_addr, sizeof(send_addr)); free(packet); return ret; }改造后流程客户端发Hello from client16字节服务端收到后调用send_data_to_client(..., Server ACK, 10)客户端recvfrom()应收到该字符串此改造将项目从“单向演示”升级为“可交互协议栈”为后续实现HTTP简易服务器打下基础。从那以后我每次做网络协议教学项目都强制走一遍tcpdump -i lo -nn -X抓包验证哪怕只是改了一个htons()。因为TCP的真相不在代码注释里而在Wireshark那一行行十六进制中——那里没有魔法只有字节序、校验和、和你亲手填满的每一个bit。希望帮到你。本文还有配套的精品资源点击获取
📝

华诺云谱内容团队

资深建站顾问 · 行业研究员

10年+企业数字化服务经验,专注智能建站、SEO优化与品牌营销,持续输出建站技巧、行业洞察与营销干货,已帮助5000+企业实现数字化增长。

你可能需要的服务

订阅华诺云谱资讯周报

每周一封,精选建站技巧、SEO与营销干货,直达邮箱。已有 8,000+ 企业主订阅,助你少走弯路。

↑