☰
Scapy实现ICMP Ping扫描:从报文构造到并发优化
2026/10/15 9:51:29 网站建设 项目流程

1. 从"能通就行"到"扫得明白":为什么还要自己写ping扫描

很多人第一次接触网络探测,都是从系统自带的ping命令开始的。敲一行ping 192.168.1.1,看到回包就以为万事大吉。但真到了需要批量判断一个网段里哪些地址活着、哪些地址是"假死"的时候,靠手敲命令一条条试,效率低到让人抓狂。更麻烦的是,系统自带的 ping 在不同平台上参数还不一样,Windows 用-n指定次数,Linux 用-c,写个脚本还得先判断操作系统,维护成本高得离谱。

这时候 Scapy 就派上用场了。Scapy 是一个用 Python 写的交互式数据包处理工具,它能让你像搭积木一样自由构造、发送、捕获和解析网络数据包。用它来做 ping 扫描,本质上就是自己构造 ICMP Echo Request 报文发出去,然后监听有没有 ICMP Echo Reply 回来。听起来和系统 ping 干的事一样,但区别在于:整个过程完全在你自己的代码掌控之中,你可以自定义超时、并发策略、源地址、TTL、载荷内容,甚至可以在同一个脚本里把结果直接写进数据库或者生成可视化报表。

这篇文章适合两类人看。一类是刚学 Python 网络编程、想找个能跑通又有实际用途的小项目练手的朋友;另一类是做运维、安全评估或者内网资产梳理,需要自己定制扫描逻辑的从业者。我会从 ICMP 协议的基本原理讲起,把 Scapy 构造报文、发送、收包的完整链路拆开揉碎,再给出可直接复现的代码和实测中踩过的坑。读完你不仅能跑通一个 ping 扫描器,还能理解为什么有些地址明明在线却扫不到、为什么并发数不是越大越好、以及怎么在不触发告警的前提下把扫描做得更"温柔"。

需要提前说明的是,本文所有示例仅用于学习网络协议原理和合法的资产自查场景。在任何网络环境中进行扫描之前,请确保你拥有该网络的明确授权,未经授权的扫描行为可能违反相关规定。

2. ICMP Echo 的报文结构与 Scapy 的构造逻辑

2.1 一个 ICMP Echo Request 里到底装了什么

要自己造 ping 包,先得知道这个包长什么样。ICMP(Internet Control Message Protocol)是 IP 层的伴生协议,它不承载用户数据,专门用来传递网络层的控制信息。Echo Request 和 Echo Reply 是其中最常见的一对,类型号分别是 8 和 0。

一个完整的 ICMP Echo Request 报文包含以下字段:

字段长度说明
Type1 字节请求为 8,应答为 0
Code1 字节Echo 类型固定为 0
Checksum2 字节整个 ICMP 报文的校验和
Identifier2 字节用于匹配请求与应答的标识符
Sequence Number2 字节序列号,用于区分同一批请求
Data可变载荷,系统 ping 默认填充时间戳等数据

Scapy 的妙处在于,你不需要手动去算校验和、拼字节。它用分层的方式组织协议,你只需要写IP(dst="目标")/ICMP(),Scapy 会自动帮你填好各层的默认值,并在发送前计算校验和。斜杠/是 Scapy 的层叠运算符,读作"叠加",表示把 ICMP 层装在 IP 层里面。

2.2 Identifier 和 Sequence 的匹配机制

这是很多人第一次写扫描器时容易忽略的点。当你同时向多个目标发包时,回来的 Echo Reply 怎么知道是哪个请求的回应?靠的就是 Identifier 和 Sequence Number 这一对组合。

系统 ping 通常用进程 ID 作为 Identifier,每发一个包 Sequence 加一。我们自己写的时候,可以手动指定这两个值,也可以让 Scapy 自动生成。但在批量扫描场景下,建议手动控制 Sequence,因为这样你能清楚地知道哪个序号对应哪个目标,收包时做匹配会简单很多。

具体做法是:给每个目标分配一个唯一的 Sequence 号,发出去之后在收包循环里检查回来的包的 Sequence 是否在预期集合中。如果只是简单判断"有没有回包",那确实不用管这些;但如果你想知道"哪个 IP 回了、哪个没回、往返时间多少",匹配逻辑就必须做扎实。

