资讯动态

ICMP数据包构造从零开始:校验和、raw socket与抓包验证

发布时间:2026/10/7 1:42:50 来源:尧图企业网站定制
简介ICMP数据包构造是网络协议学习中的一项基础实践这份压缩包面向网络初学者、在校学生及需要排查网络问题的开发人员围绕ICMP报文结构、差错报告与查询报文分类、IP数据报封装等核心知识点提供可直接运行的C源码帮助读者从零掌握构造回显请求和应答报文的方法。压缩包体积仅5KB总共包含3个文件其中C源文件与头文件实现ICMP头部设置、校验和计算以及IP封装逻辑说明文档对使用方式和验证要点做了补充结构精简便于逐行阅读和修改实验。资源已有1607人学习结合描述中提到的Wireshark抓包验证思路读者可自行观测发送与接收的ICMP报文细节对比类型、代码与数据字段从而深入理解网络通信的底层机制。对于有志于网络编程、协议分析或网络安全方向的学习者这是一份轻量而实用的入门素材既能巩固理论也能作为后续扩展开发的基础模板。1. 手工构造 ICMP 包一个没人想碰、出事最不好查的活ping 不通的时候第一反应是查链路查路由很少有人怀疑 ICMP 包本身被构造错了。我之前排查一次跨网段不通的问题抓包看到回显请求的校验和字段是乱的才发现问题出在自己写的探测工具上——不是网络是包没构造对。ICMPInternet Control Message Protocol是 TCP/IP 栈里最不起眼但最常用到的协议ping 用它探活traceroute 靠它报告超时网络故障排查基本绕不开它的差错报文。所谓ICMP 数据包构造就是绕过系统自带的 ping自己从字节层面把 ICMP 报文拼出来再用 raw socket 发出去。这对写监控探活脚本、做网络诊断工具、学协议栈底层的人都有实用价值。本文从包结构讲起每步直接给可跑的代码最后落到抓包验证和踩坑排查。2. 先拆包再装包ICMP 报文的位级布局与校验和算法2.1 ICMP 头四个字段拆开看类型、代码、校验和与标识符ICMP 报文最短 8 字节前 4 字节是公共头任何类型的报文都从这里开始。偏移 0 的字节叫类型Type8 表示回显请求0 表示回显应答3 表示目的不可达5 表示重定向11 表示超时。偏移 1 的字节叫代码Code它是对类型的补充说明比如目的不可达类型下代码 0 是网络不可达代码 1 是主机不可达代码 3 是端口不可达。偏移 2 和 3 是校验和Checksum它覆盖整个 ICMP 报文包括载荷计算时校验和字段本身先置零。回显请求和回显应答这两种报文头部的第 5 到第 8 字节还有额外两个字段标识符Identifier和序列号Sequence Number各占 2 字节。标识符用来匹配请求与应答习惯上取发送进程的 PID序列号每次发包递增用来判断丢包和乱序。没错你平常敲 ping 看到的 icmp_seq就是这个序列号字段的十进制展开。偏移字节字段长度字段名典型值/作用01 字节类型8回显请求0回显应答3目的不可达11 字节代码配合类型细分回显请求/应答取 022 字节校验和对 ICMP 报文整体做反码求和42 字节标识符匹配请求应答常取进程 PID62 字节序列号每次发包递增判定丢包乱序这 8 字节只是头部的固定部分后面还可以跟载荷。回显请求的载荷随意一般放时间戳或一串固定字符串用来测 RTT目的不可达报文则会紧跟触发错误的原始 IP 头和前 8 字节数据方便对端定位是哪个连接出了问题。2.2 校验和算法选型RFC 1071 为什么还在用IP、ICMP、UDP、TCP 的校验和全都统一用 RFC 1071 定义的反码求和算法没有分支没有加密查表都不需要。原理一句话把数据按 16 位一组拆开累加把高 16 位进位折回低 16 位最后取反码。ICMP 的校验和覆盖的是整个 ICMP 报文这点和 UDP 不一样UDP 校验和还要加一层伪头pseudo header来校验 IP 地址ICMP 不需要。算法不换的原因很简单硬件和软件都对它做过深度优化计算量极小而且它有很好的数学性质——接收端对完整报文包含校验和字段再做一次求和结果为 0xFFFF 则通过。这个性质在做抓包分析时很有用你在科来 icmp 抓包或 Wireshark 里看到的 “Checksum: 0xXXXX [correct]”本质就是软件对这一整段报文重新做了一遍校验计算。2.3 动手实现校验和Python 函数与逐行逻辑Python 实现 RFC 1071 大约十行我一般这样写def icmp_checksum(data: bytes) - int: if len(data) % 2 ! 0: data b\x00 # 补一个零字节保证能按 16 位分组 s 0 for i in range(0, len(data), 2): s (data[i] 8) data[i1] # 每两个字节拼成一个 16 位大端整数累加 s (s 16) (s 0xffff) # 把高 16 位进位折回低 16 位 s (s 16) # 折回后可能还有一次进位再折一次 return (~s) 0xffff # 取反码按 16 位保留结果代码的关键点有三个。第一如果数据长度是奇数末尾要补一个零字节补的字节只参与计算不进网络。第二累加结果可能超过 16 位所以要把高 16 位不断折回低 16 位直到没有进位。第三最后要取反码而不是补码这是校验和能否通过接收端验证的关键。如果你把~s 0xffff换成0xffff - s结果其实一样因为~s 0xffff本质就是 16 位取反但~s本身在 Python 里是个大负数必须按位与 0xffff 截断成两个字节这个细节新手特别容易漏。发送端和接收端的校验逻辑是同一个函数发送端先在校验和字段写 0计算后回填接收端对整个报文再算一次得到 0xFFFF 则校验通过。这个互通性我建议你务必亲自验证一遍它能解释之后所有校验和报错的根因。3. 用 Python 在本地跑通第一个 ICMP 回显包最小代码与抓包验证3.1 为什么选 Python 的 raw socket 而不去写 C搞 ICMP 数据包构造语言选择上无非 C、Python、Go 三派。C 的好处是结构体直接映射字节性能最好但每做一次字节序转换、每组合一次头部都得手动处理指针写错一个偏移就是踩一个坑。Go 有golang.org/x/net/icmp这类封装好的库但离字节层面较远不太适合用来理解构造过程。我一般在工具原型验证阶段用 Python标准库socket原生支持SOCK_RAWstruct.pack能按指定字节序组合字段不需要装任何第三方依赖改起来也快。生产环境再用 C 或 Go 重写也不迟。raw socket 有两种用法。一种是用socket(AF_INET, SOCK_RAW, IPPROTO_ICMP)内核帮你填充 IP 头发送数据时只需要提供 ICMP 报文本身适合构造标准 ICMP 报文另一种是用IPPROTO_RAW加IP_HDRINCL选项自己拼 IP 头适合做自定义分片、改 TTL、填奇怪源地址等场景。第一步先走前者让内核替你处理 IP 头少一个出错来源。权限这件事先说清楚Linux 下创建IPPROTO_ICMP的 raw socket 需要 root 权限或CAP_NET_RAW能力。普通用户直接跑会报PermissionError: [Errno 1] Operation not permitted。开发时可以临时sudo生产环境建议给二进制加setcap cap_net_rawep而不是把整个进程跑在 root 下。3.2 构造并发送 ICMP Echo Request从 struct.pack 到 recvfrom下面是完整的最小实现流程是组装头部、算校验和、发出去、收回应import socket import struct import time def icmp_checksum(data: bytes) - int: if len(data) % 2 ! 0: data b\x00 s 0 for i in range(0, len(data), 2): s (data[i] 8) data[i1] s (s 16) (s 0xffff) s (s 16) return (~s) 0xffff def build_echo_request(identifier: int, seq: int, payload: bytes) - bytes: header struct.pack(!BBHHH, 8, 0, 0, identifier, seq) packet header payload chk icmp_checksum(packet) # 把算好的校验和回填到偏移 2 的位置 packet struct.pack(!BBHHH, 8, 0, chk, identifier, seq) payload return packet sock socket.socket(socket.AF_INET, socket.SOCK_RAW, socket.IPPROTO_ICMP) sock.settimeout(3) addr 10.0.0.1 # 替换成你要探测的目标地址 packet build_echo_request(identifier0x1234, seq1, payloadbhello py) sock.sendto(packet, (addr, 0)) # sendto 需要二元组端口对 ICMP 无意义填 0 try: resp, peer sock.recvfrom(2048) # resp 是完整 IP 包IPv4 头 20 字节后面才是 ICMP 报文 icmp_pkt resp[20:] # 按大端解包 ICMP 头得到 type, code, checksum, id, seq typ, code, chk, r_id, r_seq struct.unpack(!BBHHH, icmp_pkt[:8]) print(f收到 {peer[0]} 的应答type{typ} code{code} id{r_id:#x} seq{r_seq}) print(f原始数据{icmp_pkt}) except socket.timeout: print(等待响应超时) finally: sock.close()struct.pack(!BBHHH, ...)里面的感叹号表示网络字节序大端。ICMP、IP、TCP、UDP 协议头里的多字节字段全部是大端存储B是 1 字节无符号整数H是 2 字节无符号整数。如果你写成小端struct.pack(BBHHH, ...)标识符和序列号在抓包工具里就会显示成字节颠倒而且校验和也会错。第二个细节是sendto的地址参数。Python 的sendto要求传一个二元组IP, 端口ICMP 没有端口概念端口填 0 即可内核不会把它写进任何字段。返回的resp是整个 IP 包IPv4 头默认 20 字节要访问 ICMP 层必须跳过前 20 个字节。如果你在某处看到有人直接resp[:8]解包 ICMP那多半是他收到的数据已经不包含 IP 头了——这在注入方式不同的环境下会发生后面踩坑部分会讲。3.3 用科来抓包工具验证自己的包该看到什么发完这个脚本你最好立刻用抓包工具验证一次别急着关终端。常见的做法是用 Wireshark 或科来 icmp 抓包工具选对应的网卡过滤条件写icmp再触发一次脚本发送。正常的抓包结果应该是两行一行是你发出去的 Echo Request类型 8 代码 0校验和显示正确一行是对端返回的 Echo Reply类型 0 代码 0。双击请求包展开 ICMP 层你会在里面看到和脚本里一模一样的标识符、序列号、载荷内容。这里有一个常被忽略的点如果校验和显示为红色并标注 incorrect说明你构造的包在接收端校验没通过对端会直接丢弃表现为抓包只有请求没有应答。此时第一优先排查自己的校验算法第二排查是不是发了两遍校验收尾时手抖把冷却数据写进去了。校验和不对这个现象是 ICMP 数据包构造里最容易自证的 bug因为它完全可以离线算出来。4. 进阶构造ICMP 重定向、不可达与时间戳请求的包模板4.1 除了 pingICMP 还有哪些类型值得手工构造回显请求只是 ICMP 的敲门砖。真正在故障排查时有用的是差错报文下面这张表列出我日常会手工构造的类型类型值名称代码举例构造用途0回显应答0模拟对端回复测试探活逻辑3目的不可达0网络1主机3端口模拟路由失败、防火墙拒绝5重定向0网络1主机测试主机路由表更新逻辑8回显请求0连通性探测11超时0TTL 超时1分片重组超时模拟 traceroute 中间节点反馈13时间戳请求0获取对端系统时间早期攻击探测手段14时间戳应答0配合 13 使用为什么要手工构造这些因为系统自带的 ping 只发回显请求而你想验证的设备行为比如路由重定向处理、分片超时告警、防火墙拦截日志往往需要你先构造一个特定差错报文喂给它。我做过一次交换机路由表异常排查就是手工发了一个重定向报文给被测主机观察它是否按 RFC 792 的要求更新了路由缓存。4.2 目的不可达类型 3报文模板差错包怎么拼接原始 IP 头目的不可达报文的设计逻辑是当路由器或主机发现包无法送达时它要把原始 IP 包的关键信息退回给发送方好让发送方知道是哪个连接出的问题。因此报文结构是 8 字节 ICMP 头 原始 IP 头 原始数据前 8 字节。这个“原始”不是指当前报文的源 IP而是触发错误的那个包的开头片段。构造时核心难点在拼接原始 IP 包片段代码可以这样实现def build_unreachable(original_ip_pkt: bytes, code: int) - bytes: # 原始 IP 头 原始载荷前 8 字节必须保留 ip_head original_ip_pkt[:20] org_data original_ip_pkt[20:28] # 载荷的前 8 字节 rest ip_head org_data # 差错报文的数据区 header struct.pack(!BBHHH, 3, code, 0, 0, 0) # 类型 3标识符序列号无意义填 0 packet header rest chk icmp_checksum(packet) # 回填校验和重新拼装 packet struct.pack(!BBHHH, 3, code, chk, 0, 0) rest return packet注意这里和回显请求有两个明显差别。第一标识符和序列号字段对目的不可达没有意义通常填 0抓包工具也不会刻意展示它们。第二差错报文的数据区必须包含原始 IP 头和前 8 字节数据否则对端无法识别是哪个 socket 的包出了问题如果省略这部分很多协议栈会直接丢弃这个差错报文RFC 1122 里明确要求收到不完整的差错包要静默丢弃。你构造时别为了少两行代码省掉这段省掉的代价就是白发。4.3 时间戳请求与应答拿到对端系统时间的另类路径时间戳请求类型 13是 ICMP 里一个比较有意思的类型它不测连通性而是要求对端返回它当前的系统时间精确到毫秒。报文的格式是 8 字节 ICMP 头外加 12 字节时间戳原始时间戳、接收时间戳、发送时间戳各 4 字节。发送时间戳由请求方填写格式是自 UTC 零点起经过的毫秒数应答方把接收时间戳和发送时间戳填上后原样返回。构造时间戳请求的难点在时间戳字段的计算。自零点经过的毫秒数用 Python 这么算import datetime now datetime.datetime.now() midnight now.replace(hour0, minute0, second0, microsecond0) elapsed_ms int((now - midnight).total_seconds() * 1000) # 原始时间戳填 elapsed_ms接收和发送时间戳先填 0 payload struct.pack(!III, elapsed_ms, 0, 0) header struct.pack(!BBHHH, 13, 0, 0, 0xabcd, 1) packet header payload chk icmp_checksum(packet) packet struct.pack(!BBHHH, 13, 0, chk, 0xabcd, 1) payload应答包里三个时间戳会被对端填入真实值。把发送时间戳减去原始时间戳就是单程时间的粗略估计把接收时间戳减去原始时间戳可以粗略估算两个系统之间的时钟偏差。这个方法在 NTP 不可用的内网里偶尔能派上用场但注意很多现代系统会默认禁用 ICMP 时间戳或过滤掉这类报文构造出来收不到应答太正常了。5. 构造 ICMP 数据包的 5 个常见翻车现场与排查方法5.1 现象抓包工具提示校验和 incorrect对端无响应原因发送端在校验和字段清零之前就先算了整个包的校验和或者把校验算法写错了。如果脚本里packet变量在校验和计算前已经是带非零校验和字段的版本结果必然是错的。解决严格按 “先填 0再计算再回填” 的顺序第一次struct.pack把校验和位置填 0计算后第二次struct.pack用真实校验和替换。建议在代码里写一个build_packet()函数强制这个流程并在函数末尾打印一次icmp_checksum(packet)验证结果应为 0这叫自校验能提前拦截大多数低级错误。另外用一个已知正确的样本比对用系统ping命令抓包保存一份 Echo Request 的十六进制和你的脚本输出逐字节对比差异一眼就能看出来。5.2 现象普通用户跑脚本报 Operation not permitted原因Linux 系统不允许非 root 用户创建IPPROTO_ICMP类型的 raw socket这是内核的安全策略不是代码问题。Windows 上则需要管理员权限并安装 WinPcap/Npcap 驱动而且 Windows 对 raw socket 的 ICMP 支持还受防火墙影响。解决开发阶段用sudo python3 your_script.py快速验证。生产环境更推荐给解释器或二进制程序设 capabilitysudo setcap cap_net_rawep $(which python3)这样普通用户也能跑不需要把整个进程提权到 root。注意 setcap 对 shell 脚本无效必须先确定 python 解释器的真实路径。容器环境别忘了在 docker run 时加--cap-addNET_RAW否则容器内照样报权限不足。5.3 现象小包正常加长载荷后 ping 超时原因载荷增大导致 IP 包超过链路 MTU触发分片。如果构造时设置了IP_DF不分片标志或对端策略丢弃 ICMP 分片就会表现为超时。回显请求还好目的不可达这类差错报文一旦加长载荷分片后的重组逻辑复杂度会明显上升。解决确认链路 MTU。ip link show看接口 MTU默认 1500 字节扣除 IP 头和 ICMP 头各 20 字节和 8 字节ICMP 载荷超过 1472 字节就会分片。调试分片行为时可以故意把载荷拉到 2000 字节再用抓包工具观察分片偏移字段关注抓包工具显示的 “Fragmented IP protocol” 是否一致。如果不想处理分片payload 不要超过 1472 字节。要测试分片路径可以在建 socket 后用IP_MTU_DISCOVER选项控制是否允许分片但这属于 IP 层调参不在 ICMP 报文结构范围内。5.4 现象抓包看到的标识符和代码里填的值对不上原因字节序不对。你代码里用struct.pack(!BBHHH, ...)是大端但如果系统从小端读取或者抓包工具解析时按小端解标识符0x1234会变成0x3412。大多时候问题出在代码用了不带!前缀的本地字节序或小端。解决检查struct.pack的格式字符串是否以!开头。不要在代码里手工交换字节序做强转那只会制造更多混乱。校验和字段的错误往往也和字节序错误同时发生因为算法假设数据是大端排列。一个快速自检办法打印packet.hex()看前 8 字节是不是08 00 xxxx 12 34其中08 00是类型和代码12 34是你的标识符。只要这里看起来是对的字节序基本没问题。5.5 现象发送成功但收不到回包抓包连请求都没出网卡原因多网卡主机上 raw socket 的默认路由没选对。sendto指定了对端 IP但内核选源 IP 和出口网卡时依据路由表如果目标网段走的是另一张网卡请求可能从错误的物理口发出抓到包的网卡当然看不到。解决构造 socket 后用setsockopt绑定出口接口import fcntl, socket, struct as st def bind_device(sock, ifname): sock.setsockopt(socket.SOL_SOCKET, 25, ifname.encode()) # SO_BINDTODEVICE2525 是 Linux 的SO_BINDTODEVICE值不同系统数值不同生产代码建议从系统头文件读取而不是硬编码。绑定时确认源 IP 与出口网卡在同一子网。如果源 IP 和路由表冲突抓包会看到一个诡异现象请求已经发出但源地址是另一张网卡的 IP对端回包打到了错误路径上。这种问题抓包在 A 网卡看请求、在 B 网卡看回包不仔细看源地址根本发现不了。6. 构造后这步必须做用抓包工具回读自己的包并确认格式6.1 抓包回读的三个检查点构造完数据包我习惯按固定顺序做三遍校验先用icmp_checksum对整个报文算一遍结果必须为 0再打印报文的 hex 前 16 字节肉眼对一遍类型、代码、标识符最后用 Wireshark 或科来 icmp 抓包工具走一遍实际收发看抓包工具的协议解析是否和你意图一致。这三步不是多余的它们分别验证校验和正确性、字段布局正确性、以及内核和协议栈的最终呈现效果。在抓包工具里我一般会开启显示过滤icmp.type 8 icmp.code 0来精确过滤目标包并打开时间列确认 RTT。如果用的是科来抓包工具重点看它的 Expert 信息区有没有提示 Checksum 错误或包被重传Wireshark 则看左下角的 “Expert Info” 里是否出现红色感叹号。这两个工具的提示逻辑不完全一样但判定标准都是同一个 RFC 1071 校验算法看到 [correct] 或者绿色对勾就说明校验这一步过了。6.2 最后一个习惯把构造参数和抓包结果保存成对照文本我的经验是调 ICMP 构造代码时最容易翻车的不是写代码的当下而是几周后回来看脚本忘了某个字段是什么意思。建议每次验证通过后把代码里的 identifier、seq、payload、目标 IP、抓包工具导出的关键帧字段追加到一个 notes 文本里格式随意但必须能对应上。这个习惯帮我省过很多次重复排查特别是分片场景下没有当时的抓包导出几乎不可能区分是构造错误还是链路问题。如果你读到这里正准备动手把我上面那段最小回显请求脚本跑通加上一次抓包验证就已经完成了从 “知道 ICMP 数据包构造” 到 “能构造 ICMP 数据包” 的跨越。之后再碰类型 3、类型 5、时间戳这些都是同一套套路清空校验和、拼字段、回填校验和、抓包验证。这套步骤我走了很多遍最深的体会是构造协议头这种事别信眼睛信校验结果和抓包导出。希望帮到你。本文还有配套的精品资源点击获取

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