ICMP校验和构造原理与伪首部计算详解

发布时间:2026/11/6 3:53:13
ICMP校验和构造原理与伪首部计算详解

简介:本资源是一份面向网络协议学习者与安全开发初学者的ICMP数据包构造实践代码包,聚焦底层网络通信原理理解与手工构造能力训练。资源包含3个核心文件:C++实现的ICMP发送主程序(ICMP.cpp)、封装ICMP头部结构与校验逻辑的头文件(ping.h),以及说明构造要点与使用方法的文本指南(Readme.txt),整体压缩包仅5KB,轻量易用。已有1611人学习下载,适合计算机网络课程实验、CTF协议分析入门或Wireshark抓包验证教学场景。读者可直接编译运行代码生成自定义类型/代码字段的ICMP报文(如回显请求/应答),结合Wireshark实时捕获并比对字段值,深入掌握IP封装流程、ICMP校验和计算、类型代码语义等关键细节,为网络编程与协议逆向打下扎实基础。

1. ICMP数据包构造:为什么一个“ping不通”的错误,可能根本不是网络断了,而是你发出去的字节序列连校验和都没算对?

你执行ping 192.168.1.1成功,但用自定义工具发一个结构完全一样的ICMP Echo Request却收不到响应——不是目标主机防火墙拦了,也不是路由丢了,而是你构造的那64字节里,第20–21字节(ICMP校验和字段)填的是0x0000,而内核协议栈在收到时直接把它当成了损坏包丢弃。这不是玄学,是ICMP协议层最硬的铁律:校验和不通过,包在IP层之上就被静默丢弃,连ICMP错误消息都不会触发。很多人以为“构造ICMP”就是memcpy几个字段,结果卡在收不到reply上三天,翻遍Wireshark抓包对比也看不出门道——因为Wireshark默认只显示“ICMP Echo (ping) request”,它把底层字节校验失败这个关键判定过程藏成了灰色小字:“Bad checksum”。这份《ICMP数据包构造》资源,不是教你怎么调用ping命令,而是带你从RFC 792原始定义出发,手撕每一个字节的生成逻辑:校验和怎么跨16位累加、为什么需要补零填充、ID与Sequence字段为何必须按主机字节序写入再按网络字节序传输、ICMP头部与载荷如何拼接才不越界。它解决的不是“能不能发”,而是“发出去的每个字节,是否被协议栈当作合法ICMP对待”。


2. ICMP协议帧结构:从RFC 792到内存布局,为什么校验和计算必须覆盖整个ICMP报文(含伪首部)?

ICMP本身没有端口、不建连接、不重传,但它对字节级精度的要求,比TCP SYN包还苛刻。它的合法性判断发生在IP层交付给ICMP模块的瞬间,而判断依据只有两个:IP首部校验和(由网卡或内核IP模块完成)+ ICMP校验和(由ICMP模块独立计算)。前者确保IP层没传错,后者确保ICMP载荷本身无篡改。但关键在于:ICMP校验和的计算范围,并非仅限于ICMP头部+数据,而是必须包含一个“伪IP首部”——这是RFC 792明确定义、但绝大多数初学者直接忽略的致命细节。

2.1 伪首部:不是真实传输的字节,却是校验和计算的强制输入

RFC 792规定,ICMP校验和计算前,需临时拼接一个12字节的“伪首部”(pseudo-header),其结构如下:

字段长度(字节)含义示例值(十六进制)
源IP地址4发送方IPv4地址C0 A8 01 01(192.168.1.1)
目的IP地址4接收方IPv4地址C0 A8 01 02(192.168.1.2)
零填充1固定为0x0000
协议号1ICMP协议号=101
ICMP报文总长度2ICMP头部+数据长度(字节)00 38(56字节)

提示:这个伪首部绝不会出现在实际网络帧中,它只存在于校验和计算的内存上下文中。Wireshark抓包看到的ICMP帧,开头就是真正的ICMP头部(Type字段),没有这12字节。但你的代码在调用checksum()函数时,必须手动构造这个结构体并作为输入参数传入。

2.2 校验和算法:16位反码求和,为什么必须“补零”且“高位在前”?

