☰
用Scapy手写Ping扫描器:从造包到并发探测的实战指南
2026/10/15 9:51:30 网站建设 项目流程

1. 从一个“造包”的视角重新认识Ping扫描

很多人第一次接触网络探测,脑子里蹦出来的第一个工具就是系统自带的ping命令。敲一下回车,看到几行回显,知道对面通不通,任务就算完成了。但如果你手里有几十上百个地址要摸一遍,或者你想搞清楚“通”和“不通”背后到底发生了什么,光靠系统命令就不够用了。这时候我会把目光转向Scapy——一个能让你从数据包层面“手工造物”的 Python 库。

这篇内容要聊的,就是怎么用 Scapy 写一个属于自己的 ping 扫描器。它解决的核心问题很直接:批量判断一批目标地址是否在线,并且让你完全掌控发包的每一个字段。系统 ping 是别人给你造好的轮子,Scapy 则是把扳手、螺丝刀、钢板全摊在你面前,你想怎么拧就怎么拧。适合谁来参考?有一定 Python 基础、想入门网络编程或者安全测试方向的朋友;也适合那些用惯了现成工具、想往下再挖一层、看看 ICMP 报文长什么样的从业者。

我先把结论摆在这儿:用 Scapy 做 ping 扫描,代码量不大,核心逻辑也就几十行,但里面藏着不少门道——超时怎么设、并发怎么控、回包怎么匹配、权限不够怎么办。这些细节才是决定你这个扫描器“能不能用、好不好用”的关键。下面我按自己的实操思路,一层一层拆开讲。

2. 为什么不用系统 ping,非要自己造轮子

2.1 系统 ping 的三个硬伤

系统自带的 ping 命令在单点测试时确实方便,但一旦进入批量场景,它的短板就暴露得很明显。

第一个硬伤是解析成本高。系统 ping 的输出是给人看的自然语言,你要从里面提取“是否在线”“延迟多少”,就得靠字符串匹配或者正则去抠。不同操作系统、不同语言环境下的输出格式还不一样,写出来的解析代码又脆又难维护。我早期偷懒用过subprocess调系统 ping,结果换了一台机器,输出格式变了,脚本直接趴窝。

第二个硬伤是并发控制弱。系统 ping 默认是串行的,一个地址等一秒,一百个地址就是一百秒。虽然可以用多进程或者-c参数配合 shell 循环去凑并发,但进程开销大,控制粒度也粗,想精确限制“同时最多发 50 个包”这种需求,用 shell 拼起来非常别扭。

第三个硬伤是不可控。你没法指定 ICMP 报文的标识符、序列号,没法自定义 payload,更没法在收到回包时拿到原始字节去做进一步分析。对于做协议研究或者需要精细探测的场景,系统 ping 就是个黑盒。

2.2 Scapy 带来的三个自由度

换成 Scapy 之后,上面三个问题基本都迎刃而解。

自由度一:报文完全自定义。你可以用IP(dst="x.x.x.x")/ICMP()这样一层一层叠出一个包,每个字段想改就改。想给 ICMP 加一段自定义 payload?直接/Raw(load=b"hello")接上去就行。这种“搭积木”式的构造方式,是理解网络协议最直观的途径。

自由度二:收发一体,结果结构化。Scapy 的sr()、sr1()、srp()这些函数把“发包”和“收包”打包成了一个动作,返回的是解析好的报文对象,不是文本。你直接访问resp[IP].src、resp[ICMP].type就能拿到字段值,不用再做字符串解析。

自由度三:并发和超时随手可调。sr()函数自带timeout和inter参数,配合多线程或者 Scapy 自己的异步发送机制,可以很轻松地控制发包节奏和并发规模。

提示:Scapy 构造和发送原始 ICMP 包需要较高权限。在 Linux/macOS 上通常需要sudo,在 Windows 上需要以管理员身份运行,并且要装好 Npcap 驱动。这是绕不过去的门槛,后面会专门讲怎么处理。