2.3 Scapy 的发送与接收函数怎么选

Scapy 提供了好几个发送和接收的函数,用途各不相同,选错了要么收不到包,要么性能差得离谱。常见的几个:

  • send():只发不收,工作在第三层(网络层),适合你只关心发包不关心回包的场景。
  • sendp():只发不收,工作在第二层(数据链路层),需要指定网卡接口。
  • sr():发并收,返回"已应答"和"未应答"两个列表,工作在第三层。
  • sr1():发一个收一个,只返回第一个应答包,适合单目标探测。
  • srp():发并收,工作在第二层。

做 ping 扫描,最核心的是sr()。它一次性把包发出去,然后在超时时间内收集所有回包,最后把"有回应的"和"没回应的"分开返回。这个设计非常契合扫描场景——你不需要自己写循环去等每个包,Scapy 帮你做了批量处理。

不过要注意,sr()默认是串行等待的,也就是说它会等完整个超时时间才返回。如果你扫 254 个地址、超时设 2 秒,那最坏情况下要等 2 秒(因为包是并发发出的,不是一个个等)。这个行为在后面的并发章节会详细展开。

3. 从零写出第一个可用的 ping 扫描脚本

3.1 环境准备与依赖确认

先把环境弄干净。Scapy 在主流 Linux 发行版上可以直接用包管理器装,也可以走 pip。我习惯用虚拟环境,避免和系统 Python 的包打架:

python3 -m venv venv source venv/bin/activate pip install scapy

装完之后验证一下:

from scapy.all import * print(conf.version)

能打印出版本号就说明装好了。这里有个细节:Scapy 发送原始套接字需要 root 权限(或者 Linux 上的CAP_NET_RAW能力)。如果你用普通用户跑,会报PermissionError或者干脆发不出去。所以脚本要么用sudo跑,要么给 Python 解释器单独授权。我一般图省事直接sudo,但在生产环境里更推荐用能力授权的方式,最小化权限。

3.2 单目标探测:先跑通再扩展

别一上来就写批量扫描,先用最简单的单目标把链路跑通。下面这段代码向一个目标发一个 ICMP Echo,然后等回应:

from scapy.all import IP, ICMP, sr1 def ping_one(target, timeout=2): pkt = IP(dst=target) / ICMP() reply = sr1(pkt, timeout=timeout, verbose=0) if reply is None: return None return reply.src result = ping_one("192.168.1.1") print(result)

verbose=0是为了关掉 Scapy 默认的详细输出,不然每发一个包它都会在终端刷一堆信息,批量扫描时会把屏幕淹掉。sr1返回的是第一个匹配的应答包,如果超时没收到就返回None。

跑通这一步,你就已经完成了 ping 扫描最核心的动作。剩下的批量、并发、结果整理,都是在这个基础上做工程化包装。

3.3 批量扫描:用 sr() 一次发一批

单目标跑通后,扩展到网段。最直接的想法是写个 for 循环,一个个调ping_one。但这样效率太低,254 个地址串行发,每个等 2 秒,那就是 500 多秒。正确做法是用sr()把整批包一次性发出去:

from scapy.all import IP, ICMP, sr import ipaddress def ping_sweep(network, timeout=2): net = ipaddress.ip_network(network, strict=False) targets = [str(ip) for ip in net.hosts()] pkts = [IP(dst=t) / ICMP() for t in targets] answered, unanswered = sr(pkts, timeout=timeout, verbose=0) alive = [] for sent, recv in answered: alive.append(recv.src) return alive alive_hosts = ping_sweep("192.168.1.0/24") for host in alive_hosts: print(host)

这里用ipaddress模块来解析网段,net.hosts()会自动排除网络地址和广播地址,省得你自己算。sr()接收一个包列表,返回两个列表:answered里是(发送包, 接收包)的元组,unanswered里是没收到回应的发送包。

实测下来,扫一个 /24 网段,超时设 2 秒,整个函数大概 2 秒多一点就返回了。因为包是并发发出的,等待时间基本等于超时时间,而不是 254 乘以超时。这就是sr()相比手写循环的最大优势。