ICMP校验和采用经典的16位反码求和(one's complement sum),步骤严格如下:

  1. 将待校验数据(伪首部 + ICMP报文)按每2字节为单位分组;
  2. 若总字节数为奇数,则在末尾补一个字节0x00(注意:不是补0x0000,是补单字节0);
  3. 将所有16位组相加,若产生进位,则将进位加到低16位(即“回卷”);
  4. 对最终16位结果取反码(bitwise NOT),得到校验和。

下面是一段可验证的C语言实现,它严格遵循上述步骤:

// 计算校验和:data指向待校验内存起始地址,len为字节数 uint16_t icmp_checksum(const uint8_t *data, size_t len) { uint32_t sum = 0; const uint16_t *ptr = (const uint16_t *)data; // 按16位累加 while (len > 1) { sum += *ptr++; len -= 2; } // 处理剩余单字节(奇数长度) if (len == 1) { sum += *(const uint8_t *)ptr; // 注意:此处是uint8_t,非uint16_t } // 回卷:将32位sum的高16位加到低16位 while (sum >> 16) { sum = (sum & 0xFFFF) + (sum >> 16); } // 取反码 return (uint16_t)(~sum); }

这段代码的关键点在于:

  • ptr强制类型转换为uint16_t*,意味着CPU会以网络字节序(大端)解释内存中的两个连续字节。如果你的机器是小端(x86/x64),*(uint16_t*)"\x01\x02"读出来是0x0201,而非0x0102—— 这正是我们需要的:校验和计算要求字节流按网络序解释,所以直接按uint16_t*读取天然符合要求。
  • 补单字节0x00的逻辑在if (len == 1)分支中,且用uint8_t*读取,避免越界或符号扩展。
  • while (sum >> 16)循环处理多轮进位,确保最终结果稳定在16位内。

2.3 ICMP Echo Request完整帧布局:从Type到Data,每个字段的字节位置都决定生死

一个标准的ICMP Echo Request(Type=8)帧,在内存中必须严格按以下顺序排列(共56字节示例,不含IP首部):

偏移(字节)字段长度值(示例)说明
0Type108必须为8,表示Echo Request
1Code100必须为0,ICMP Echo无子类型
2–3Checksum2?? ??计算后填入,初始置0
4–5Identifier212 34主机字节序写入,但校验和计算时按网络序解释
6–7Sequence Number200 01同Identifier,用于匹配Request/Reply
8–55Data48"abcdefghijklmnopqrstuvw..."可选载荷,常为ASCII字符或递增字节

注意:Identifier和Sequence Number字段在内存中是以主机字节序写入的(比如x86上htons(0x1234)写入为0x12 0x34),但校验和计算时,它们被当作uint16_t读取,CPU自动按当前平台字节序解释。由于我们构造的是网络报文,而校验和算法要求“按网络序解释字节”,因此必须确保这两个字段在内存中的存储顺序与网络序一致——这就是为什么必须用htons()转换后再写入。如果直接写0x1234到内存而不转换,在小端机上存为0x34 0x12,校验和就会算错。


3. 构造流程实战:从零开始组装一个可被Linux内核接受的ICMP Echo Request

构造一个能被目标主机正常响应的ICMP包,不是简单地把字段塞进缓冲区,而是一套有严格时序和依赖关系的操作链。核心矛盾在于:校验和字段自身参与校验和计算,因此必须先清零,再计算,最后填入。任何一步顺序错误,都会导致整个包被丢弃。

3.1 内存缓冲区分配与初始化:为什么必须用memset清零整个缓冲区?

我们通常申请一块足够大的uint8_t buf[1024]作为原始报文缓冲区。关键第一步不是填Type,而是:

memset(buf, 0, sizeof(buf)); // 必须全清零!

原因有三:

  • ICMP校验和算法要求:未使用的字节(如Data部分不足48字节时的尾部)必须为0,否则会影响累加结果;
  • Identifier和Sequence Number字段若未显式赋值,残留垃圾值会导致校验和错误;
  • 某些内核版本(如较老的2.6.x)在解析ICMP时,会对Code字段做额外检查,非0值可能被直接拒绝,而memset确保Code=0。

3.2 伪首部构造:如何安全地拼接IP地址与ICMP长度?

伪首部不占用真实网络带宽,但它是校验和计算的强制输入。构造时需极度小心字节序:

struct pseudo_header { uint32_t src_ip; // 网络字节序 uint32_t dst_ip; // 网络字节序 uint8_t zeros; // 0x00 uint8_t protocol; // 1 (ICMP) uint16_t icmp_len; // ICMP报文总长(网络字节序!) }; struct pseudo_header ph; ph.src_ip = htonl(INET_ADDR("192.168.1.1")); // 转为网络序 ph.dst_ip = htonl(INET_ADDR("192.168.1.2")); ph.zeros = 0; ph.protocol = 1; ph.icmp_len = htons(56); // ICMP总长56字节,必须htons! // 将伪首部拷贝到临时缓冲区,用于校验和计算 uint8_t checksum_buf[1024]; memcpy(checksum_buf, &ph, sizeof(ph)); memcpy(checksum_buf + sizeof(ph), icmp_payload, icmp_payload_len);

关键点:ph.icmp_len必须用htons()转换!因为伪首部中该字段定义为“网络字节序”,而htons(56)在x86上生成0x00 0x38,若直接赋值56,则存为0x38 0x00(小端),校验和必然失败。

3.3 校验和注入:为什么必须“先置零,再计算,后填入”?

这是整个构造流程中最易出错的环节。正确顺序如下:

// 步骤1:在ICMP payload缓冲区中,将Checksum字段(偏移2-3)置零 uint8_t *icmp = buf; // 指向ICMP头部起始 icmp[2] = 0; icmp[3] = 0; // 步骤2:构造完整待校验数据(伪首部 + ICMP payload) uint8_t *calc_data = malloc(sizeof(ph) + icmp_payload_len); memcpy(calc_data, &ph, sizeof(ph)); memcpy(calc_data + sizeof(ph), icmp, icmp_payload_len); // 步骤3:计算校验和 uint16_t csum = icmp_checksum(calc_data, sizeof(ph) + icmp_payload_len); // 步骤4:将结果以网络字节序填入ICMP头部 icmp[2] = (csum >> 8) & 0xFF; // 高字节 icmp[3] = csum & 0xFF; // 低字节 free(calc_data);

如果跳过步骤1(不置零),那么icmp_checksum()会把原icmp[2-3]的随机值也纳入计算,导致结果永远不匹配;如果步骤4中直接写*(uint16_t*)&icmp[2] = csum,在小端机上会把csum=0x1234写成0x34 0x12,而接收方按网络序解析期望0x12 0x34,校验失败。


4. 常见问题排查:5个让开发者通宵调试的真实踩坑记录

ICMP构造看似简单,实则处处是坑。以下是某开发者在模拟项目X中反复验证的5个高频问题,每个都附带Wireshark抓包特征、根本原因及修复方案。

4.1 现象:Wireshark显示“ICMP Echo (ping) request”,但目标主机tcpdump无任何ICMP包捕获

原因:发送方使用了AF_INET套接字但未设置SOCK_RAW权限,或未绑定到IPPROTO_ICMP协议号,导致内核拒绝投递。更隐蔽的情况是:程序以普通用户运行,而Linux默认禁止非root用户创建RAW socket(CAP_NET_RAWcapability缺失)。
解决:

  • 检查socket创建:int sock = socket(AF_INET, SOCK_RAW, IPPROTO_ICMP);
  • 若报错Operation not permitted,用sudo setcap 'cap_net_raw+ep' ./your_program授予权限,或改用root运行;
  • 在Wireshark中过滤ip.proto == 1确认是否真发出,而非仅显示“request”字样。

4.2 现象:Wireshark抓到请求包,但显示“Bad checksum”,且目标主机无响应

原因:校验和计算时未包含伪首部,或伪首部中icmp_len未用htons()转换,或Identifier/Sequence字段未用htons()写入导致字节序错乱。
解决:

  • 用Wireshark右键“Protocol Preferences → ICMP → Validate checksums”开启校验;
  • 手动计算伪首部+ICMP payload的校验和,与Wireshark显示的“Calculated”值比对;
  • 重点检查ph.icmp_len = htons(56)和icmp[4-5] = htons(id)两处。

4.3 现象:请求包能发出,目标主机也返回Reply,但Reply的Identifier或Sequence字段与Request不匹配

原因:Request中Identifier和Sequence字段在内存中以主机序写入,但未用htons()转换,导致小端机上存为低位在前,而目标主机(无论大小端)均按网络序解析,造成数值错乱。
解决:

  • 严格使用htons():*(uint16_t*)&icmp[4] = htons(my_id);
  • 验证:在Request包中,icmp[4-5]应为0x12 0x34(对应id=0x1234),而非0x34 0x12。

4.4 现象:同一台机器上,用ping命令能通,但自定义程序发的包始终超时,Wireshark显示请求包长度异常(如60字节而非56字节)

原因:ping命令默认发送56字节Data,总ICMP长=8(头)+56=64字节;而你的程序可能误将icmp_len设为sizeof(ICMP_HEADER)+data_len,却忘了ICMP头部是8字节,导致icmp_len=64,但实际payload只写了56字节,末尾8字节为随机内存垃圾,校验和必错。
解决:

  • 显式定义:#define ICMP_HDR_LEN 8,icmp_len = ICMP_HDR_LEN + data_len;
  • 用hexdump -C打印你构造的buf前64字节,确认第8–63字节是否为你预期的Data内容。

4.5 现象:程序在本机ping 127.0.0.1成功,但发给局域网其他主机时Wireshark显示“Destination unreachable (Port unreachable)”

原因:目标主机防火墙(如iptables)默认丢弃非ping命令发起的ICMP Echo Request,或系统禁用了net.ipv4.icmp_echo_ignore_all=1。这不是你包的问题,而是目标策略。
解决:

  • 在目标主机执行:sysctl net.ipv4.icmp_echo_ignore_all,若输出1则需sudo sysctl -w net.ipv4.icmp_echo_ignore_all=0;
  • 检查iptables:sudo iptables -L INPUT | grep icmp,若存在DROP规则,临时清空:sudo iptables -P INPUT ACCEPT。

5. 设计取舍与验证方法:为什么我坚持用sendto()而非send(),以及如何用BPF过滤器做原子级验证

构造ICMP包的终极目标,不是“发出去”,而是“被对方协议栈当作合法ICMP处理”。这就引出两个关键设计取舍:传输接口的选择,以及验证手段的可靠性。很多教程推荐用send()配合已连接的raw socket,但我在模拟项目X中坚持用sendto(),原因在于连接状态会隐式修改报文语义,而ICMP本质是无连接的。

5.1sendto()vssend():连接状态带来的字节序陷阱

当你调用connect(sock, (struct sockaddr*)&dest, sizeof(dest))后,再用send(sock, buf, len, 0)发送,内核会自动填充IP首部的源地址和目的地址。这看似省事,但埋下隐患:

  • connect()传入的sockaddr_in.sin_addr是网络字节序,但某些旧版glibc在内部处理时,可能对sin_port字段做二次字节序转换;
  • 更严重的是,send()在填充IP首部时,会将sockaddr_in.sin_addr直接复制为IP首部的daddr,但此时若你的ICMP伪首部中dst_ip字段仍用主机序赋值(如ph.dst_ip = dest.sin_addr.s_addr),就会出现“伪首部dst_ip”与“IP首部daddr”不一致——而校验和计算依赖前者,IP层转发依赖后者,两者错位必然失败。

sendto()则完全规避此风险:你显式提供sockaddr_in*,内核仅从中提取目的IP用于IP首部填充,而伪首部由你手动构造,二者来源统一,可控性极强。我的血泪经验是:只要sendto()的第一个参数sock正确,第二个参数buf内容正确,第三个参数len准确,第四个参数flags=0,第五个参数dest地址正确,就一定能发出预期字节流。

5.2 BPF过滤器:在内核态做原子级验证,绕过用户态抓包干扰

Wireshark依赖libpcap,而libpcap工作在AF_PACKET套接字上,其抓包时机晚于内核协议栈处理。这意味着:一个因校验和错误被内核静默丢弃的ICMP包,Wireshark可能根本抓不到(因为没进入AF_PACKET队列),或者抓到但标记为“Malformed”。要真正确认“包是否到达ICMP模块”,必须在更早的路径上拦截。

Linux提供了SO_ATTACH_FILTER机制,允许你在raw socket上挂载Berkeley Packet Filter(BPF)程序。下面是一个验证ICMP Echo Request是否被内核接收的最小BPF:

struct sock_filter code[] = { BPF_STMT(BPF_LD + BPF_H + BPF_ABS, 12), // 加载IP首部协议字段(偏移12) BPF_JUMP(BPF_JMP + BPF_JEQ + BPF_K, 1, 0, 3), // 若!=1(ICMP),跳过后续 BPF_STMT(BPF_LD + BPF_B + BPF_ABS, 20), // 加载IP首部IHL字段(偏移20,取低4位) BPF_STMT(BPF_ALU + BPF_MUL + BPF_K, 4), // IHL * 4 = IP首部长度 BPF_STMT(BPF_LD + BPF_B + BPF_IND, 0), // 从IP首部起始偏移处读ICMP Type BPF_JUMP(BPF_JMP + BPF_JEQ + BPF_K, 8, 0, 1), // 若Type==8,accept;否则reject BPF_STMT(BPF_RET + BPF_K, 0), // 拒绝 BPF_STMT(BPF_RET + BPF_K, 65535), // 接受全部(64KB) }; struct sock_fprog bpf = { .len = sizeof(code)/sizeof(code[0]), .filter = code }; setsockopt(sock, SOL_SOCKET, SO_ATTACH_FILTER, &bpf, sizeof(bpf));

这段BPF在recvfrom()之前运行,它直接从内核sk_buff中提取IP首部和ICMP Type,仅当Type==8时才将包交付给你的recvfrom()。如果recvfrom()始终阻塞,说明包根本没通过校验和检查,被ICMP模块丢弃;如果能收到,则证明你的构造已通过最严苛的内核检验。

从那以后我每次调试ICMP构造,都强制走一遍这个BPF验证流程:先用sendto()发包,再用带BPF的recvfrom()等响应,同时开Wireshark抓包比对。三者结果一致,才敢说“这个包是合法的”。希望帮到你。

本文还有配套的精品资源,点击获取