3. 动手之前:环境与权限这两道坎

3.1 Scapy 的安装与版本选择

安装本身没什么难度,一条命令的事:

pip install scapy

但版本上我建议用2.5.0 及以上。早期版本在 Windows 上的收发稳定性和 IPv6 支持都有些问题,新版本对 Python 3.10+ 的兼容性也更好。装完之后可以跑一句python -c "from scapy.all import *; print(conf.version)"确认版本。

如果你用的是虚拟环境,记得在虚拟环境里装,别装到系统 Python 里去了。我见过有人系统里装了一份、虚拟环境里没装,跑脚本时一直报ModuleNotFoundError,查了半天才发现是环境搞混了。

3.2 权限问题的三种应对姿势

权限是 Scapy 做原始套接字操作时最常撞的墙。原始套接字(raw socket)允许你直接读写网络层的数据,操作系统出于安全考虑,默认只给高权限进程开放这个能力。

姿势一:直接提权。Linux/macOS 下sudo python your_script.py,Windows 下用管理员权限打开终端。这是最省事的办法,但缺点是整个脚本都以高权限运行,如果你脚本里还干了别的事,风险面就大了。

姿势二:只给 Python 解释器赋权。Linux 下可以用setcap给 Python 二进制文件加上网络原始套接字的权限:

sudo setcap cap_net_raw+eip $(readlink -f $(which python3))

这样之后普通用户跑脚本也能发包。但要注意,这等于给这台机器上所有用这个 Python 的脚本都开了口子,安全性上要自己权衡。

姿势三:Windows 下装 Npcap。Windows 没有setcap这种机制,Scapy 依赖 Npcap 驱动来收发原始包。安装 Npcap 时记得勾选“WinPcap API 兼容模式”,否则 Scapy 可能找不到网卡。装完之后仍然需要管理员权限运行脚本。

注意:不管用哪种方式,都别把提权后的脚本随便丢到不受信任的环境里跑。原始套接字能做的事很多,权限给出去容易,收回来难。

4. 核心原理:一个 ICMP 回显包的完整生命周期

4.1 ICMP 回显请求与应答的字段拆解

要写好 ping 扫描,得先搞清楚一个 ICMP 回显包(Echo Request)里到底有什么。用 Scapy 打印一下最清楚:

from scapy.all import * pkt = IP(dst="192.168.1.1")/ICMP() pkt.show()

输出会告诉你,IP 层有源地址、目的地址、TTL、协议号(ICMP 是 1);ICMP 层有 type(请求是 8,应答是 0)、code(通常为 0)、id(标识符)、seq(序列号)、chksum(校验和)。其中id 和 seq 是匹配请求与应答的关键。

当你发出一个 type=8 的请求,目标如果在线且允许 ICMP,就会回一个 type=0 的应答,并且把请求里的 id 和 seq 原样带回来。扫描器要做的,就是发出去一批请求,然后看哪些请求收到了对应的应答。

4.2 为什么 id 和 seq 的匹配如此重要

在单目标单包的情况下,你发一个收一个,不太需要担心匹配问题。但批量扫描时,你会在短时间内发出大量请求,回包也是乱序到达的。如果不靠 id 和 seq 去对应,你根本不知道哪个应答是回应哪个请求的。

Scapy 的sr()函数在底层帮你做了这件事:它维护了一个发送记录,收到回包时根据 IP 层和 ICMP 层的字段去匹配。但如果你自己用send()和sniff()分开写,就得手动处理这个匹配逻辑。我个人的建议是,除非有特殊需求,否则优先用sr()系列函数,省心且不容易出错。

4.3 超时机制:等多久才算“不通”

超时设置是 ping 扫描里最需要拿捏的参数。设太短,网络稍微抖一下就把在线主机判成离线;设太长,扫一批地址要等到天荒地老。

