
简介该压缩包提供一套ICMP数据包构造的C实现主要面向网络协议学习者、网络管理员及需要理解互联网控制消息协议底层机制的开发人员。资源共3个文件包含一个cpp源文件、一个h头文件和一个txt说明文档压缩包大小仅5KB。通过阅读代码与说明读者可以掌握ICMP报文中类型字段、代码字段和可变数据字段的填写逻辑并理解其为何必须封装在IP数据报中才能发送。目前已有1607人学习下载说明该示例具备可参考性。cpp文件实现了ICMP回显请求/应答的构造h头文件定义了相关报文结构与常量的组织txt文档则补充了编译用法和验证要点。配合Wireshark抓包工具可以对照捕获到的报文检查源地址、目的地址及协议内容是否与预期一致从而加深对IP封装和ICMP差错报告、查询两类报文的认识。对于希望动手实践网络编程、开展网络故障诊断或进行协议安全分析的读者这份精简代码包能够直接编译运行起到从理论到实践的桥梁作用。1. 看懂ping才算入门ICMP数据包构造很多做网络的人都有个错觉天天用ping觉得自己对ICMP熟得不行。真到要自己构造一个ICMP数据包——把Type、Code、Checksum、Identifier、Sequence Number一个个按字节填进报文——才发现连报文长什么样都记不全。ICMP数据包构造这件事本质是回答三个问题你要发什么样的ICMP报文、报文里每个字段怎么填、内核帮你做了什么而你必须自己动手。这篇文章我按实际拆过的项目走一遍从协议格式到Python构造实现再到校验和、原始套接字这些绕不开的坑。适合三类人做网络监控想自定义探测包的、做安全测试需要构造畸形报文的、以及单纯想弄明白ping命令背后原理的。2. ICMP协议与报文结构构造之前先背下格式2.1 Type、Code、Checksum头三个字节一分都不能错ICMP报文由IP头之后的部分构成最前面8字节是ICMP头。所有类型的ICMP报文前4字节都是统一的Type1字节、Code1字节、Checksum2字节。真正构造的时候第一个要确定的字段就是Type。Type决定了这个报文是干什么的Echo Request是8Echo Reply是0Destination Unreachable是3Time Exceeded是11。Type和Code是联动的Code在特定Type下才有意义。比如Type 3Destination Unreachable的Code0表示网络不可达、1表示主机不可达、2表示协议不可达、3表示端口不可达、4表示分片需要但DF置位。构造时如果只填Type3Code是空的对端收到报文后大概率回一个ICMP参数错误。所以我的习惯是先查表确认Type和Code的组合是否合法再动手写代码。Checksum是整个ICMP报文包含头部和后面的数据部分的校验和。它的算法是把报文按16比特一组累加进位移到低位再加最后取反。很多人在这里翻车是因为忘了ICMP的Checksum计算范围是整个报文而IP头的Checksum只覆盖IP头。这个细节直接决定构造出来的包能不能被对端接受尤其是做无回复测试时校验错的对端直接丢包连错误都不给你回。2.2 Identifier与Sequence Number构造Echo类报文必须自己维护的会话字段Echo Request和Echo Reply这一类报文头8字节里除了Type、Code、Checksum还有两个字段Identifier2字节和Sequence Number2字节。这两个字段是给发送方做会话匹配用的。内核里ping实现的做法是Identifier取进程PIDSequence Number每次发送递增1。你自己构造时完全可以用任意值但要保证你发送的请求和接收的回复能对应上否则你收到一个包根本不知道是不是你之前发的。我一般这样分配Identifier固定成一个足够独特的数比如某个端口号或者从系统时间戳截取的低16位Sequence Number按发送顺序递增超出65535就回绕到0。关键点是字节序——这两个字段在封装进报文时必须是网络字节序大端所以在Python里用struct.pack(H, value)而不是小端。这一点错导致的后果是对方回包时会把这两个字段原样抄回来你本地解析时如果按小端去解匹配永远对不上。Type与常见的Code组合我会直接列成一张表记在工作笔记里Type名称常见Code构造时注意0Echo Reply0被动回复一般不主动构造3Destination Unreachable0/1/3/4Code 4需要带引发错误包的部分IP报文5Redirect0/1少用部分系统禁用8Echo Request0ping的基础构造最频繁11Time Exceeded0/1Traceroute的关键3. 用原始套接字手写ICMP Echo Request一个能跑通的Python实现3.1 创建原始套接字权限、协议族和平台差异构造ICMP报文的第一步不是填充字段而是先拿到一个能发包的原始套接字。Python里用socket库创建import socket # AF_INET 表示IPv4SOCK_RAW 表示原始套接字 # IPPROTO_ICMP 指定协议为ICMP内核不会再往IP之上加一层头 sock socket.socket(socket.AF_INET, socket.SOCK_RAW, socket.IPPROTO_ICMP)这段代码的逻辑是绕过TCP/UDP的封装告诉内核“这个套接字我自己构造IP载荷你只管加IP头”。注意IPPROTO_ICMP只是告诉内核该套接字用于ICMPIP头仍然由内核帮你加上去。如果你的需求是连IP头都自己改——比如设置自定义TTL、DF标志或IP选项——就需要用IPPROTO_RAW或者给原始套接字设置IP_HDRINCL选项。参数说明SOCK_RAW在Linux和macOS上的行为略有差别。Linux下你需要root权限macOS下需要root或额外的entitlementWindows上的Python标准库没有原始套接字支持。所以这段代码最稳的运行环境是Linux root。我一般会先检查当前权限提醒自己别到跑的时候才报Permission denied。3.2 校验和计算实现细节与验证方法校验和的实现是整个构造过程里最需要耐心的地方。标准的IPv4校验和算法如下ICMP直接复用同一套逻辑def checksum(data: bytes) - int: # 如果字节数为奇数末尾补一个0字节 if len(data) % 2 1: data b\x00 # 按16位一组累加sock.ntohs保证读取的是网络字节序 s 0 for i in range(0, len(data), 2): s (data[i] 8) data[i 1] # 溢出回卷把高16位移到低16位继续累加 while s 16: s (s 0xFFFF) (s 16) # 取反并截断到16位 return (~s) 0xFFFF这段代码的含义是把校验和字段先置为0然后以16比特为单位做二进制反码求和最后取反。注意累加时要读成网络字节序的数值也就是高位字节在前否则同样的内容Ones Complement的结果完全不同。ntohs在这里的作用就是完成主机序和网络序的转换。有一种验证方式是拿发送的报文和线上抓包工具抓到的报文做对比。我每天构造完都会先用tcpdump或Wireshark抓一次看目标机器回的Echo Reply里的Checksum是否和本地计算一致。如果对端回包了说明你的校验和至少被对端认为是合法的。3.3 完整实现构造、发送、接收与超时处理把前面的片段拼起来一个最小可用的ICMP Echo Request构造器长这样import socket import struct import time def build_icmp_echo(identifier: int, seq: int, payload: bytes) - bytes: # ICMP Echo Request: type8, code0 # checksum先填0计算后再回填 header_no_checksum struct.pack(BBHHH, 8, 0, 0, identifier, seq) # 校验和计算范围头部数据 ck checksum(header_no_checksum payload) header struct.pack(BBHHH, 8, 0, ck, identifier, seq) return header payload def send_and_receive(target: str, count: int 3): sock socket.socket(socket.AF_INET, socket.SOCK_RAW, socket.IPPROTO_ICMP) sock.settimeout(2.0) identifier 0x1234 for i in range(count): payload bhello icmp construct packet build_icmp_echo(identifier, i, payload) sock.sendto(packet, (target, 0)) try: data, addr sock.recvfrom(1024) # 收到的data包含IP头IP头长度前4字节的第4位决定偏移 ip_header_len (data[0] 0x0F) * 4 icmp_header data[ip_header_len:ip_header_len8] icmp_type, icmp_code, _, recv_id, recv_seq struct.unpack(BBHHH, icmp_header) if icmp_type 0 and recv_id identifier and recv_seq i: print(fseq{i} reply ok, rtt{time.time() - start:.3f}s) except socket.timeout: print(fseq{i} timeout) send_and_receive(192.168.1.1)逻辑说明build_icmp_echo把头部打包成网络字节序计算校验和再回填send_and_receive创建套接字每次发送后立即阻塞接收。接收时因为原始套接字收到的包包含IP头所以必须先解析IP头长度再定位ICMP头。匹配条件有三个Type为0Echo Reply、Identifier一致、Sequence Number一致。参数上值得注意的地方recvfrom的缓冲区1024字节通常够用但如果网络上MTU很大、对端回包带了额外数据就可能截断。我实际遇到过一次对端回包长度超过1500字节只能把缓冲区调大。另外sendto的目标地址带端口0因为ICMP的报文里没有端口概念端口字段在此处无意义。4. 避坑校验和、长度与权限的四个典型翻车现场4.1 Permission deniedPython一行报错现场全是root问题现象运行脚本直接抛PermissionError: [Errno 1] Operation not permitted代码一眼看去没问题。原因原始套接字SOCK_RAW要求进程有超级用户权限。Linux默认非root用户禁止创建原始套接字macOS还要额外在系统设置里授权。很多人在自己笔记本上能跑一放到临时容器或不带特权的CI环境就挂。解决先用id -u确认当前用户是root非root环境用sudo执行或者给Python解释器加CAP_NET_RAW能力setcap cap_net_rawep $(which python3)。注意加了capability之后再执行也需要文件系统支持安全属性容器里还要看Seccomp配置。从那以后我写构造脚本第一件事都是先打印uid检查环境。4.2 校验和的字节序按小端算的包直接进黑洞现象构造完发送到目标对方完全没回应tcpdump抓本地出包能看到包发出去了但目标网络里就是没有收到。原因我在校验和实现里把ntohs丢掉了直接按主机字节序读16位数据。虽然一端的机器是小端序无所谓但网络字节序要求高位在前校验和的值和对方按大端计算的完全不一致。对端把校验和放进包后一算错了直接丢弃。解决校验和函数里的数据读取严格按网络字节序。最稳的做法是把校验和的计算和验证都放在字节流层面做不要转换成host字节序的整数。血的教训写完之后拿tcpdump对比一遍再混日子直接会出现“发了十次一次没回”的玄学现象。4.3 收到回复但匹配不上Identifier和Sequence的解析端序坑现象目标回了Echo Reply程序确实收到了但recv_id打印出来和发送时填的完全对不上明明用的是同一个变量。原因接收时解析ICMP头用了struct.unpack(BBHHH)这个格式没问题错在我自己发送时把Identifier用了小端序列化struct.pack(H, id)。发送方和接收方各用各的字节序虽然两者字段数值一致但在报文里顺序不同导致接收端解析出来的值被解释成了另一个数字。解决规范做法是全链路统一网络字节序发送和接收都用格式。有一类偏方是解析时也用小端两边都错就会碰巧对上但一旦在别的平台上跑就断。正确做法只有一个发送接收同用BBHHH并在校验阶段打印发送值和解出的值定位这种隐藏端序问题。4.4 长度计算错误校验和计算范围少了尾部数据现象构造出来的包很多时候能用但一旦payload长度是奇数就会出现偶发丢包。原因ICMP校验和的计算范围是整个ICMP报文头部payload。我最初偷懒只计算了8字节头后续payload较长时校验和的累加结果不同对端发现校验和不匹配会直接丢弃。而且奇数字节长度时校验和算法要求在末尾补一个0字节参与计算这个容易忘。解决严格按“头部数据整体参与校验和”来算。把checksum函数调用放在打包完的完整报文上而不是只对头部计算。现在我的构造代码里校验和只接受完整报文不会再因为少算几个字节造成谜一样的丢包。5. 进阶把构造的ICMP包当探测工具——Traceroute原理与抓包验证最后一步把构造能力用在一个真实场景实现一个最简版Traceroute。Traceroute的本质是发送TTL从1开始递增的ICMP Echo Request每经过一个路由器TTL减1减到0时路由器回一个ICMP Time ExceededType11报文。路由器不回Echo Reply但你收到Type11的包就说明路径上一个节点被你探测到了。构造这个包和Echo Request唯一的区别是需要显式设置IP头里的TTL字段。用之前的sendto方式TTL由内核默认值控制所以要改用设置socket选项的方式import socket import struct import time # 创建原始套接字 sock socket.socket(socket.AF_INET, socket.SOCK_RAW, socket.IPPROTO_ICMP) # 设置TTL为hops每次探测递增 sock.setsockopt(socket.IPPROTO_IP, socket.IP_TTL, hops) # 发送Echo Request等待Type11或Type0关键参数说明setsockopt(socket.IPPROTO_IP, socket.IP_TTL, hops)在Linux下是设置IP头TTL的标准接口在macOS上同样适用。收到Type11报文时需要从ICMP头中读取原报文的源IP地址作为中间跳的路由器地址。我实际跑这个探测的时候发现一个细节很重要超时时间不能设太短。有些路由器对ICMP的处理优先级很低在高负载环境下响应会慢到几百毫秒以上。给我自己的探测脚本设置2秒超时连续丢包3次就认为这一跳不可达或者被过滤了。验证方法先把构造出来的Echo Request发到内网网关再用tcpdump -i eth0 icmp抓包比对看发出的包的Identifier和Sequence是否与预期一致、是否成对出现再对照Wireshark里的Checksum看计算是否正确。全部对上这套构造流程就算真正跑顺了——那次之后我再写任何网络探测脚本都强制把“校验和 端序 TTL 权限检查”这四件事在代码里先过一遍再也不用对着黑盒子猜哪一层出了问题。希望这篇笔记能帮你在构造ICMP数据包的路上少走几步弯路。本文还有配套的精品资源点击获取