3.4 结果整理:把"活着的主机"变成可用数据

扫出来的结果如果只是打印在屏幕上,价值有限。实际项目里通常需要把结果整理成结构化数据,方便后续处理。我一般会返回一个字典列表,每个元素包含 IP、往返时间、TTL 等信息:

import time def ping_sweep_detail(network, timeout=2): net = ipaddress.ip_network(network, strict=False) targets = [str(ip) for ip in net.hosts()] pkts = [IP(dst=t) / ICMP() for t in targets] start = time.time() answered, unanswered = sr(pkts, timeout=timeout, verbose=0) elapsed = time.time() - start results = [] for sent, recv in answered: rtt = (recv.time - sent.sent_time) * 1000 results.append({ "ip": recv.src, "rtt_ms": round(rtt, 2), "ttl": recv.ttl, }) return results, elapsed

recv.time是收到包的时间戳,sent.sent_time是发出包的时间戳,两者相减就是往返时间。注意 Scapy 的时间戳单位是秒,乘以 1000 转成毫秒更直观。TTL 值也有用,它能粗略反映目标主机的操作系统类型和经过的路由跳数,后面会细说。

拿到这个结果列表,你想写 CSV、存数据库、生成 HTML 报表都行,数据已经规整好了。

4. 并发、超时与"扫不到"的那些坑

4.1 超时设多少才合理

超时是 ping 扫描里最需要拿捏的参数。设太短,网络稍微抖一下目标就被判成"不在线",漏报;设太长,整个扫描过程拖沓,扫大网段时体验极差。

我的经验是:局域网内 1 秒足够,跨网段或无线环境给到 2 秒,跨地域的远程目标可以给到 3 秒。判断依据是正常 RTT 通常远小于这些值——局域网 RTT 一般在 1 毫秒以内,同城跨网段也就几毫秒到几十毫秒。超时设 1 秒已经是正常 RTT 的几十上百倍,留足了余量。

但有个反直觉的点:超时时间并不等于扫描总耗时。因为sr()是并发发包的,总耗时约等于"最后一个回包到达的时间"或"超时时间",取两者中较早满足全部包处理完的那个。所以你把超时从 1 秒调到 2 秒,扫描时间不会翻倍,只是最坏情况下的等待上限变长了。

4.2 并发数不是越大越好

有人觉得,既然要快,那就把整个 /16 网段一次性全发出去,六万多个包同时轰。这么做的问题在于:

第一,本地网络栈和网卡的发包队列有上限,一次性塞太多包,内核缓冲区满了之后会丢包,导致本来在线的目标因为你的包根本没发出去而被判成离线。第二,大量 ICMP 包瞬间涌出,很容易触发中间网络设备的限速或告警,轻则丢包重则被封。第三,收包端如果回包太集中,Scapy 的处理速度跟不上,也会丢。

比较稳妥的做法是分批发送,每批控制在 256 到 512 个包之间。扫 /24 网段一批就够,扫 /16 就分成 256 批。下面是一个分批扫描的封装:

def ping_sweep_batched(network, batch_size=256, timeout=2): net = ipaddress.ip_network(network, strict=False) all_hosts = [str(ip) for ip in net.hosts()] alive = [] for i in range(0, len(all_hosts), batch_size): batch = all_hosts[i:i+batch_size] pkts = [IP(dst=t) / ICMP() for t in batch] answered, _ = sr(pkts, timeout=timeout, verbose=0) for sent, recv in answered: alive.append(recv.src) return alive

这个版本扫 /16 网段时,内存占用可控,也不会因为一次性发包太多而丢包。批大小可以根据你的机器性能调整,我一般用 256,老机器上降到 128 也很稳。

4.3 为什么有些主机"明明在线却扫不到"

这是 ping 扫描最常见的困惑。目标机器开着,服务也正常,但就是收不到 ICMP 回应。原因通常有这几类:

目标主机禁用了 ICMP 回应。很多服务器出于安全考虑,在防火墙里直接 drop 掉 ICMP Echo Request。这种情况下你发多少包都没用,但目标的其他端口可能是开放的。解决办法是不要只依赖 ICMP,配合 TCP SYN 扫描做交叉验证。