我的经验值是:局域网内 1 到 2 秒足够,跨公网建议 2 到 3 秒。这个数字不是拍脑袋来的。局域网 RTT 通常在 1 毫秒以内,留 1 秒是给极端情况兜底;公网 RTT 可能到几十甚至几百毫秒,加上中间可能丢包重传,2 到 3 秒是比较稳妥的窗口。

Scapy 的sr()里timeout参数控制的是“等待回包的总时长”,不是单个包的超时。也就是说,如果你一次发 100 个包,timeout=2,那么这 2 秒内收到的所有回包都会被收集,2 秒一到就停止等待。这个语义和很多人直觉里的“每个包等 2 秒”不一样,用的时候要心里有数。

5. 从零写一个可用的 Ping 扫描器

5.1 最小可用版本:单线程逐个扫

先上一个最朴素的版本,把逻辑跑通:

from scapy.all import IP, ICMP, sr1 import sys def ping_one(host, timeout=2): pkt = IP(dst=host)/ICMP() resp = sr1(pkt, timeout=timeout, verbose=0) if resp is None: return False, None return True, resp[IP].src if __name__ == "__main__": targets = ["192.168.1.1", "192.168.1.2", "192.168.1.3"] for t in targets: alive, ip = ping_one(t) print(f"{t} -> {'在线' if alive else '离线'}")

这段代码的逻辑很直白:对每个目标构造一个 ICMP 请求,用sr1()发出去并等第一个回包,收到就判在线,超时就判离线。verbose=0是为了关掉 Scapy 默认的发送日志,不然屏幕会被刷屏。

这个版本能跑,但问题也很明显:串行执行,效率低。三个地址还好,三百个地址就得等好几分钟。而且它没有做任何异常处理,遇到网络不可达或者权限报错会直接崩。

5.2 进阶版本:多线程并发扫描

要提速,最直接的办法是把发包动作丢到线程池里。下面这个版本用concurrent.futures做并发控制:

from scapy.all import IP, ICMP, sr1 from concurrent.futures import ThreadPoolExecutor, as_completed def ping_one(host, timeout=2): try: pkt = IP(dst=host)/ICMP() resp = sr1(pkt, timeout=timeout, verbose=0) if resp is None: return host, False return host, True except Exception as e: return host, False def scan(targets, max_workers=50, timeout=2): results = {} with ThreadPoolExecutor(max_workers=max_workers) as executor: futures = {executor.submit(ping_one, t, timeout): t for t in targets} for future in as_completed(futures): host, alive = future.result() results[host] = alive return results

这里有几个参数需要解释。max_workers=50表示同时最多有 50 个线程在发包。这个数字不是越大越好——线程太多会导致本机网络栈排队,反而增加丢包率。我实测下来,普通办公网络环境下 50 到 100 是比较舒服的区间,再往上收益递减明显。

as_completed的作用是“谁先完成就先处理谁”,不用等所有任务按顺序结束,结果收集更及时。

实操心得:多线程 + Scapy 的sr1()组合在 Linux 上表现很稳,但在 Windows 上偶尔会遇到 Npcap 驱动的并发瓶颈,表现为部分线程莫名超时。如果遇到这种情况,把max_workers降到 20 左右通常能缓解。

5.3 更优雅的方案:用 sr() 批量发包

其实 Scapy 的sr()函数本身就支持一次发多个包,内部做了批量处理,比手动开线程更高效:

from scapy.all import IP, ICMP, sr def batch_scan(targets, timeout=2): pkts = [IP(dst=t)/ICMP() for t in targets] answered, unanswered = sr(pkts, timeout=timeout, verbose=0) alive = [resp[IP].src for _, resp in answered] dead = [pkt[IP].dst for pkt in unanswered] return alive, dead

sr()返回两个列表:answered里是“请求-应答”对,unanswered里是没收到回包的请求。这种写法代码更短,而且 Scapy 内部对批量发包做了优化,性能通常比手动开线程还好一些。

