Python端口扫描脚本实战:从TCP三次握手到并发与Banner识别 1. 端口扫描的核心原理与思路拆解1.1 为什么值得自己写一个端口扫描脚本先聊一个实际场景。我以前接手过一批服务器前任管理员留下了不知道多少服务在跑文档基本等于没有。想知道哪些端口在监听手上有现成的工具可以用但那个工具装在哪台机器上不记得了而且临时要调个参数又不够灵活。这种时候用Python写个端口扫描脚本就是最顺手的方案——代码量不大依赖少几乎任何装有Python的环境都能跑起来扫完的结果还能直接导出成自己想要的格式方便后续整理归档。很多人会说“现成的扫描工具那么成熟为什么还要自己写”我的感受是自己写并不一定是为了替代现成工具而是为了理解它背后的逻辑。端口扫描本质上就是在问一个问题目标机器的某个端口现在是否在监听。搞懂这个问题的答案是怎么得到的以后遇到网络不通、服务连不上、防火墙拦了之类的情况你会比只会点扫描按钮的人多一整套排查思路。另外自己写扫描器还有一个好处——可控。你可以精确控制超时时间、并发数量、扫描范围、输出格式想加什么功能就加什么比如扫到开着的端口之后顺手抓个banner识别一下服务类型这些都可以用几十行代码搞定。我用这个脚本维护内部服务清单已经两年多了虽然功能没有商业工具花哨但胜在轻巧、透明、随改随用。1.2 TCP三次握手与connect扫描的核心原理要写端口扫描必须先理解TCP建立连接的过程。所谓TCP连接核心就是三次握手客户端先发一个SYN包给服务端服务端如果这个端口正在监听就回一个SYNACK包客户端收到后再回一个ACK包连接就建立了。三次握手的本质是双方各确认一次对方“能收能发”这也是为什么TCP是可靠连接的基础。端口扫描抓的就是这个过程中的“中间状态”。如果我们的扫描程序主动发起连接请求对方回了SYNACK说明端口是开放的如果对方回了RST包——相当于直接把我们顶回来——说明端口没有服务监听如果发出去的包石沉大海一直没有回应那多半是被防火墙静默拦截了。这个过程用代码来模拟最简单的方式就是用一个socket去“尝试连接”目标主机的指定端口靠返回值判断结果。Python里最典型的做法是用socket库的connect_ex()方法。这个方法跟connect()一样会尝试三次握手区别在于connect()失败时直接抛异常而connect_ex()会把错误码直接返回给你。判断方式很直接返回值是0说明连接成功了端口是开放的返回其他值比如10061Windows上的“目标计算机积极拒绝”或者111Linux上的CONNECTION REFUSED说明端口关闭如果抛了超时异常那就是被过滤了。这一层弄明白了第一版扫描器的核心逻辑就已经出来了。1.3 三种主流扫描方式的选择TCP connect、SYN半开扫描与UDP探测写扫描器之前先得搞清楚有哪几种扫描方式因为不同方式的原理和适用场景差别很大。平时见得最多的是TCP connect扫描就是我们上面说的那个逻辑套接字完整走完三次握手能连上就是开放被拒绝就是关闭超时就是被过滤。这种方式的优势是实现简单、无需特权普通用户权限就能跑所以我日常维护服务清单主要用它。缺点也显而易见——会在目标上留下明确的连接日志而且因为一次次地握手相对容易触发安全设备的告警不适合做太频繁的探测。另一种是SYN半开扫描。思路是只发SYN包收到SYNACK就认定端口开放然后立刻发一个RST包断开根本不建立完整连接。这样一来目标上不会有完整的连接记录扫描速度快很多但也需要原始套接字的权限通常要以管理员身份运行而且要处理更底层的报文细节。这种模式用Python写起来就不仅仅是调socket库那么简单了往往需要结合libpcap或者构造原始数据包才能实现代码复杂度比connect扫描高了一个量级。至于UDP扫描就完全是另一回事。UDP没有握手的概念判断端口是否开放主要靠的是“发个包过去看对方怎么回应”ICMP端口不可达说明端口关闭有响应说明服务在线没反应则无法确定可能是防火墙把ICMP也拦了也可能是UDP服务本身就不回包。所以UDP扫描的结论里会有一大堆“unknown/open|filtered”这种模糊状态。我自己日常基本用不到UDP扫描因为多数内部服务走TCP偶尔需要确认DNS或者SNMP这类UDP服务时直接用对应的客户端工具去探效果更好。2. 环境准备与第一版基础扫描器实现2.1 依赖选择与运行环境说明写这个脚本真正核心的依赖只有一个——Python标准库里的socket另外再加一个concurrent.futures来做并发控制以及argparse来解析命令行参数。第一次写扫描器的人可能会想着装扫描框架之类的第三方库实际上完全没必要。标准库的好处是走到哪儿都能跑不依赖pip安装也不怕环境被影响。尤其你是要在多个机器上运行这个脚本的话零依赖就是最大的优势。Python版本方面建议用3.8以上。3.7及以下的老版本不是不能跑但新版的标准库在处理超时、并发、类型标注这些方面体验更好。我平时在3.10和3.11上跑都没问题。操作系统方面Windows、Linux、macOS都能运行我主要在Linux的虚拟机上跑因为生产环境大多是Linux扫出来的结果和实际服务状态最贴近。我在写完整版之前先做了一个最小验证断开路径上所有的代理设置直接用socket向目标主机的特定端口发起连接看返回值。这个过程是理解后续所有逻辑的基础也建议你先复现一下真实感受到“连接成功”和“连接被拒绝”的底层反馈后面调试复杂脚本时心里才会有底。2.2 第一个可用版本单线程connect扫描我先给出一个最原始的单线程版本。这个版本虽然慢但逻辑最直观适合用来跑通整个流程。import socket def scan_port(host, port, timeout3): try: sock socket.socket(socket.AF_INET, socket.SOCK_STREAM) sock.settimeout(timeout) result sock.connect_ex((host, port)) sock.close() return result 0 except socket.timeout: return None # 超时推测被防火墙过滤 except socket.error as e: return False # 拒绝连接或其他错误 if __name__ __main__: host 127.0.0.1 for port in range(1, 1024): res scan_port(host, port) if res is True: print(f[OPEN] {host}:{port}) elif res is None: print(f[FILTERED] {host}:{port})这个版本的核心就一句话对每个端口执行connect_ex()返回值0就说明开了。超时的情况单独抛出来标记为filtered因为对端没有任何回应我们无法确定端口是彻底关闭了还是被防火墙静默丢弃了。单线程扫描的瓶颈一目了然如果某个端口没有响应程序会卡在设定的超时时间上比如设了3秒扫到1000个端口里面有两百个没回应的光等待就浪费了十分钟以上。这个版本适合用来验证逻辑不适合做真正的扫描任务。2.3 超时参数的权衡与结果判断细节超时时间这个参数是整个扫描器里最需要认真调的一个点。设大了扫描速度慢尤其是不回应的情况会特别拖时间设小了可能会误伤因为网络正常延迟比较高的时候服务明明存在但响应稍慢你提前放弃了。我在实际使用中的默认值是1秒到2秒之间。内网扫描1秒足够跨公网或者链路质量差的情况建议调到2秒甚至3秒。怎么判断当前网络环境适合什么值你可以先扫一两个已知开放的端口观察一下平均响应时间然后乘以3作为超时基数这样比较稳妥。需要注意一个容易忽视的细节connect_ex()在超时的时候既不会返回0也不会返回一个标准的错误码而是直接抛socket.timeout异常。所以代码里必须把timeout异常单独捕出来不能笼统地归为失败否则你就把“被过滤”和“关闭”给混淆了。这个区别在处理防火墙严格的目标时尤其重要能帮你区分到底是服务没开还是网段根本不允许你探测。还有一个细节是socket对象的复用。同一个socket连接失败之后不要再拿去做下一次连接我干脆每次扫描都新建socket用完后close。这样可以避免各种状态残留带来的诡异问题。当然频繁创建socket本身有一定开销但这部分开销对整体扫描速度的影响可以忽略代码清晰才是更重要的。3. 并发改造扫描速度与性能的平衡3.1 为什么要并发一次请求的延迟模型单线程扫描慢的根源在于“一个端口一个端口地串行等待”。每个端口无论成功失败至少产生一次网络往返RTT即使是1毫秒扫描一万个端口也需要10秒但加上TCP握手、操作系统socket创建销毁、超时等待这些开销实际远不止这个数。要理解并发有多大的提升可以先算一笔账假如超时设为2秒扫1000个端口其中300个没有回应的单线程最坏情况就是300次超时乘以2秒加700次快速响应差不多也要10分钟。但如果用20个线程并发最坏情况大约是单线程的1/20也就是30秒左右。对于快速响应的端口时间差不是线性的但整体速度提升基本按照并发倍数走实际效果非常明显。所以必须并发但并发也不是越大越好。线程数开太高一来本机soket资源可能吃紧二来目标机器可能因为大量连接堆积而重启或丢包。我在实际使用中有一个经验值普通内网环境并发线程数控制在100到200之间比较合适目标主机性能一般、或者网络设备比较敏感的时候降到50更稳。别为了追求快把线程数拉到上千最后扫描结果全是误报反而得不偿失。3.2 线程池方案用concurrent.futures优雅地控制并发Python里做并发现在最推荐的做法不是手动创建一堆Thread而是用concurrent.futures.ThreadPoolExecutor线程池。你只需要规定这个池子里同时有多少线程在干活把任务丢进去线程池会自己调度、自己回收省心很多。下面是基于线程池改造后的核心代码我日常用的版本就是从这段代码开始扩展的import socket from concurrent.futures import ThreadPoolExecutor, as_completed def scan_port(args): host, port, timeout args try: sock socket.socket(socket.AF_INET, socket.SOCK_STREAM) sock.settimeout(timeout) result sock.connect_ex((host, port)) sock.close() if result 0: return port, open return port, closed except socket.timeout: return port, filtered except socket.error: return port, closed def scan_host(host, ports, timeout1, max_workers100): tasks [(host, port, timeout) for port in ports] with ThreadPoolExecutor(max_workersmax_workers) as executor: future_map {executor.submit(scan_port, task): task for task in tasks} for future in as_completed(future_map): port, status future.result() if status ! closed: print(f[{status.upper()}] {host}:{port})这段代码有几个值得注意的设计点。一是把参数打包成元组传入工作函数方便ThreadPoolExecutor统一调度。二是用as_completed来取结果谁先完成谁先被处理不会因为某个端口卡住而影响其他结果的输出。三是把闭包和半开的端口都过滤掉只打印你关心的状态。使用线程池之后还有一个额外好处是可以随时调整并发数而不影响代码结构。你想快速扫的时候把max_workers调到200想低调扫的时候调到30只是改一个参数的事。我用这个方案扫描内网一个网段的常用端口清单一两分钟之内就能拿到完整结果和单线程比起来速度快了几十倍。3.3 协程方案同等的速度更轻量的资源占用线程池虽然慢方便但线程是有成本的。每个线程需要分配独立的栈空间线程切换也依赖操作系统调度。如果你希望进一步降低资源占用或者想练一练异步编程的思路可以改用asyncio。协程的方案可以在单个线程里通过事件循环调度成千上万个socket连接效率理论上更高。下面是一段用协程实现的扫描核心import asyncio async def scan_port(host, port, timeout1): try: reader, writer await asyncio.wait_for( asyncio.open_connection(host, port), timeouttimeout ) writer.close() await writer.wait_closed() return port, open except asyncio.TimeoutError: return port, filtered except (ConnectionRefusedError, OSError): return port, closed async def main(host, ports, timeout1, concurrency200): sem asyncio.Semaphore(concurrency) async def limited(port): async with sem: return await scan_port(host, port, timeout) tasks [limited(port) for port in ports] results await asyncio.gather(*tasks) for port, status in results: if status ! closed: print(f[{status.upper()}] {host}:{port})协程方案里的关键点是asyncio.wait_for把open_connection包了一层直接控制每一个连接的超时上限。另外用Semaphore做并发限流避免一次性把成千上万个连接同时发出去。这个方案跑起来的体验是资源占用比线程池小但代码理解起来稍微绕一些。对于普通扫描任务线程池已经完全够用协程的优势在扫描端口数量特别大或者本机资源吃紧时才明显。我个人建议先把线程池版本跑熟再考虑是否切到协程。3.4 并发数、超时与误报率之间的互动关系并发数与超时不是两个独立的参数它们会互相影响。当并发数特别大的时候本机的网络栈会出现大量socket堆积连接的质量可能下降导致一些明明开放的端口因为响应慢而超时被误报成filtered。反过来并发数太少扫描速度上不去对大规模扫描任务是灾难。我在实际使用中调参的经验是先按1秒超时、100并发跑一遍看结果里filtered的比例高不高。如果filtered特别多优先怀疑不是目标端口的问题而是超时太短或者并发太高把并发降到50、超时调到2秒再试如果filtered明显减少就说明是扫描端的参数问题。如果filtered依然很多再去查网络中间设备是不是真的有拦截策略。这里想强调一个容易翻车的地方很多人看线程池快就把max_workers设成500甚至1000觉得“反正线程池自己管理”。但线程太多时Python的GIL会导致线程切换频繁反而拖慢整体速度而且大量socket同时进入握手过程会对本机网络栈造成额外压力。以我的经验普通2核4G的云主机上150到200并发已经是比较舒服的区间了。4. 进阶功能服务识别与Banner抓取4.1 从端口开放到识别服务类型的价值扫出一个端口是开放的这只是第一步。对于运维和排查场景来说更有价值的信息是这个端口上跑的是什么服务比如是SSH、HTTP、MySQL还是某个内部应用。为什么因为端口号跟服务类型的对应关系并不是绝对的。80端口一般是Web服务但完全可能有别的服务故意绑在上面3306一般是MySQL但有些团队会为躲开扫描器把数据库换到其他端口。如果只依赖端口号猜服务很容易产生误判。而如果你能抓取到服务主动返回的banner信息——就是连接建立后服务端发送的一串问候语或协议特征——就能更准确地判断出来。我之前就碰到过这样一个例子扫出来一台机器的8888端口开着光看端口号以为是什么管理后台用浏览器打开一看没有任何响应。后来抓了banner发现返回的是SSH的版本信息原来是某位同事把SSH配置到了非标准端口上。这种情况在真实世界里并不少见所以做端口扫描时顺手做服务识别其实是刚需。4.2 基于socket抓取banner的实现方法抓banner的原理很简单TCP连接建立之后很多服务会主动发送一条欢迎信息比如SSH会发“SSH-2.0-OpenSSH_8.9p1”FTP会发“220 (vsFTPd 3.0.3)”。我们只需要在连接成功后尝试读取一小段数据然后根据内容特征判断服务类型。下面是抓banner的核心函数我是在扫描脚本里把这一层加进去的BAD_BANNERS (, , \r\n, \n) def grab_banner(sock, timeout3): sock.settimeout(timeout) try: banner sock.recv(1024).decode(utf-8, errorsignore).strip() if banner and banner not in BAD_BANNERS: return banner except socket.timeout: pass except ConnectionResetError: pass return None这里有几个细节值得注意。一是recv的缓冲区大小我习惯用1024字节其实多数服务banner不会超过几百字节这个大小足够了。二是编码问题老式服务可能使用ASCII或Latin-1编码如果直接按UTF-8解码会乱码所以要用errorsignore容错。三是有些服务不会主动发消息需要客户端先发一个指令才会回所以纯等待式抓banner不是万能的抓不到只能说明对方不说话不代表端口没开。某些服务需要主动询问才回banner比如HTTP服务必须客户端发一个请求才会返回响应。这种情况可以再添加一个模式在连接建立后主动发送一个简单的探测包。以HTTP为例发送一个“GET / HTTP/1.0\r\n\r\n”然后读取头部响应既可以看到“Server: nginx”这样的服务指纹还能确认它确实是HTTP服务。4.3 常见服务指纹与识别规则识别banner的规则一句话总结就是“匹配关键字”。我根据自己的实践经验整理了一份精简版的服务特征表可以用来参考服务类型常见banner特征典型端口SSHSSH-2.0-OpenSSH、SSH-1.9922HTTPHTTP/1.1、Server: nginx/apache/iis80、8080HTTPS连接后无明文banner需要TLS握手443FTP220、vsFTPd、ProFTPD21SMTP220、ESMTP、Postfix25MySQLHandshakeV10、360883306RedisOK、-ERR6379MongoDBhello、isWritablePrimary27017识别规则从简单到复杂有几个档次。最粗糙的是banner里直接包含服务名和版本号这种情况直接正则匹配就能确定。稍微复杂一点的是服务没有显式版本号但响应格式有明显特征比如MySQL的握手包开头固定是0x0aRedis多行字符串总是以或者-开头。最复杂的是加密协议的识别比如HTTPS和SSH的握手包都是二进制直接读banner读不出来得再向socket里发几个字节的探测看对方回不回TLS ServerHello或者SSH协议标识。我的原则是能用banner精确识别就精确识别识别不了时结合端口号做合理推测但必须在结果里打上“guess”标志防止后续使用者误判。多一份谨慎就少一个生产事故。5. 完整脚本设计参数、输出与代码整合5.1 命令行参数的设计思路一个脚本要给别人用、要给自己未来用命令行参数必须设计清楚。我一般保留这几个基本参数目标主机、端口范围、并发数、超时时间、输出文件。用argparse来实现短选项和长选项都提供方便记忆。import argparse def parse_args(): parser argparse.ArgumentParser(descriptionPython端口扫描脚本) parser.add_argument(host, help目标主机IP或域名) parser.add_argument(-p, --ports, default1-1024, help端口范围例如 1-1000 或 22,80,443) parser.add_argument(-t, --timeout, typefloat, default1.0, help连接超时时间秒) parser.add_argument(-c, --concurrency, typeint, default100, help并发线程数) parser.add_argument(-o, --output, help输出结果到文件) return parser.parse_args()端口范围解析是一个容易被忽略的小点但做成支持“22,80,443”和“1-1000”两种格式会顺手很多。解析逻辑并不复杂用逗号分割得到若干段每一段里如果有连字符就按range展开没有就当单个端口处理。多加这个能力命令行输入就从“我只能扫一大片”变成了“我想扫哪几个就扫哪几个”使用场景立刻开阔了。如果你需要日常维护服务清单可以考虑在参数里加一个-hostlist支持从文件读取多个目标IP。这对于扫描整个内网网段非常好用。我自己是把端口扫描和后面的主机列表扫描结合使用的扫描一批IP的同一组常用端口输出格式统一对齐整理成表格非常方便。5.2 结果的输出格式与保存策略扫描结果的输出格式直接影响后续使用的效率。我最开始是直接print扫完就算完后来发现结果丢了很可惜于是开始把结果写入文件。输出格式建议用CSV原因有两条一是用Excel或者表格工具可以直接打开不需要额外脚本二是Python的csv标准库写起来很简单不需要处理什么复杂序列化。def save_csv(results, filename): with open(filename, w, newline, encodingutf-8) as f: writer csv.writer(f) writer.writerow([host, port, status, banner]) for row in results: writer.writerow(row) def save_json(results, filename): with open(filename, w, encodingutf-8) as f: json.dump(results, f, ensure_asciiFalse, indent2)CSV适合快速人工查看JSON适合后续程序化处理。如果结果要交给别的系统做展示或入库JSON更方便如果只是自己维护清单CSV足够了。还有一个我觉得特别实用的小功能按状态分组统计。扫完之后打印一段汇总比如“开放端口12关闭端口980过滤端口32”。这样你不用数列表也能马上判断这次扫描的整体质量。如果过滤端口占比太高你应该立刻怀疑是不是自己参数调得太激进或者网络环境异常而不是傻乎乎地拿着结果去分析。5.3 一个可以直接使用的完整脚本框架最后我把上面所有功能整合成一个可以立刻使用的版本。这个版本包含命令行参数解析、线程池并发扫描、banner抓取、结果保存这几个核心能力你可以在自己的环境里直接复制运行。import argparse import csv import json import socket from concurrent.futures import ThreadPoolExecutor, as_completed BAD_BANNERS (, , \r\n, \n) def parse_ports(port_str): ports set() for part in port_str.split(,): part part.strip() if - in part: start, end part.split(-, 1) ports.update(range(int(start), int(end) 1)) else: ports.add(int(part)) return sorted(ports) def grab_banner(sock, timeout3): sock.settimeout(timeout) try: banner sock.recv(1024).decode(utf-8, errorsignore).strip() if banner and banner not in BAD_BANNERS: return banner except (socket.timeout, ConnectionResetError, OSError): pass return None def scan_port(host, port, timeout): try: sock socket.socket(socket.AF_INET, socket.SOCK_STREAM) sock.settimeout(timeout) result sock.connect_ex((host, port)) if result 0: banner grab_banner(sock, timeout) sock.close() return {host: host, port: port, status: open, banner: banner} sock.close() return {host: host, port: port, status: closed, banner: None} except socket.timeout: return {host: host, port: port, status: filtered, banner: None} except socket.error: return {host: host, port: port, status: closed, banner: None} def scan(host, ports, timeout, concurrency): results [] with ThreadPoolExecutor(max_workersconcurrency) as executor: future_map {executor.submit(scan_port, host, p, timeout): p for p in ports} for future in as_completed(future_map): results.append(future.result()) return results def main(): parser argparse.ArgumentParser(descriptionPython端口扫描脚本) parser.add_argument(host, help目标主机IP或域名) parser.add_argument(-p, --ports, default1-1024, help端口范围1-1000 或 22,80,443) parser.add_argument(-t, --timeout, typefloat, default1.0, help超时时间/秒) parser.add_argument(-c, --concurrency, typeint, default100, help并发线程数) parser.add_argument(-o, --output, help输出文件路径) args parser.parse_args() ports parse_ports(args.ports) results scan(args.host, ports, args.timeout, args.concurrency) open_results [r for r in results if r[status] open] filtered_results [r for r in results if r[status] filtered] for r in open_results: banner_info f banner{r[banner]} if r[banner] else print(f[OPEN] {r[host]}:{r[port]}{banner_info}) print(f统计: 开放{len(open_results)}, 关闭{len(results) - len(open_results) - len(filtered_results)}, 过滤{len(filtered_results)}) if args.output: if args.output.endswith(.json): with open(args.output, w, encodingutf-8) as f: json.dump(results, f, ensure_asciiFalse, indent2) elif args.output.endswith(.csv): with open(args.output, w, newline, encodingutf-8) as f: writer csv.writer(f) writer.writerow([host, port, status, banner]) for r in results: writer.writerow([r[host], r[port], r[status], r[banner]]) else: print(输出格式仅支持 .csv 或 .json) if __name__ __main__: main()这个脚本的适用场景是做内网服务清点、连通性验证、以及初步的安全自查。第一次运行建议扫描127.0.0.1上少数几个端口比如-p 22,80验证脚本行为符合预期再扩展到真实目标。注意扫描过程中输出的banner信息可能包含敏感的服务版本号保存结果的文件请按敏感数据管理不要随意分发。6. 常见问题与排查技巧实录6.1 扫得慢、超时多的原因与解决思路用这个脚本的朋友踩过的最普遍的问题就是慢。慢的根源又多半不是脚本逻辑的问题而是超时参数设置得不合理。如果你扫一个网段每个端口都等满3秒才返回那即使有100个并发线程整体耗时也会非常难看。解决方式很简单先内网用1秒跨网段用1.5到2秒确认目标IP存活之后再调低并发调高速度。还有一个让我印象深刻的案例有次扫描一个段内的多台机器发现大量端口状态显示为filtered排查了半天最后才发现是扫描机器和目标机器之间有一台防火墙设备设置了每秒钟连接数限制。大量并发连接触发限制后后面的包被丢弃于是所有未被限制的端口看起来都像被过滤了。从那以后我学会了一个习惯——扫之前先ping一下确认网络通再扫一个关闭的端口看看它返回的是“closed”还是“filtered”以此判断网络设备是否在干扰扫描结果。6.2 误报和漏报是怎么产生的端口扫描里最常见的误报就是把开放的端口报成filtered把关闭的端口误报成open则相对少见但也不是完全没有。什么时候会把关闭的端口误报成open呢比如目标系统有一个防火墙规则把所有未匹配的TCP流量都做了“accept”处理但后面又没有实际服务在监听。这时握手包虽然有回应但最后仍然会被RST终止connect_ex的返回值就很难判定有时会直接返回0。所以我在扫描结果里经常同时保留banner信息没有banner但状态是open的端口要打一个问号再人工复核。漏报更常见于UDP端口。UDP服务不一定回包而且很多防火墙默认丢弃UDP的不可达通知导致大量端口只能归到filtered。如果你确实需要确认一个UDP端口的状态建议别依赖通用扫描器直接用专门的应用层探测方法比如DNS用dig查询、SNMP用snmpwalk等。还有一个容易忽略的漏报源头IPv6。如果你的目标机器只监听了IPv6地址而你又只扫IPv4当然什么也扫不到。socket库默认socket.AF_INET就是只扫IPv4要支持IPv6需要换成AF_INET6同时注意目标地址的格式。不过大多数内网环境还是以IPv4为主这个坑遇到一次就够了。6.3 需要意识到的行为边界与合规扫描主张端口扫描器本身是一个中性的技术工具但它也有明确的使用边界。只应该对你自己拥有、或者已经获得明确授权的主机执行扫描。对不属于你的网络设备和服务进行未经允许的探测轻则日志留痕引发误会重则构成违规行为会给个人或者团队带来不必要的麻烦。我自己的习惯是扫描之前先通过工单系统确认目标主机属于当前负责的业务范围在扫描说明里写明目的、范围和时间窗口。扫描过程中只做端口状态探测和轻量级的banner抓取不去做漏洞利用也不做大规模密集爆破避免对目标系统的稳定性造成影响。规矩这一条说出来没什么技术含量但比任何优化技巧都重要。6.4 几个值得养成的工作习惯工具脚本也要做版本管理放到代码仓库里和同事一起维护。扫描结果文件定期归档离线留存作为后续排查的参考依据。对结果里的open端口做二次确认特别是那些非标准端口的服务建议手动连一次看一眼。扫描前先确认本机的网络代理设置某些环境里代理会把connect()劫持导致结果全部失真。有点讽刺的是我最早写这个脚本的时候只是为了快速找到一台服务器上被某个同事随手绑定的临时服务。现在它已经变成我日常排查的标准工具箱之一每当有网络连通性问题、服务起不来的问题、或者新机器交接盘点需求我都会先跑一遍扫描拿到的结果再结合日志排查经常能省下一个小时的无头绪搜索。工具本身不值钱值钱的是你能不能用它把未知变成已知。Python脚本的好处就在这儿——它允许你按自己的思路去理解网络而不是对着一个功能复杂的黑盒子瞎猜。