中间设备过滤了 ICMP。有些网络策略会在边界路由器上丢弃 ICMP,尤其是跨网段扫描时。这时候你本地抓包能看到请求发出去了,但回应永远回不来。

目标在 NAT 后面。如果目标经过地址转换,回包的源地址可能不是你发的目标地址,Scapy 的匹配逻辑可能对不上。这种情况需要放宽匹配条件,或者用更底层的抓包方式自己解析。

本地防火墙拦了回包。有时候请求发出去了,目标也回了,但你本机的防火墙把回包丢了。排查方法是同时在另一个终端跑tcpdump icmp,看回包到底有没有到达网卡。

我踩过最坑的一次是:扫描脚本跑着没问题,但结果总是少几个已知在线的机器。后来抓包发现,那几台机器的回包 TTL 特别低,经过的跳数多,回包到达时间接近超时边界,偶尔就被判成超时。把超时从 1 秒调到 2 秒后问题消失。所以超时边界附近的丢包,是漏报的隐形杀手。

5. 让扫描结果更有信息量:TTL、时间戳与操作系统推断

5.1 TTL 能告诉你什么

TTL(Time To Live)是 IP 头里的一个字段,每经过一个路由器就减一,减到零就被丢弃。它的初始值由发送方的操作系统决定,常见的有:

初始 TTL常见操作系统
64Linux、macOS、多数 Unix
128Windows 系列
255部分网络设备、Solaris

你收到的回包 TTL 是初始值减去经过的跳数。所以如果你收到 TTL=61,大概率是 Linux 主机,中间经过了 3 跳(64-61=3)。收到 TTL=125,大概率是 Windows,经过 3 跳(128-125=3)。

这个推断不精确,因为初始 TTL 可以被修改,跳数也可能因为路径变化而不同。但在内网资产梳理场景下,它能帮你快速区分"这台是 Linux 服务器还是 Windows 工作站",对后续的扫描策略制定有参考价值。

5.2 往返时间的波动说明了什么

RTT 不只是个数字,它的波动模式能反映网络状况。同一批扫描里,如果某台主机的 RTT 明显高于同网段其他主机,可能说明:

  • 它经过的路径更长,或者链路质量差。
  • 它负载很高,处理 ICMP 的优先级被压低了。
  • 它其实不在同一个物理网段,而是通过某种隧道或远程链路接入的。

我在一次内网梳理中就靠 RTT 异常发现了一台"看起来在本地网段、实际走远程链路"的设备,后来查出来是某个部门私自接的跨区链路。这种信息单看"在线/离线"是发现不了的。

5.3 把结果导出成可分析的格式

扫完之后,把结果存成 CSV 是最通用的做法,Excel、Python、数据库都能直接读:

import csv def save_results(results, filename="ping_results.csv"): with open(filename, "w", newline="") as f: writer = csv.DictWriter(f, fieldnames=["ip", "rtt_ms", "ttl"]) writer.writeheader() writer.writerows(results)

如果结果要进数据库,把results列表直接传给 ORM 的批量插入接口就行。如果要做可视化,用matplotlib画个 RTT 分布直方图,一眼就能看出哪些主机的响应时间偏离正常范围。这些后续处理都不难,关键是前面的扫描结果要带够字段——只存一个 IP 列表,后面想分析都没素材。

6. 实测中那些文档不会告诉你的细节

6.1 权限问题比想象中更隐蔽

Scapy 发包需要原始套接字权限,这个大家都知道。但有个隐蔽的坑:在某些系统上,即使你用 root 跑,如果 Python 是通过某些包管理器安装的,能力位可能没配好。表现是脚本不报错,但包就是发不出去,sr()返回的 answered 永远是空。

排查方法很简单:跑一个最小复现,向127.0.0.1发一个 ICMP,看能不能收到自己的回包。如果本机都收不到,那肯定是权限或环境问题,跟目标网络无关。本机回环能通,再往外扫才有意义。

6.2 虚拟化环境下的网卡选择

在虚拟机或容器里跑 Scapy,经常遇到"发包成功但收不到回包"的情况。原因多半是网卡选错了。Scapy 默认会根据路由表选网卡,但在多网卡环境下,它可能选了一个不通外网的接口。

