ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

ICMP数据包构造实战:从校验和计算到原始套接字发送与路径MTU探测

ICMP数据包构造实战:从校验和计算到原始套接字发送与路径MTU探测 简介这份资源围绕ICMP数据包构造展开面向网络编程初学者、网络管理员及备考网络相关认证的学习者帮助理解ICMP协议在TCP/IP协议族中的定位与底层通信机制。压缩包共3个文件包含1个cpp源文件、1个h头文件与1个txt说明文档整体约5KB体量轻巧便于快速阅读与二次修改。其中头文件用于声明ICMP报文结构与相关函数源文件给出数据包构造的具体实现说明文档则补充协议背景与使用指引。资源重点讲解差错报告报文与查询报文的区别以及类型字段、代码字段和数据字段的组成方式并说明如何将ICMP报文封装进IP数据报再借助Wireshark抓包验证类型、代码与数据是否符合预期。目前已有1607人学习下载适合希望从代码层面掌握ICMP构造、加深网络诊断与协议分析理解的学习者参考。1. ICMP 数据包构造从 ping 不通到自定义探测的最后一公里你有没有遇到过这种场景内网一台设备 ping 不通但业务端口是活的或者你想测一条链路对特定 ICMP 类型的放行策略系统自带的 ping 只会发 Echo Request根本不够用。这时候就得自己构造 ICMP 数据包。ICMP 数据包构造说白了就是绕过系统 ping 命令的封装从 IP 头到 ICMP 头逐字节拼出一个报文再通过原始套接字发出去。它能解决三类问题自定义 Type/Code 探测、带载荷的路径 MTU 发现、以及排查防火墙对 ICMP 的细粒度过滤规则。适合做网络排障、安全评估和协议栈开发的从业者。下面按“原理→环境→构造→发送→避坑→进阶”的顺序把可复现的路径讲清楚。2. ICMP 报文结构与校验和为什么你拼出来的包总被丢弃2.1 ICMP 头部字段的逐字节含义ICMP 报文紧跟在 IP 头之后固定 8 字节头部加可变数据区。Type 决定报文大类Code 是子类型Checksum 覆盖整个 ICMP 报文不包含 IP 头后 4 字节随 Type 变化。以最常用的 Echo RequestType 8和 Echo ReplyType 0为例后 4 字节是 Identifier 和 Sequence Number用来匹配请求与应答。字段偏移长度Echo 场景取值说明Type01 字节8 请求 / 0 应答报文大类Code11 字节0Echo 固定为 0Checksum22 字节计算得出覆盖整个 ICMP 报文Identifier42 字节自定义匹配请求与应答Sequence62 字节递增区分同一请求的不同包Data8可变任意载荷常用于 MTU 探测很多人第一次构造时只填了 Type 和 CodeChecksum 留 0结果抓包看到包发出去了对端一个回包都没有。原因就是校验和错误接收端协议栈直接静默丢弃连 ICMP 错误都不回。2.2 校验和算法反码求和的那点玄学ICMP 校验和用的是 16 位反码求和算法本身不复杂但有两个容易翻车的点一是数据区长度为奇数时要补一个 0 字节再算二是求和过程中的进位要回卷加到低位。下面这段 Python 实现可以直接抄。def checksum(data: bytes) - int: # 若数据长度为奇数末尾补一个 0 字节 if len(data) % 2 ! 0: data b\x00 total 0 # 每 2 字节组成一个 16 位字累加 for i in range(0, len(data), 2): word (data[i] 8) data[i 1] total word # 进位回卷把超出 16 位的部分加回低位 total (total 0xFFFF) (total 16) # 最后取反码 return ~total 0xFFFF逻辑说明data[i] 8是因为网络字节序是大端高位在前。total (total 0xFFFF) (total 16)这行是反码求和的核心每次累加后立刻回卷进位避免最后一次性处理导致溢出。参数方面传入的data必须是完整的 ICMP 报文头部加数据区不能只传头部。如果你用struct.pack拼包注意!前缀表示网络字节序漏了它校验和一定错。2.3 用 struct 拼出第一个 Echo RequestPython 标准库的struct是拼二进制报文最顺手的工具。下面构造一个带 32 字节载荷的 Echo Request。import struct def build_echo_request(ident: int, seq: int, payload: bytes) - bytes: # Type8, Code0, Checksum 先填 0 占位 header struct.pack(!BBHHH, 8, 0, 0, ident, seq) packet header payload # 计算校验和并回填到偏移 2 的位置 chksum checksum(packet) packet struct.pack(!BBHHH, 8, 0, chksum, ident, seq) payload return packet payload bA * 32 pkt build_echo_request(0x1234, 1, payload) print(pkt.hex())逻辑说明struct.pack(!BBHHH, ...)中!是大端B是 1 字节H是 2 字节顺序对应 Type、Code、Checksum、Identifier、Sequence。先填 0 算校验和再回填这是标准做法。参数上ident建议用进程 PID 的低 16 位避免多个探测进程互相干扰seq从 1 开始递增方便统计丢包。载荷长度直接影响路径 MTU 探测的结果后面会细说。3. 原始套接字发送权限、字节序与内核回包的三道坎3.1 原始套接字的创建与权限要求构造好的 ICMP 包要通过原始套接字raw socket发出去。Linux 下需要CAP_NET_RAW能力或 root 权限Windows 下需要管理员权限。下面是最小发送代码。import socket def send_icmp(dst: str, packet: bytes): # AF_INET SOCK_RAW IPPROTO_ICMP 表示原始 ICMP 套接字 sock socket.socket(socket.AF_INET, socket.SOCK_RAW, socket.IPPROTO_ICMP) # 设置超时避免阻塞 sock.settimeout(3) sock.sendto(packet, (dst, 0)) sock.close()逻辑说明SOCK_RAW配合IPPROTO_ICMP告诉内核“我要自己处理 ICMP 层”内核会自动补 IP 头。sendto的地址端口填 0 即可ICMP 没有端口概念。参数上settimeout建议设 2 到 3 秒太短会误判丢包太长排障效率低。如果你在容器里跑注意--cap-addNET_RAW或--privileged否则socket()直接抛PermissionError。3.2 接收回包为什么 recvfrom 拿到的是整个 IP 包原始套接字接收时内核返回的是完整的 IP 包不是纯 ICMP 报文。你需要跳过 20 字节 IP 头如果带选项则更长再解析 ICMP。def recv_icmp(sock): data, addr sock.recvfrom(65535) # IP 头长度在低 4 位单位是 4 字节 ihl (data[0] 0x0F) * 4 icmp_type data[ihl] icmp_code data[ihl 1] ident, seq struct.unpack(!HH, data[ihl 4:ihl 8]) return addr[0], icmp_type, icmp_code, ident, seq逻辑说明data[0] 0x0F取 IP 头长度字段乘 4 得到字节数这是处理可变 IP 选项的正确姿势。硬编码 20 在某些带时间戳选项的链路上会解析错位。参数上recvfrom(65535)的缓冲区要足够大否则大载荷回包会被截断。拿到ident和seq后要和发送记录比对避免把其他进程的 ICMP 回包算进来。3.3 字节序与校验和回填的常见误用字节序问题在 ICMP 构造里非常隐蔽。struct.pack(!BBHHH, ...)的!保证大端但如果你手动用位移拼包比如(type 8) | code那就变成小端了对端解析出的 Type 和 Code 会完全错位。另一个误用是校验和回填时重新拼了整个包但忘了保留原载荷导致校验和与载荷不匹配。我一般会先把完整包拼好存一个变量算完校验和只替换偏移 2 到 4 的两个字节而不是重新 pack 一遍。4. 避坑与排查构造 ICMP 时最容易翻车的 5 个点4.1 现象包发出去了但抓不到回包原因通常有三层校验和错误被静默丢弃、防火墙丢弃了该 Type/Code、或者目标地址不可达但 ICMP 错误被上层过滤。排查顺序是先在本机用tcpdump确认包确实发出且校验和正确再在目标侧抓包看是否到达。如果目标侧没收到查中间设备对 ICMP 的放行策略。解决方法是先用标准 ping 确认链路通再逐步替换成自定义包缩小变量范围。4.2 现象recvfrom 收到自己的请求包原因是在同一台机器上发送和接收时原始套接字会收到所有 ICMP 流量包括自己发出的 Echo Request。解决方法是解析出 Type 后过滤只处理 Type 0Echo Reply或你关心的错误类型同时用 Identifier 匹配自己的进程。别偷懒只判断地址本机回环场景下地址是一样的。4.3 现象大包发送后收到 Type 3 Code 4这是路径 MTU 发现机制在起作用。Type 3 是 Destination UnreachableCode 4 是 Fragmentation Needed。说明你发的包超过了某段链路的 MTU 且设置了 DF 位。解决方法是根据回包中携带的下一跳 MTU 值调小载荷或者清除 DF 位让中间设备分片。构造时 IP 头的 DF 位在原始套接字下由内核控制需要setsockopt配合IP_MTU_DISCOVER调整。4.4 现象Windows 上 socket 创建失败Windows 的原始套接字限制比 Linux 多SOCK_RAW配合IPPROTO_ICMP在部分版本上需要管理员权限且不能发送任意 Type。解决方法是确认以管理员身份运行或者改用IPPROTO_RAW并自行构造 IP 头。如果还是不行检查安全软件是否拦截了原始套接字调用。4.5 现象校验和算出来和抓包工具不一致先确认抓包工具显示的是校验和字段的原始值还是验证后的值。Wireshark 默认会验证并显示“correct”或“incorrect”但某些版本对 ICMP 校验和的展示是原始字节。其次确认你的数据区长度是否包含填充字节。最后检查是否在计算时把 Checksum 字段本身也算进去了——正确做法是计算时该字段置 0算完再回填。5. 进阶用 ICMP 做路径 MTU 探测与时间戳请求5.1 路径 MTU 探测的二分搜索策略路径 MTU 探测的核心是发送不同大小的 ICMP Echo Request 并设置 DF 位收到 Type 3 Code 4 就说明当前尺寸超了。用二分搜索可以在 log2(1500-68) 约 11 次探测内收敛到精确 MTU。下面是一个简化实现框架。def probe_mtu(dst: str, low: int 68, high: int 1500) - int: while low high: mid (low high 1) // 2 # 载荷 MTU - IP头20 - ICMP头8 payload bX * (mid - 28) pkt build_echo_request(0x1234, mid, payload) # send_with_df 需设置 DF 位此处省略套接字选项细节 reply send_and_recv(dst, pkt) if reply and reply.type 3 and reply.code 4: high mid - 1 # 超 MTU往小搜 else: low mid # 未超往大搜 return low逻辑说明mid - 28是因为 IP 头 20 字节加 ICMP 头 8 字节载荷加上这两个才是实际 MTU。二分收敛条件是low high最终low就是可用 MTU。参数上high初始值设 1500 是以太网默认如果链路支持巨帧可以调大。注意每次探测的 Identifier 保持一致Sequence 递增方便匹配回包。5.2 时间戳请求与往返时延计算ICMP 时间戳请求Type 13和时间戳应答Type 14可以绕过应用层直接测链路 RTT。报文后 12 字节分别是 Originate、Receive、Transmit 三个时间戳单位是毫秒从 UTC 午夜起算。构造时 Originate 填当前时间Receive 和 Transmit 填 0对端回包时会填充。计算 RTT 用接收时刻减去 Originate 即可。这个方法的精度受限于毫秒级时间戳不如应用层探测精细但胜在不需要目标开放任何端口。5.3 我踩过的那些坑早期我构造 ICMP 时总想省事校验和直接用网上抄来的一段代码结果那段代码没处理奇数长度补零小载荷测试全过一上 31 字节载荷就全丢。后来养成的习惯是任何校验和实现先用已知正确的抓包数据做单元测试确认输入输出一致再集成。另一个习惯是每次构造完先在本机回环上发一遍用tcpdump -i lo -vv看校验和字段确认无误再发到真实链路。这个习惯帮我省了至少几十次“包发出去了但没回”的排查时间。希望帮到你。本文还有配套的精品资源点击获取
RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进