简介:这是一份面向网络编程初学者与网络管理人员的ICMP数据包构造实践资料,围绕ICMP协议原理与报文封装展开,帮助读者理解差错报告报文与查询报文的区别,并动手实现回显请求与应答的构造过程。资源包共3个文件,包含1个cpp源文件、1个h头文件与1个txt说明文档,分别承担核心构造逻辑、结构声明与使用说明,压缩包整体约5KB,体量轻便,适合快速阅读与本地编译调试。目前已有1607人学习下载,说明其在协议入门与实验练习中具有一定参考价值。读者可从中获得ICMP报文类型与代码字段的对应关系、IP数据报封装思路,以及借助Wireshark抓包过滤icmp并核对字段的验证方法,为网络诊断、协议分析与后续网络编程打下基础。
1. ICMP数据包构造:从 ping 不通到抓包看真相
机房割接后的凌晨两点,一台新上架的服务器 ping 网关不通,但同网段其他机器一切正常。换网线、换光模块、重启网卡都试过,最后抓包发现:这台机器发出的 ICMP Echo Request 里,源 IP 填的是一个早已下线的旧地址。问题不在物理层,在协议栈的地址选择逻辑上。这就是 ICMP 数据包构造要解决的事——当网络行为不符合预期时,你需要能亲手造一个包,把每一个字段摊开来看。
ICMP 数据包构造,指的是用程序或工具从零组装一个 ICMP 报文,控制它的类型、代码、校验和、标识符、序列号以及载荷内容,然后通过原始套接字发出去。它解决的是“标准 ping 命令不够用”的问题:你想指定源地址、想自定义载荷做路径探测、想模拟特定类型的 ICMP 差错报文、想验证防火墙对 ICMP 的过滤规则,标准工具都做不到。适合做网络协议开发、安全测试、运维排障的从业者,也适合想真正理解 IP 层之上发生了什么的学习者。
2. ICMP 报文结构拆解与构造前的三个选型决策
2.1 固定头部 8 字节里哪些字段必须自己算
ICMP 报文结构比很多人想象的简单。固定头部 8 字节,后面跟可变长度的数据部分。以最常见的 Echo Request(类型 8)为例:
| 字段 | 偏移 | 长度 | 说明 |
|---|---|---|---|
| 类型 Type | 0 | 1 字节 | 8 表示 Echo Request,0 表示 Echo Reply |
| 代码 Code | 1 | 1 字节 | Echo 类报文固定为 0 |
| 校验和 Checksum | 2 | 2 字节 | 覆盖整个 ICMP 报文,必须自己计算 |
| 标识符 Identifier | 4 | 2 字节 | 用于匹配请求与应答,通常填进程 ID |
| 序列号 Sequence | 6 | 2 字节 | 每发一个包递增 |
| 数据 Data | 8 | 可变 | 载荷,可以是时间戳或任意字节 |
类型和代码决定了这个 ICMP 报文要表达什么。除了 Echo 类,常见的还有类型 3(目的不可达)、类型 11(超时)、类型 5(重定向)。构造时最容易翻车的是校验和——它覆盖整个 ICMP 报文(头部加数据),计算方式是把数据按 16 位分组求和,再取反码。很多人只算了头部 8 字节,结果包发出去对方直接丢弃,抓包看就是 checksum incorrect。
标识符和序列号在 Echo 场景下用于匹配。如果你同时构造多个请求,标识符相同而序列号不同,就能区分哪个应答对应哪个请求。做路径探测时,通常递增序列号并配合 TTL 变化来逐跳发现路由器。
2.2 原始套接字、数据包库还是手工组包
构造 ICMP 包有三条路,选哪条取决于你的环境和控制粒度。
第一条是原始套接字(raw socket)。Linux 下用socket(AF_INET, SOCK_RAW, IPPROTO_ICMP),Windows 下需要管理员权限且 API 略有不同。优点是内核帮你处理 IP 头部,你只需要填 ICMP 部分;缺点是权限要求高,且不同系统行为有差异。
第二条是数据包构造库,比如 Python 的 Scapy。它把协议字段抽象成对象,你写IP(dst="10.0.0.1")/ICMP(type=8)/Raw(load=b"test")就能发出去。优点是开发快、跨平台、字段可读性好;缺点是引入依赖,且在高频发送场景下性能不如手工组包。
第三条是完全手工组包,自己构造 IP 头部和 ICMP 头部,通过原始套接字发送。控制粒度最细,可以伪造源 IP、设置 IP 选项、控制分片标志。缺点是代码量大,校验和要自己算,且伪造源 IP 在多数生产网络里会被反向路径过滤丢掉。
我一般会这样选:做协议验证和一次性探测用 Scapy,写进生产工具用原始套接字,需要伪造头部字段做安全测试才手工组包。下面给一个 Scapy 的最小可用示例,再给一个原始套接字的版本。
2.3 用 Scapy 构造并发送一个可控 ICMP Echo
先确认环境:Python 3.8 以上,安装 scapy 用pip install scapy。Linux 下需要 root 或 CAP_NET_RAW 权限。
from scapy.all import IP, ICMP, Raw, send, sr1 import time # 构造 ICMP Echo Request # type=8 表示请求,code=0 固定 # id 用进程号方便匹配,seq 从 1 开始 pkt = IP(dst="10.0.0.1", ttl=64) / ICMP(type=8, code=0, id=12345, seq=1) / Raw(load=b"hello-icmp") # send 只发不收,sr1 发一个收一个 reply = sr1(pkt, timeout=2, verbose=0) if reply: print(f"收到应答: 源地址={reply[IP].src}, 类型={reply[ICMP].type}, 序列号={reply[ICMP].seq}") print(f"载荷: {bytes(reply[Raw].load) if Raw in reply else '无'}") else: print("超时,未收到应答")这段代码的逻辑:IP(dst=...)指定目的地址和 TTL,ICMP(type=8)指定 Echo Request,Raw(load=...)填入自定义载荷。sr1发送后等待第一个应答,超时 2 秒。收到应答后从reply[ICMP].type判断是 0(Echo Reply)还是其他类型(比如 3 表示不可达)。
参数怎么调:ttl改小可以触发类型 11 的超时报文,用来做路径探测;id和seq用于匹配请求与应答,批量发送时务必保证唯一;load可以放时间戳,收到应答后算 RTT。如果要做路径 MTU 发现,需要设置 IP 头部的 DF 标志并逐步增大载荷,Scapy 里用IP(flags="DF")。
2.4 原始套接字版本:自己算校验和
当环境不允许装 Scapy,或者你需要精确控制每一个字节时,用原始套接字。下面是一个 Python 版本,Linux 下可直接跑。
import socket import struct import os import time def checksum(data): """计算 ICMP 校验和:16 位分组求和后取反码""" if len(data) % 2: data += b'\x00' s = 0 for i in range(0, len(data), 2): w = (data[i] << 8) + data[i + 1] s += w s = (s >> 16) + (s & 0xffff) s += s >> 16 return ~s & 0xffff def build_icmp_echo(ident, seq, payload=b"raw-icmp-test"): """组装 ICMP Echo Request 报文""" # 类型 8,代码 0,校验和先填 0 header = struct.pack("!BBHHH", 8, 0, 0, ident, seq) packet = header + payload # 计算校验和并回填 csum = checksum(packet) header = struct.pack("!BBHHH", 8, 0, csum, ident, seq) return header + payload def send_icmp(dst, timeout=2): ident = os.getpid() & 0xffff seq = 1 packet = build_icmp_echo(ident, seq) # 创建原始套接字,协议号 1 对应 ICMP sock = socket.socket(socket.AF_INET, socket.SOCK_RAW, socket.IPPROTO_ICMP) sock.settimeout(timeout) sock.sendto(packet, (dst, 0)) start = time.time() try: data, addr = sock.recvfrom(1024) rtt = (time.time() - start) * 1000 # IP 头部通常 20 字节,ICMP 从第 20 字节开始 icmp_type = data[20] icmp_code = data[21] print(f"来自 {addr[0]} 的应答: 类型={icmp_type}, 代码={icmp_code}, RTT={rtt:.2f}ms") except socket.timeout: print("请求超时") finally: sock.close() if __name__ == "__main__": send_icmp("10.0.0.1")关键点说明:struct.pack("!BBHHH", ...)里的!表示网络字节序(大端),B是 1 字节,H是 2 字节。校验和函数先把数据补齐到偶数长度,然后按 16 位累加,最后取反码。socket.IPPROTO_ICMP告诉内核只接收 ICMP 报文。收到数据后,前 20 字节通常是 IP 头部,第 20 字节开始才是 ICMP 内容——但注意,某些系统在原始套接字上不返回 IP 头部,所以更稳妥的做法是判断第一个字节:如果是 0x45(IPv4 且头部长度 20),就跳过 20 字节。
这个版本比 Scapy 更底层,你能看到校验和是怎么算出来的,也能在需要时修改 IP 头部字段。代价是代码更长,且不同系统对原始套接字的处理有差异。
3. 构造 ICMP 时最容易翻车的四个地方
3.1 校验和算错:包发出去了但没人理
现象:抓包能看到 Echo Request 发出,但没有任何应答,或者对方回的是类型 3 代码 1(主机不可达)。
原因:校验和只算了头部 8 字节,没算数据部分。或者字节序搞反了,把大端算成了小端。
解决:校验和必须覆盖整个 ICMP 报文。用 Scapy 时它自动算,不用管;手工组包时,先把校验和字段填 0,算完再回填。验证方法:用 Wireshark 抓包,看它显示的 checksum 状态是 correct 还是 incorrect。
3.2 原始套接字权限不足:socket 创建失败
现象:PermissionError: [Errno 1] Operation not permitted。
原因:Linux 下创建原始套接字需要 root 或 CAP_NET_RAW 能力,普通用户没有。
解决:用sudo运行,或者给 Python 解释器赋能力:sudo setcap cap_net_raw+ep $(which python3)。容器环境里需要在 securityContext 里加CAP_NET_RAW。Windows 下需要以管理员身份运行。
3.3 标识符冲突:应答匹配到了别的进程
现象:自己发的请求,收到的应答序列号对不上,或者载荷内容不是自己填的。
原因:标识符用了固定值,和系统 ping 命令或其他工具的标识符撞了。ICMP Echo 的标识符通常用进程 ID,但如果你手动填了一个常见值,就可能冲突。
解决:标识符用os.getpid() & 0xffff动态生成,或者用一个随机数。批量发送时,标识符保持不变,序列号递增,这样能区分同一进程发出的多个请求。
3.4 防火墙静默丢弃:没有差错报文也没有应答
现象:抓包只看到请求发出,没有任何回包,连类型 3 的不可达都没有。
原因:中间防火墙配置了 DROP 规则,直接丢弃 ICMP 报文,不返回任何差错。这和 REJECT 不同,REJECT 会回类型 3。
解决:用traceroute或逐步增大 TTL 的方式判断在哪一跳被丢。如果某跳之后全部超时,说明该跳之后有设备丢弃了 ICMP。这时需要换用 TCP 或 UDP 探测,或者联系网络管理员确认策略。
4. 用 ICMP 构造做路径探测与差错报文模拟
4.1 递增 TTL 实现逐跳发现
路径探测的原理很简单:发一个 TTL=1 的包,第一跳路由器收到后 TTL 减为 0,丢弃并回一个类型 11 代码 0 的超时报文,报文的源地址就是第一跳的地址。然后 TTL=2,第二跳回超时,以此类推,直到到达目的地址收到 Echo Reply。
from scapy.all import IP, ICMP, sr1 def trace_path(dst, max_hops=15): """用递增 TTL 做路径探测""" for ttl in range(1, max_hops + 1): pkt = IP(dst=dst, ttl=ttl) / ICMP(type=8, id=9999, seq=ttl) reply = sr1(pkt, timeout=1, verbose=0) if reply is None: print(f"{ttl}: 超时") continue src = reply[IP].src icmp_type = reply[ICMP].type if icmp_type == 0: print(f"{ttl}: {src} 到达目标") break elif icmp_type == 11: print(f"{ttl}: {src} 超时") else: print(f"{ttl}: {src} 类型={icmp_type} 代码={reply[ICMP].code}")参数说明:max_hops控制最大跳数,一般 15 到 30 够用。timeout=1是每跳等待时间,内网可以设 0.5,公网建议 2。seq=ttl让每个包的序列号不同,方便匹配。如果某一跳返回类型 3 代码 3(端口不可达),说明目标主机可达但对应服务没开——不过 ICMP Echo 不涉及端口,这里只是说明差错类型。
4.2 模拟类型 3 差错报文验证防火墙规则
有时你需要验证防火墙是否正确处理了 ICMP 差错报文。比如,构造一个类型 3 代码 4(需要分片但设置了 DF 标志)的报文,看目标主机是否接受。
from scapy.all import IP, ICMP, send # 构造类型 3 代码 4 的 ICMP 差错报文 # 差错报文需要携带原始 IP 头部和原始数据的前 8 字节 original_ip = IP(src="10.0.0.100", dst="10.0.0.1") original_payload = b"\x00" * 8 # 差错报文的数据部分 = 原始 IP 头部 + 原始数据前 8 字节 error_data = bytes(original_ip) + original_payload pkt = IP(dst="10.0.0.1") / ICMP(type=3, code=4) / error_data send(pkt, verbose=0) print("已发送类型 3 代码 4 的 ICMP 差错报文")逻辑说明:ICMP 差错报文(类型 3、4、5、11、12)的数据部分必须包含触发差错的原始 IP 头部和至少前 8 字节数据。这是 RFC 792 的要求,不遵守的话很多系统会直接丢弃。bytes(original_ip)把 Scapy 的 IP 对象序列化成字节。发送后用抓包工具确认对方是否回了类型 3 代码 4,或者是否静默丢弃。
参数怎么调:code可以改成 0(网络不可达)、1(主机不可达)、3(端口不可达)、13(管理禁止)等。不同代码对应不同的差错场景,验证防火墙策略时逐个试。
4.3 用 ICMP 载荷做时间戳与数据完整性验证
ICMP Echo 的载荷可以是任意字节。一个实用技巧是在载荷里放发送时间戳,收到应答后算 RTT,同时校验载荷是否被中间设备篡改。
import time import struct from scapy.all import IP, ICMP, Raw, sr1 def ping_with_timestamp(dst): """在载荷里放时间戳,收到后算 RTT 并校验载荷""" send_time = time.time() # 8 字节双精度浮点数存时间戳 payload = struct.pack("!d", send_time) pkt = IP(dst=dst) / ICMP(type=8, id=8888, seq=1) / Raw(load=payload) reply = sr1(pkt, timeout=2, verbose=0) if reply is None: print("超时") return recv_time = time.time() rtt = (recv_time - send_time) * 1000 # 提取应答载荷并解析时间戳 if Raw in reply: echoed = bytes(reply[Raw].load) if len(echoed) == 8: echoed_time = struct.unpack("!d", echoed)[0] if abs(echoed_time - send_time) < 0.001: print(f"载荷完整,RTT={rtt:.2f}ms") else: print(f"载荷被篡改: 发出={send_time}, 收到={echoed_time}") else: print(f"载荷长度异常: {len(echoed)}") else: print("应答无载荷")这段代码的价值在于:如果中间有设备修改了 ICMP 载荷(某些负载均衡或安全设备会这么做),你能立刻发现。struct.pack("!d", ...)把时间戳打包成 8 字节网络字节序的双精度浮点数。收到后解包对比,偏差超过 1 毫秒就认为被篡改。
5. 构造 ICMP 的几个进阶技巧与验证习惯
第一个技巧:用 ICMP 做路径 MTU 发现。设置 IP 头部的 DF 标志,然后逐步增大载荷长度。当某个长度发出去后收到类型 3 代码 4 的差错报文,说明该长度超过了路径 MTU。Scapy 里这样写:
from scapy.all import IP, ICMP, Raw, sr1 def find_path_mtu(dst, start=500, end=1500): """逐步增大载荷,找到路径 MTU""" for size in range(start, end + 1, 10): payload = b"\x00" * size pkt = IP(dst=dst, flags="DF") / ICMP(type=8, id=7777, seq=size) / Raw(load=payload) reply = sr1(pkt, timeout=1, verbose=0) if reply is None: print(f"载荷 {size} 字节: 超时") continue if reply[ICMP].type == 3 and reply[ICMP].code == 4: print(f"载荷 {size} 字节: 需要分片,路径 MTU 约为 {size + 28}") return size + 28 elif reply[ICMP].type == 0: print(f"载荷 {size} 字节: 通过") print("未发现 MTU 限制") return end + 28size + 28是因为 IP 头部 20 字节加 ICMP 头部 8 字节。这个值就是路径 MTU 的近似值。注意,有些网络屏蔽了类型 3 代码 4 的差错报文,这时这个方法会失效,需要换用 TCP 的 MSS 探测。
第二个技巧:验证 ICMP 重定向。类型 5 的 ICMP 重定向报文用于通知主机有更优路由。构造一个类型 5 代码 1(主机重定向)的报文,看目标主机是否更新了路由表。这个操作风险较高,只在实验环境做。
第三个习惯:每次构造完 ICMP 包,先用 Wireshark 抓包确认字段正确,再发到真实网络。我见过太多次因为校验和算错、字节序搞反、载荷长度不对导致的“玄学”问题,抓包一看全是低级错误。另一个习惯是:批量发送时给每个包打上唯一标识,收到应答后先匹配标识再处理,避免把别的进程的应答当成自己的。
最后一个血泪经验:在生产环境做 ICMP 构造测试前,先确认目标网络没有 ICMP 限速策略。有些设备对 ICMP 报文做了速率限制,每秒超过 10 个就丢弃,你的测试结果会完全不可靠。遇到这种情况,把发送间隔调到 200 毫秒以上,或者改用 TCP SYN 探测。希望帮到你。
本文还有配套的精品资源,点击获取