解决办法是显式指定接口:

conf.iface = "eth0"

或者在发送时用iface参数。先用conf.iface看一下当前默认接口是哪个,再用ip addr确认这个接口的网段和你要扫的目标是否匹配。不匹配就手动改。

6.3 扫描节奏与"温柔"原则

即使是合法的资产自查,也不建议把扫描做得太激进。原因有两个:一是可能影响目标主机的性能(虽然 ICMP 处理开销很小,但海量请求仍会占用资源);二是可能触发网络监控系统的告警,给运维同事添麻烦。

我的做法是:批与批之间加一个短暂间隔,比如每批之间 sleep 0.1 到 0.5 秒。这样整体扫描时间增加不多,但对网络设备的冲击小很多。另外,扫描时间尽量避开业务高峰期,选在凌晨或午休时段做。

import time for i in range(0, len(all_hosts), batch_size): batch = all_hosts[i:i+batch_size] pkts = [IP(dst=t) / ICMP() for t in batch] answered, _ = sr(pkts, timeout=timeout, verbose=0) for sent, recv in answered: alive.append(recv.src) time.sleep(0.2)

这 0.2 秒的间隔,是我在多次实测后定下来的经验值。再短效果不明显,再长就有点浪费时间了。

6.4 结果去重与异常包处理

sr()返回的 answered 列表里,偶尔会出现同一个 IP 对应多个回包的情况。这通常是因为目标有多张网卡,或者中间设备做了某种代理回应。处理方法是按 IP 去重,保留 RTT 最小的那条记录:

def dedup_results(results): best = {} for r in results: ip = r["ip"] if ip not in best or r["rtt_ms"] < best[ip]["rtt_ms"]: best[ip] = r return list(best.values())

另外,偶尔会收到类型不是 0 的 ICMP 包,比如目标不可达(类型 3)。这些包不应该被当成"主机在线"的证据。在解析回包时加一个类型判断,只认 Echo Reply:

from scapy.all import ICMP for sent, recv in answered: if recv.haslayer(ICMP) and recv[ICMP].type == 0: alive.append(recv.src)

这个判断能过滤掉不少误报。我一开始没加,结果把几台"目标不可达"的路由器回应也算成了在线主机,闹了笑话。

7. 从 ping 扫描延伸出去的几个实用方向

ping 扫描本身是个小工具,但它是很多更大项目的起点。跑通之后,你可以沿着几个方向继续深挖。

方向一:多协议交叉验证。ICMP 扫不到的机器,用 TCP SYN 扫常见端口(比如 22、80、443)再确认一遍。两者结果取并集,资产发现的覆盖率会高很多。Scapy 同样能构造 TCP SYN 包,原理和 ICMP 类似,只是多了端口和标志位。

方向二:结果持久化与变化追踪。把每次扫描的结果存进数据库,加上时间戳。下次扫描时对比,就能发现"新增了哪些主机""哪些主机下线了"。这对内网资产管理非常有用,能及时发现未登记的设备接入。

方向三:可视化展示。把 IP、RTT、TTL 这些数据用图表呈现出来,比如按网段分组的在线率饼图、RTT 分布热力图。运维汇报或者安全审计时,一张图比一堆数字有说服力得多。

方向四:封装成命令行工具。用argparse把网段、超时、批大小这些参数暴露出来,做成一个可以随时调用的脚本。再进一步,可以打包成可执行文件,分发给团队里不熟悉 Python 的同事使用。

我个人在实际操作中的体会是,ping 扫描这个项目最大的价值不在于它本身有多复杂,而在于它逼着你去理解"一个包从构造到发出再到收回"的完整链路。把这个链路搞透了,后面学 TCP 扫描、ARP 扫描、甚至自己写协议解析工具,都是水到渠成的事。最后再分享一个小技巧:调试扫描脚本时,永远在另一个终端开着tcpdump -i any icmp,这样你能实时看到包到底发出去没有、回包到底有没有到。屏幕上的代码逻辑和网卡上的真实流量对不上时,抓包是唯一能告诉你真相的东西。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询