先说点实在的。写端口扫描器这事儿,我当年第一次干的时候,纯粹是为了搞清楚公司内网那台老文件服务器到底开了哪些端口。系统管理员离职了,交接文档就一句“服务器配置好,能用”,我接手没两天就觉得不对劲——有些端口明明不该对外暴露,却总感觉有人在试探。后来我花了一个周末,用 Python 写了个简单的端口扫描器,把内网几十台设备扫了一遍,才算是把网络的家底摸清楚。
说实话,现在网上一搜“Python 端口扫描器”,能搜出一堆现成代码,但大多数都停留在“能扫通几个端口”的阶段,根本没法在生产环境用。超时处理不当会导致扫描慢得像蜗牛爬,误判严重;没有并发控制,扫个 /24 网段能把交换机搞到告警;更别提扫到一半直接被防火墙拉黑这种糗事。这篇文章我不光讲怎么把扫描器写出来,还会把背后的协议原理、性能优化、服务指纹识别这些“为什么”讲透。适合刚学完 Python 基础、想搞网络安全的同学,也适合那些被分配了内网运维任务、需要快速做资产普查的开发者。
1. 端口扫描器的整体设计与思路拆解
1.1 先搞清楚端口扫描到底在做什么
端口扫描本质上就是向目标主机的某个端口发起连接尝试,然后根据返回结果判断这个端口处于什么状态。常见的状态有三种:开放(open)、关闭(closed)、被过滤(filtered)。开放意味着目标机上确实有服务在监听这个端口,关闭则表示没有进程占用,过滤则多半是防火墙或者网络策略把探测包拦掉了。
这里面有一个很容易被新手忽略的点:扫描结果里的“closed”其实非常有价值。它能告诉你目标主机是活着的、网络是通着的,只是这个端口没开而已。所以很多高级扫描工具会把 closed 和 open 合并起来作为主机存活的判断依据。我在做内网资产盘点的时候,先把所有端口都扫一遍,再用开放端口的目标去跑服务指纹识别,效率比单纯扫存活要高得多。
1.2 为什么选 Python 而不是其他语言
有人可能会说,端口扫描这种对性能要求高的活儿,用 C 或者 Go 不是更香吗?这话没毛病,但 Python 自有它的不可替代性。
第一,Python 的 socket 库是标准库,不需要装任何第三方依赖就能完成最基本的 TCP 连接测试。对于学习协议原理来说,这是无价的——你能看到每一步发生在什么层级,而不是像用现成工具那样黑盒操作。
第二,Python 的胶水特性让扫描器很容易跟后续的数据处理流程打通。扫出来的结果直接塞进列表、写进 JSON、存进数据库,甚至直接接上告警通知脚本,这些在 Python 里都是一顿饭的工夫。
第三,扫描器这种工具,性能瓶颈根本不在语言本身,而在于网络延迟和并发模型设计。你用 Python 配合 asyncio 或者多线程,完全可以在合理时间内扫完整个 /24 网段。我把话放这儿:对于大多数内网场景,Python 扫描器的速度足够用了,前提是你会做并发控制。
1.3 确定功能边界
在动手写代码之前,得先想清楚这个扫描器要满足哪些核心需求。我的方案是按三层来设计的:
底层负责最基础的 TCP 连接测试和 UDP 探测;中间层负责并发调度和超时控制;上层负责扫描策略(全端口、常见端口、指定端口范围)和结果输出。这样分层的好处是,将来你想加个 SYN 半开扫描,只需要在底层加一个构造原始数据包的模块,上层完全不用动。
功能边界这一块我需要特别强调:我建议只做 socket 实现的标准扫描,不要去做基于原始套接字的半开扫描。原因有两个。其一,原始套接字在 Windows 和 Linux 上权限要求不一样,跨平台适配非常痛苦;其二,半开扫描涉及构造 IP 头和 TCP 头的细节,容易写出不稳定代码,而且很多云主机的网络环境根本不让你发这种包。对于学习项目来说,先实现标准扫描,把原理吃透,比追求花哨的扫描方式重要得多。
2. 核心原理与扫描机制详解
2.1 TCP 三次握手的原理与扫描器视角
TCP 端口扫描的基础是三次握手过程:客户端发 SYN,服务器回 SYN+ACK,客户端再回 ACK,连接建立。扫描器的视角下,关键在于观察服务器对各种异常握手的反应。
标准 TCP connect 扫描就是完整走完三次握手。你尝试 connect,如果目标端口开放,握手成功,马上关闭连接;如果端口关闭,目标会回复一个 RST 包,表示拒绝连接;如果没收到任何回复,那就是被防火墙静默丢弃了。这个原理很简单,但很多人没想过为什么关闭的端口要回 RST 而不是什么都不回——这是 TCP 协议的设计约定:当收到的报文对应着一个不存在的连接状态时,接收方必须回复 RST 来重置这种异常状态。
理解了这一层,你就能明白扫描器返回结果的三种状态到底是从哪里来的了。我也踩过一种坑:有些路由器或主机对关闭端口的 RST 回复会限速,导致你扫同一网段时,后面几十个端口全都表现为超时,看起来像被过滤了,实际上只是回复被限流了。
2.2 全连接扫描与半连接扫描的取舍
全连接扫描(TCP connect scan)用系统调用来完成握手,优点是准确、稳定、跨平台,缺点是会在目标机器上留下完整的连接日志。半连接扫描(SYN scan)则是只发 SYN 包、收到 SYN+ACK 后立刻回 RST,不建立完整连接,所以目标机器上不会留下应用层日志,速度快,但需要原始套接字权限。
从实际开发的角度,我强烈建议学习阶段先用全连接扫描。原因很简单:你是在学 socket 编程,不是在做入侵工具。全连接扫描涉及的坑已经够多了,等你能把超时控制、并发调度这些基础问题处理好,再研究原始套接字也不迟。而且,现在主流的防火墙对于 SYN 扫描的识别非常成熟,半连接扫描在真实环境中的隐蔽性早就没那么强了。
2.3 UDP 端口扫描的特殊性
UDP 扫描比 TCP 麻烦得多,因为 UDP 没有握手的概念。你的探测包发过去,如果端口是关闭的,目标会回一个 ICMP Port Unreachable 包,告诉你说“没有人听这个口”。如果端口是开放的,很多 UDP 服务要么不回任何东西,要么只会回复一个畸形协议包。这就导致 UDP 扫描需要依赖“无响应”来推断端口有可能开放,而“无响应”也可能只是网络丢包或者 ICMP 被防火墙挡住了。
我做 UDP 扫描的时候通常用一个取巧的办法:针对常见 UDP 服务端口,发对应的协议探测数据。比如 DNS 时候发一个域名解析请求,SNMP 时候发一个 get 请求,NTP 时候发一个时间同步请求。如果收到了协议层的响应,那基本可以确认端口开放;如果直接收到 ICMP 不可达,就可以确认关闭。这个方法比发空包靠谱得多,代价是代码量会大一些。
2.4 超时与重试机制的设计逻辑
超时设计是扫描器性能的命脉。设置太短,网络上稍微有点延迟就把开放的端口误判成过滤;设置太长,数千个端口扫下来,时间全耗在等待上了。我自己的实践经验是:内网扫描超时设为 0.5 到 1 秒比较合适,外网扫描则设到 2 到 3 秒。
这里有个常规文档不会提的技巧:超时应该做成自适应。第一轮用一个较短的超时(比如 1 秒)把所有端口快速过一遍,凡是超时的端口记录下来;第二轮只对超时的端口用更长超时(3 秒)重试一次。这个方法能把扫描时间缩短一半以上,同时错误率上升非常有限。原因也很简单:网络环境在同一时间窗口内是相对稳定的,第一轮超时的目标,第二轮大概率还是超时,只有少量边界情况会翻转。
3. 扫描器的完整实现与代码解析
3.1 基础版 TCP 端口扫描实现
直接上代码。这是最基础的版本,只依赖 Python 标准库,没有任何第三方依赖,代码量也足够短,适合逐行理解。
import socket import sys def tcp_scan_port(host, port, timeout=1.0): """检测单个TCP端口状态""" sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM) sock.settimeout(timeout) try: result = sock.connect_ex((host, port)) if result == 0: return "open" elif result == 111: # ECONNREFUSED return "closed" else: return "filtered" except socket.gaierror: return "unknown-host" except socket.timeout: return "filtered" finally: sock.close() def scan_ports(host, ports): print(f"scanning {host}, target ports count: {len(ports)}") for port in ports: status = tcp_scan_port(host, port) if status == "open": print(f"[+] {host}:{port} open") # 如果你想看所有状态,可以放开注释 # else: # print(f"[-] {host}:{port} {status}") if __name__ == "__main__": target_host = sys.argv[1] if len(sys.argv) > 1 else "127.0.0.1" # 默认扫描80、443、22、3306、6379等常见端口 common_ports = [21, 22, 23, 25, 53, 80, 110, 135, 139, 143, 443, 445, 993, 995, 1433, 1521, 3306, 3389, 5432, 5900, 6379, 8080] scan_ports(target_host, common_ports)这段代码核心就一个connect_ex方法。它跟connect的区别是,连接失败时不会抛异常,而是返回一个错误码。0 表示成功,111 表示连接被拒绝,其他错误码则视情况而定。用connect_ex是为了避免大量 try-except 堆在一起,代码更清爽。
执行时直接传主机名或 IP 就行。输出效果大概是这样:
scanning 192.168.1.10, target ports count: 22 [+] 192.168.1.10:22 open [+] 192.168.1.10:80 open [+] 192.168.1.10:443 open到这一步,一个能用的扫描器就出来了。但别急着高兴——这个版本存在一个致命缺点:扫描速度非常慢,因为它是单线程逐端口扫描的。假如你要扫 65535 个端口,以平均每个端口 0.5 秒计算,你需要跑九个多小时。这不叫扫描器,这叫惩罚工具。
3.2 扫描参数的深入解析:为什么要这样选择
timeout=1.0 这个参数的选择是有考量的。内网环境下,正常服务的响应时间通常都在毫秒级别,1 秒的超时已经非常宽裕了。但是对于外网目标,如果中间经过了多条链路,1 秒可能不够用。这也是我建议超时参数要支持命令行输入的原因——硬编码超时是最容易被骂的设计。
端口列表的选择也不是随意的。我列的那 22 个端口,覆盖了最常见的 Web 服务(80、443、8080)、远程管理(22、3389)、数据库(3306、1433、5432)、文件共享(445、139、21)和邮件服务(25、110、993、995)。这些端口加起来占据了内网服务数量的九成以上。初学阶段不理解端口对应的服务没有关系,但要有一个意识:扫到开放端口只是第一步,识别出上面跑的是什么服务才是最终目的。
3.3 升级并发版:用线程池把速度提上去
标准做法是用ThreadPoolExecutor来做并发控制。每次最多开 100 个线程,避免同时发起上百个连接导致目标机器响应异常,也避免本机文件描述符耗尽。
import socket import sys from concurrent.futures import ThreadPoolExecutor, as_completed import argparse import time def tcp_scan_port(host, port, timeout=1.0): sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM) sock.settimeout(timeout) try: result = sock.connect_ex((host, port)) if result == 0: return port, "open" elif result == 111: return port, "closed" else: return port, "filtered" except (socket.gaierror, socket.timeout): return port, "filtered" finally: sock.close() def get_port_list(start_port, end_port, common_only=False): if common_only: return [21, 22, 23, 25, 53, 80, 110, 135, 139, 143, 443, 445, 993, 995, 1433, 1521, 3306, 3389, 5432, 5900, 6379, 8080] if start_port and end_port: return list(range(start_port, end_port + 1)) return list(range(1, 65536)) def scan_ports_concurrent(host, ports, max_workers=100, timeout=1.0): print(f"scanning {host}, total ports: {len(ports)}, threads: {max_workers}") open_ports = [] start_time = time.time() with ThreadPoolExecutor(max_workers=max_workers) as executor: future_to_port = {executor.submit(tcp_scan_port, host, port, timeout): port for port in ports} for future in as_completed(future_to_port): port, status = future.result() if status == "open": open_ports.append(port) print(f"[+] {host}:{port} open") elapsed = time.time() - start_time print(f"scan completed in {elapsed:.2f} seconds") return open_ports if __name__ == "__main__": parser = argparse.ArgumentParser(description="Python TCP port scanner") parser.add_argument("host", help="target host or IP") parser.add_argument("-s", "--start-port", type=int, default=1) parser.add_argument("-e", "--end-port", type=int, default=1024) parser.add_argument("--common", action="store_true", help="scan only common ports") parser.add_argument("-t", "--timeout", type=float, default=1.0) parser.add_argument("-w", "--workers", type=int, default=100) args = parser.parse_args() ports = get_port_list(args.start_port, args.end_port, args.common) open_ports = scan_ports_concurrent(args.host, ports, args.workers, args.timeout) print(f"open ports: {sorted(open_ports)}")跑一下看看效果。扫描本地机器的 1-1024 端口,100 个并发线程:
scanning 127.0.0.1, total ports: 1024, threads: 100 [+] 127.0.0.1:22 open [+] 127.0.0.1:80 open [+] 127.0.0.1:3306 open scan completed in 3.85 seconds1024 个端口只用了不到 4 秒,比单线程版本快了几百倍。这中间的关键就在于合理设置并发数。我做过实测,当你把并发数从 1 调大到 50 时,扫描耗时下降曲线非常陡峭;但从 50 到 200 的改善就明显放缓了,而网络丢包率和误判率却会上升。所以 100 是一个性价比比较高的默认值。
3.4 添加服务指纹识别:让扫描结果更有价值
只报端口号意义很有限——你得知道 3306 是 MySQL、6379 是 Redis,但这些常见对应远不够用。更可靠的方式是主动跟开放端口的服务“打个招呼”,从返回的 banner 信息里判断服务类型。
def grab_banner(host, port, timeout=3.0): """尝试从开放端口获取服务Banner""" try: sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM) sock.settimeout(timeout) sock.connect((host, port)) # 第一次握手后,一些服务会主动推送banner sock.send(b"\r\n") banner = sock.recv(1024).decode(errors="replace") return banner.strip() except Exception: return None finally: sock.close()以 SSH 为例,当你连接一个 SSH 端口时,服务器会主动发送版本信息给你,例如:
SSH-2.0-OpenSSH_7.9p1 Ubuntu-10+deb10u2这一行信息就是完美指纹。它能告诉你运行的是 OpenSSH(服务类型),版本是 7.9p1(精确版本号),跑在内核是 Ubuntu 10 的机器上(操作系统特征)。对于版本敏感的老旧服务,这行 banner 直接就能推出来是否存在已知漏洞。
不过我不会每次扫描都做 Banner 识别,因为耗时明显更长。常规做法是:先并发扫全端口,筛出 open 的结果,然后再逐个做 Banner 抓取。这个顺序很重要,因为抓取 Banner 的耗时通常是timeout级别的,如果同时对几千个开放端口做抓取,反而容易触发目标机器的连接频率限制。
3.5 命令行参数补全与日志记录
扫描器这种工具,大多数人习惯在命令行里调用,而且需要记录历史结果,方便对比端口变动。我补一个带日志输出的完整版本,这也更接近生产可用的状态。
import datetime import json class ScannerLogger: def __init__(self, filename=None): self.filename = filename or f"scan_{datetime.datetime.now().strftime('%Y%m%d_%H%M%S')}.json" def save_result(self, host, open_ports, elapsed): data = { "host": host, "timestamp": datetime.datetime.now().isoformat(), "open_ports": sorted(open_ports), "elapsed": round(elapsed, 2) } with open(self.filename, "w", encoding="utf-8") as f: json.dump(data, f, indent=2, ensure_ascii=False) print(f"[*] result saved to {self.filename}")这段代码里有个实用设计:日志文件按时间戳命名,保证多次扫描的结果不会互相覆盖,方便以后做对比分析。JSON 格式存结果,后续想写个 Web 展示界面或者导入数据库,都可以直接处理。
4. 并发模型、性能优化与真实场景实测
4.1 线程数不是一个越大越好的参数
这是我这几年踩出来的经验,必须单独说一下。很多人以为并发线程越多扫描就越快,第一次写并发版本时把 workers 直接拉到 500,结果扫本地网络都觉得卡顿,目标机器更是直接被拖到响应超时。实际上,过多的并发连接会造成两类问题:
一是网络层拥塞。大量 SYN 包同时涌向目标机器,交换机的并发连接表先被占满,后面所有的连接都开始丢包。你用connect_ex发起连接时,系统会先占用一个临时端口,这个端口资源是有限的。并发数超过一定规模后,本机的端口分配就会陷入等待。
二是目标机器的连接处理瓶颈。Linux 内核的net.core.somaxconn默认值也就 128 左右,超过这个数量的半连接排在队列里,后面的连接只能干等超时。所以并发 300 可能比并发 100 还慢,因为大量请求在目标机器排队,白白消耗了超时时间。
我的经验值是:家庭宽带下并发 50-100 比较理想,云服务器上可以放到 150-200。如果扫的是特殊的硬路由设备,并发还得再降一档,这类设备对连接数异常敏感。
4.2 不同操作系统下的性能差异
这里有个细节很多人不知道:同样的 Python 扫描代码,在 Linux 和 Windows 上表现差别很大。
Linux 下/proc/sys/net/ipv4/ip_local_port_range默认允许 28,000 多个临时端口可用,所以并发开大基本不担心端口耗尽。Windows 则不同,默认动态端口范围只有 16,000 个左右,而且每个连接长时间占用端口,扫描大批量端口时更容易报Address already in use的错误。
所以如果你用的是 Windows 来执行大规模扫描,建议把并发数控制在 50 以内,同时设置一个更短的 TIME_WAIT 时间。在 Linux 下则可以通过这几个命令来扩展可用端口范围:
sysctl -w net.ipv4.ip_local_port_range="10000 65535" sysctl -w net.ipv4.tcp_fin_timeout=10 sysctl -w net.ipv4.tcp_tw_reuse=1这些调整只针对扫描器运行的本机环境,不会影响目标机器的状态。注意tcp_tw_reuse只对主动连接方生效,扫描时恰好就是本机在主动发起大量连接,因此效果非常明显。
4.3 用 asyncio 做异步协程版本
除了多线程,Python 还有一种适合 IO 密集型任务的并发模型——asyncio。它的优势在于协程调度开销远低于线程切换,一个线程内可以同时管理上千个连接任务。
import asyncio import socket async def tcp_scan_async(host, port, timeout=1.0): """异步版本的单端口TCP扫描""" try: conn = asyncio.open_connection(host, port) reader, writer = await asyncio.wait_for(conn, timeout=timeout) writer.close() await writer.wait_closed() return port, "open" except asyncio.TimeoutError: return port, "filtered" except OSError as e: if e.errno == 111: return port, "closed" return port, "filtered" async def run_scan(host, ports, timeout=1.0): tasks = [] semaphore = asyncio.Semaphore(200) # 控制并发协程数量 async def bounded_scan(port): async with semaphore: return await tcp_scan_async(host, port, timeout) for port in ports: tasks.append(asyncio.ensure_future(bounded_scan(port))) results = await asyncio.gather(*tasks) return results if __name__ == "__main__": host = "127.0.0.1" ports = list(range(1, 1025)) results = asyncio.run(run_scan(host, ports)) for port, status in results: if status == "open": print(f"[+] {host}:{port} open")异步版本的好处是,当网络环境波动比较大、容易出现延迟时,大批量任务切换成本极低,不会因为线程切换导致 CPU 占用飙升。正常情况下,线程池和协程版本扫描相同范围的端口,耗时差距不大;但如果目标机器网络状况差,大量连接出现超时,协程版本的表现就要稳得多了。
4.4 真实内网扫描的实验记录与解读
这里分享一次真实的内网扫描,目标是一个有 50 台设备的小型办公网络,范围是192.168.1.0/24。扫描策略是:先扫 22 台网段内主机的存活状态,再对存活主机扫常见 22 个端口,并发线程 100,超时 1 秒。
第一轮存活探测结果很快,整个网段只用了 40 秒左右,有 43 台机器响应了 ping。第二轮端口扫描花了 1 分半钟,总共发现 67 个开放端口,分布大致如下:
| 端口 | 数量 | 猜测服务 |
|---|---|---|
| 80/443 | 6 | 内网 Web 系统、打印机管理界面 |
| 22 | 12 | Linux 服务器、网络设备 SSH |
| 3389 | 5 | Windows 远程桌面 |
| 3306 | 3 | MySQL 数据库 |
| 445 | 8 | Windows 文件共享 |
| 161 | 2 | 网络打印机 SNMP |
| 其他 | 31 | 各种应用服务端口 |
从这个结果可以得出几个安全建议。第一,有 6 台设备的 Web 管理界面直接暴露在内网,如果密码简单,风险不小。第二,8 台设备开了 445 端口的文件共享,这个端口在历史上被多次利用过漏洞,建议排查一下是否关闭了不必要的共享。第三,3 台 MySQL 的 3306 端口对整个网段开放,有被横向爆破的风险。
这就是扫描器的价值所在——它不是用来搞破坏的工具,而是帮你看清网络里到底有什么、哪些地方可能存在风险。把扫描做成周期性任务,每次结果都保存下来,然后对比差异,就能发现新设备上线、旧端口意外开放这类网络异常。
5. 常见问题与排查技巧实录
5.1 扫描结果大量 filtered 该怎么判断
这个情况在新手里极其常见。扫一个网段,结果大部分端口显示 filtered,但那些开放端口明明能正常访问。问题通常出在这几个地方。
第一种可能是防火墙的 RST 响应限速。很多防火墙对于没有命中规则的数据包,会采用“silent drop”策略并限制 ICMP 不可达的发送频率。当你的扫描速率超过这个限制时,后面的请求就全部超时了。解决办法就是降低并发数,或者给端口列表分批处理,中间加短暂停顿。
第二种可能是 IDS/IPS 的检测机制。有些内网安全设备能识别端口扫描行为,检测到后会对源 IP 直接做黑洞路由,导致你后续的探测包全部超时。这类问题的特征是:扫描刚开头是正常的,过了一段时间,连之前能通的端口也变 filtered 了。解决办法是把并发数调低,适当延长扫描时间,并且从不同的源地址分多次扫描。
第三种比较隐蔽,是 NAT 网关的问题。如果你的扫描器运行在内网的一台机器上,经过 NAT 出去访问外网目标,NAT 网关的会话表超时时间可能比你的扫描超时时间短。结果是,网关已经丢弃了映射关系,你的后续包根本到不了目标机器。出现这种情况时,把超时时间设到 3 秒或以上,往往就能解决。
5.2 目标主机不响应 ping 但端口是开放的
这是做存活探测时最常见的误判。很多服务器为了安全考虑,防火墙已经禁掉了 ICMP Echo 请求,直接 ping 不通。但你扫它的 80 端口,连接是能建立的。
所以存活探测不能只依赖 ICMP ping,要用“TCP 半开扫描 + 端口探测”的方式。我的做法是:先挑几个存活概率最高的端口(80、443、22、3389)做快速 TCP 连接测试,任一连通即认为主机在线。这个方法比 ping 要可靠得多,尤其是在 Windows 机器默认就开启防火墙的环境下。注意,这只是存活探测,不要把这些端口当作真实开放来用——最后还是要全端口扫描确认。
5.3 端口扫出来 open,连接却失败
这种情况也遇到过。扫描时显示 80 端口 open,但你用浏览器访问却直接超时。问题出在扫描瞬间和目标连接瞬间之间,网络状态已经变了。
最常见的情况是防火墙的临时放行规则。某些安全设备会针对未经确认的 SYN 包给出 RST 或者直接丢弃,但对合法浏览器的连接则放行。扫描器发出的探测包特征比较单一,容易触发安全策略的临时拦截。另一种可能是目标服务做了来源 IP 白名单,只允许特定地址段访问。你的扫描器源地址不在白名单里,握手能完成但后续数据交互会被丢弃。
当出现这种情况时,可以试试对同一个端口做三次连接测试,看看是否每次都显示 open。如果三次结果一致,基本可以确认端口确实开放;如果时开时关,那多半是中间设备在搞鬼。在真实环境里,网络设备的行为千奇百怪,过度依赖单一 IP 的单一测试结果,很容易得到错误结论。
5.4 扫描过程中程序卡死或内存暴涨
扫描器跑着跑着一动不动,或者内存疯狂上涨,这种问题我调试过不下十次。最常见的元凶是线程或协程泄漏——错误地给每个端口创建一个线程,且线程启动后既没有超时控制,也没有正确关闭 socket,最后线程越积越多,资源耗尽。
排查这类问题,有一个百试百灵的手段:先缩小端口范围,把问题重现到最小规模。如果只扫 5 个端口仍然卡死,那就基本确定是代码层面出了问题,而不是数据量太大。然后逐行检查 socket 有没有在finally块里关闭,线程池有没有用with语句管理。
另外一个容易踩的坑是往列表里塞结果时没有加锁。多个线程同时向同一个列表append,Python 的 GIL 在多数情况下会保护这一步,但在极端线程数下可能会产生稍微错乱的结果。既然在做并发扫描,结果集就应该用带锁的结构,或者干脆让每个线程返回独立列表,最后再汇总。
5.5 完整问题速查表
为了方便你日常排障,我把常见情况整理成一个速查表,面对扫描异常的时候直接对照排查。
| 表现 | 可能原因 | 优先排查方向 |
|---|---|---|
| 大量端口 filtered | 防火墙静默丢弃、IDS 封禁 | 降低并发数,分批扫描 |
| 刚开始正常,过一会全超时 | 源 IP 被安全设备标记 | 更换源地址,降低扫描频率 |
| 存活主机大量漏报 | 防火墙禁 ICMP | 改用 TCP 端口探测做主存活判断 |
| 扫描时本机性能飙升 | 连接数超过本机限制 | 检查临时端口范围,合理设置并发 |
| 显示 open 但访问不上 | 中间层状态变化/白名单 | 重复探测多次,验证结果一致性 |
| 扫 UDP 全部无响应 | ICMP 不可达被过滤 | 改用协议专用探测载荷逐个验证 |
| 扫外网目标极慢 | 链路延迟高、NAT 超时短 | 增大超时时间,启用异步协程模型 |
5.6 扫描的“度”与使用边界
最后必须说一说工具的使用边界。端口扫描器是我们了解自己网络的必要手段,但这不意味着可以随便对不明目标发起扫描。在实际操作中,务必记住几个原则:只能扫描自己拥有或得到明确授权的网络资产;扫描前先通知网络管理员;扫描速率控制在对目标业务无影响的范围内;扫描结果只用于安全加固和资产管理,不能用于其他用途。
我自己做内网扫描时,一定会跟运维团队提前沟通,确认扫描时间安排在业务低峰期,并说明扫描目的。这不是形式主义——网络上有大量自动化的入侵检测系统在实时运行,未经说明的扫描极有可能触发告警,给自己找不必要的麻烦。
说回工具本身。你现在应该能看出来,一个成熟的端口扫描器,绝不只是一堆 socket 调用的简单堆叠。它要处理超时控制、并发策略、结果验证、服务识别这些实际问题。特别是超时机制的调优,那绝不是网上随便抄一段代码就能搞定的,必须结合自己的网络环境反复实测。我也建议你把这个扫描器持续完善下去:加上端口扫描与漏洞信息库的联动,做成资产发现与告警的自动化流程,甚至接上可视化展示。端口扫描只是一个起点,真正有价值的是从网络数据中发现问题和机会的能力。