但要注意,sr()是一次性把所有包都发出去,如果你的目标列表有几千个,瞬间的流量峰值可能触发某些网络设备的限速或者告警。这时候可以配合inter参数控制发包间隔:

answered, unanswered = sr(pkts, timeout=timeout, inter=0.01, verbose=0)

inter=0.01表示每发一个包间隔 10 毫秒,这样能把流量摊平,降低被限速的概率。

6. 结果解析与输出:让数据说话

6.1 从回包里提取有用信息

收到应答只是第一步,真正有价值的是从应答里挖出更多信息。除了“在线/离线”,你还可以拿到:

  • 响应时间:Scapy 的sr()返回的answered列表里,每个元素是一个元组(sent_pkt, recv_pkt),而recv_pkt上带有time属性,可以算出 RTT。
  • TTL 值:resp[IP].ttl能反映目标的操作系统类型和经过的路由跳数。TTL 接近 64 通常是 Linux/Unix,接近 128 通常是 Windows。
  • ICMP 类型:正常情况下应答是 type=0,但如果收到 type=3(目的不可达)之类的,说明目标主机可能在线但端口或协议被拒。

下面这段代码把 RTT 和 TTL 一起打出来:

import time from scapy.all import IP, ICMP, sr def scan_with_detail(targets, timeout=2): pkts = [IP(dst=t)/ICMP() for t in targets] start = time.time() answered, unanswered = sr(pkts, timeout=timeout, verbose=0) for sent, recv in answered: rtt = (recv.time - sent.sent_time) * 1000 print(f"{recv[IP].src} 在线 | RTT={rtt:.1f}ms | TTL={recv[IP].ttl}") for sent in unanswered: print(f"{sent[IP].dst} 无响应")

sent.sent_time是 Scapy 在发包时自动打的时间戳,recv.time是收包时间,两者相减就是 RTT。这个精度比系统 ping 给出的还要细。

6.2 输出格式的选择

扫描结果怎么呈现,取决于你的使用场景。如果只是自己看,直接 print 就行;如果要喂给别的程序,建议输出成 JSON 或者 CSV。

import json def scan_to_json(targets, timeout=2): pkts = [IP(dst=t)/ICMP() for t in targets] answered, unanswered = sr(pkts, timeout=timeout, verbose=0) result = {"alive": [], "dead": []} for sent, recv in answered: result["alive"].append({ "ip": recv[IP].src, "ttl": recv[IP].ttl, "rtt_ms": round((recv.time - sent.sent_time) * 1000, 2) }) for sent in unanswered: result["dead"].append(sent[IP].dst) return json.dumps(result, ensure_ascii=False, indent=2)

JSON 的好处是结构清晰,后续不管是入库还是做可视化都方便。CSV 则更适合丢进表格软件里做统计。

7. 踩坑实录:那些文档里不会写的问题

7.1 常见问题速查表

问题现象可能原因解决思路
报错PermissionError权限不足提权运行或给 Python 赋 raw socket 权限
所有目标都判离线防火墙拦截 ICMP换用 TCP SYN 扫描或确认目标是否禁 ping
部分目标结果不稳定超时设太短或并发过高调大 timeout,降低 max_workers
Windows 下找不到网卡Npcap 未装或未勾选兼容模式重装 Npcap 并勾选 WinPcap 兼容
回包匹配错乱手动 send/sniff 未做 id 匹配改用 sr() 系列函数
扫描速度极慢串行发包改用多线程或 sr() 批量发送

7.2 三个我亲自踩过的坑

坑一:以为所有在线主机都会回 ICMP。这是新手最容易犯的错。很多服务器出于安全策略会直接丢弃 ICMP 请求,你扫出来是“离线”,实际上它的 Web 服务跑得好好的。所以 ping 扫描的结果只能作为参考,不能当作“主机是否存活”的唯一依据。要更准确,得结合 TCP 端口扫描一起看。

坑二:在虚拟机里跑扫描,结果全是超时。虚拟机的网络模式如果是 NAT,ICMP 包可能被宿主机的虚拟网络层拦掉。我当初在虚拟机里调了半天代码,最后发现是网络模式的问题,换成桥接模式就正常了。这个坑排查起来很费时间,因为代码本身没错,错在环境。

坑三:目标列表里有重复地址,结果对不上。如果你的目标列表里有重复 IP,sr()返回的answered里会出现多条相同 IP 的记录,统计在线数量时就会重复计数。解决办法是在扫描前用set()去重,或者在结果收集时用字典按 IP 聚合。

提示:扫描前先对目标列表做一次清洗——去重、剔除明显不合法的地址、确认网段范围。这一步花不了几秒钟,但能省掉后面很多莫名其妙的调试时间。

8. 性能调优:让扫描又快又稳

8.1 超时、并发、间隔的三角平衡

这三个参数是相互制约的。超时短、并发高、间隔小,扫描就快,但丢包和误判的概率上升;反过来则稳但慢。我的调参思路是:

先定超时。局域网 1 秒,公网 2 到 3 秒,这是底线,再短就容易误判。

再定并发。从 50 开始试,如果发现丢包率明显上升(比如同一批地址扫两次结果差异很大),就往下调。

最后定间隔。如果目标网段有流量监控或者限速设备,把inter设成 0.01 到 0.05 秒,把流量摊开。

8.2 分批次扫描大网段

如果你要扫一个 /16 的网段,那就是六万多个地址,一次性全发出去不现实。合理的做法是分批,比如每批 256 个地址,扫完一批再扫下一批:

def scan_subnet(base, start=1, end=254, batch_size=64, timeout=2): all_alive = [] for i in range(start, end + 1, batch_size): batch = [f"{base}.{j}" for j in range(i, min(i + batch_size, end + 1))] pkts = [IP(dst=t)/ICMP() for t in batch] answered, _ = sr(pkts, timeout=timeout, verbose=0) all_alive.extend(resp[IP].src for _, resp in answered) return all_alive

分批的好处是内存占用可控,而且每批之间可以加个短暂休眠,进一步降低对网络的冲击。

8.3 用异步发送替代多线程

Scapy 从 2.4 版本开始提供了AsyncSniffer和异步发送的能力,可以在单线程内实现高并发收发,避免了多线程的 GIL 和上下文切换开销。不过异步模式的代码复杂度更高,回调逻辑要自己理清楚。如果你的扫描规模在几百个地址以内,多线程方案完全够用;上千个地址再考虑上异步。

9. 从 Ping 扫描延伸出去的可能性

Ping 扫描本身只是个起点。把这套“构造报文-发送-解析回包”的框架掌握之后,你能做的事远不止 ICMP。

比如把ICMP()换成TCP(dport=80, flags="S"),就变成了 TCP SYN 扫描,能探测端口开放情况;换成ARP(pdst="192.168.1.1")配合srp(),就变成了 ARP 扫描,在局域网内比 ICMP 更可靠,因为很多主机不响应 ICMP 但一定会响应 ARP。

再进一步,你可以把扫描结果和 TTL 分析结合起来做操作系统指纹识别,或者把 RTT 数据收集起来做网络质量监控。这些延伸方向的核心逻辑都是一样的:用 Scapy 精确控制每一个字节,然后从回包里读出你想要的信息。

我个人在实际操作中的体会是,Scapy 最大的价值不在于它帮你省了多少代码,而在于它逼着你去理解协议本身。当你亲手拼出一个 ICMP 包、亲眼看到回包里的每一个字段时,那些原本停留在书本上的协议格式突然就变得具体了。这种“看得见摸得着”的感觉,是调用现成工具永远给不了的。

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

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

立即咨询