ARTICLE DETAIL

资讯详情

深耕编程入门与网站建设的一线实战洞察。

基于Python和Nmap的自动化网络资产发现系统实战解析

基于Python和Nmap的自动化网络资产发现系统实战解析 从最开始用ping一个个探活到后来写脚本批量跑端口再到今天这套基于 Python 和 Nmap 的自动化网络资产发现系统说实话这条路我走了挺久。做运维和安全的同行应该都有同感内网一扩大资产清单就开始失控谁上了线、谁开了新端口、谁暴露了不该暴露的服务光靠手工记录根本追不上变化。这篇博文就把我开发的这套自动化扫描系统完整拆开讲一遍从设计思路、环境准备、核心代码到问题排查全部是实操经验希望能帮你少踩坑。这套系统解决的核心问题很简单把今天内网有哪些 IP 在线、开放了哪些端口、跑的是什么服务、操作系统是什么、有没有已知的高危风险这些原本需要大量手工操作的事情变成一条命令就能搞定的自动化任务。适合正在做网络资产梳理、安全基线核查、攻防演练前期信息收集或者单纯想给实验室/公司内网做一份动态资产清单的人。我会把涉及的关键技术点、为什么这么选型、以及实际运行中容易翻车的细节都交代清楚。1. 整体设计与方案选型1.1 为什么是 Python Nmap先聊选型。我当时面临的选择其实不少Zabbix 可以做资产发现但太重而且侧重于监控告警不适合做灵活的端口扫描和指纹识别Masscan 扫描速度极快但功能单一结果太粗糙只能告诉你端口开没开拿不到服务版本、操作系统指纹这些关键信息纯用 Python 的socket自己写扫描器倒是能练手但轮子要造一堆而且要做到 Nmap 那种 OS 指纹识别和 NSE 脚本探测工程量巨大。所以最终选了 Python Nmap 这套组合。原因有三第一Nmap 本身是全功能扫描引擎端口扫描、服务版本探测、OS 检测、NSE 脚本扩展全都具备而且经过了十几年的大规模验证准确率有保证第二Nmap 不仅支持命令行还提供了 XML 输出格式这意味着它能很好地和其他程序对接第三Python 生态里有python-nmap这个封装库可以直接在代码里调用 Nmap 的扫描能力并解析结果开发效率非常高。这种组合的本质是把 Nmap 当作一个高性能的扫描引擎Python 负责调度、解析、存储和展示。你不需要重新发明扫描器只需要把 Nmap 的能力管理起来、自动化起来。1.2 功能模块划分整套系统我拆成了五个模块各干各的活互不干扰模块职责关键技术点目标解析模块将 IP、CIDR、域名列表解析为待扫描目标CIDR 计算、去重、黑白名单过滤任务调度模块管理并发扫描任务控制扫描速率线程池、队列、超时控制扫描执行模块调用 Nmap 完成实际扫描动作python-nmap、扫描参数封装结果解析模块解析 XML/对象形式的扫描结果提取资产信息XML 解析、数据规范化数据存储模块将结果写入 SQLite/JSON支持增量比对数据库操作、哈希比对1.3 方案选型中的取舍有几个取舍我拎出来单独说因为这些决策直接影响后面的开发体验。第一并发方式的选择。Python 里做并发有三种主流方式多线程、多进程、异步 IO。Nmap 扫描是一个 IO 密集型操作扫描过程中大部分时间在等网络响应所以我最终选了concurrent.futures.ThreadPoolExecutor做多线程。一个线程管理一个 Nmap 子进程的调用线程数控制在 50 以内避免同时拉起太多 Nmap 进程把系统资源吃满。需要注意Python 的 GIL 在多线程场景下确实会影响纯计算任务的性能但这里主要瓶颈在网络 IO多线程完全够用也不必上多进程增加复杂度。第二Nmap 输出格式的选择。python-nmap 库默认返回的是一个PortScanner对象可以直接访问各种属性方便得要命。但我在实际开发中发现如果扫描目标数量巨大一次性在内存里构造一个大对象再慢慢解析内存占用会飙升。所以我在实现时额外保留了 Nmap 的 XML 输出做一个双保险。对象解析适合快速验证代码逻辑XML 文件适合归档审计。第三存储方案的选择。资产发现工具对数据持久化的要求其实不高SQLite 就非常合适。文件型数据库部署简单、不需要额外起服务单机跑完全没压力。JSON 文件则用来做数据快照方便分享和二次处理。我见过有人非得上 MySQL、Redis对这种工具型应用来说纯属过度设计。2. 环境准备与前置知识2.1 Python 工作环境配置如果你用的是 Linux我强烈推荐后面权限相关的问题会省心很多先确认系统里有没有 Python。Ubuntu/Debian 系可以直接装sudo apt update sudo apt install python3 python3-pip python3-venvWindows 用户安装 Python 时有个细节容易忽略安装向导第一屏最底下有个 Add Python to PATH 的复选框记得勾上否则后面在 cmd 里敲python会提示找不到命令。装完以后打开终端验证python --version pip --version建议建一个虚拟环境来隔离项目依赖不要直接把库装到全局环境不然将来项目多了会互相打架python -m venv scanenv source scanenv/bin/activate # Linux/macOS scanenv\Scripts\activate # Windows2.2 Nmap 安装与验证Nmap 安装是这个项目里最容易出问题的一环不同系统安装方式不太一样。Linux 下直接用包管理器装即可sudo apt install nmapWindows 下可以到官方站点下载安装包。这里说一个 Windows 用户的坑安装完成之后python-nmap 库在调用 Nmap 时默认找的是nmap命令路径。如果你安装时改了安装目录或者用的是免安装的绿色版就一定记得把 Nmap 所在目录加到系统的 PATH 环境变量里否则程序运行时会直接报nmap not found。验证 Nmap 是否可用nmap --version注意在开始写代码之前先手动用 Nmap 扫一次你自己允许测试的机器比如本机 127.0.0.1确认 Nmap 本身能正常工作。这是排查问题时必须先做的一步很多开发中遇到的诡异现象最后都发现是 Nmap 本身环境的问题。2.3 python-nmap 库基础用法python-nmap 是项目最核心的依赖库安装命令pip install python-nmap它最核心的用法分三步创建扫描器、执行扫描、解析结果。import nmap nm nmap.PortScanner() nm.scan(hosts127.0.0.1, arguments-p 22,80,443 -sV) # 判断主机是否在线 if nm[127.0.0.1].state() up: # 遍历端口 for proto in nm[127.0.0.1].all_protocols(): ports nm[127.0.0.1][proto].keys() for port in sorted(ports): state nm[127.0.0.1][proto][port][state] service nm[127.0.0.1][proto][port].get(name, unknown) print(f{port}/tcp {state} {service})这里arguments参数就是你传给 Nmap 的那一串命令行参数非常灵活。我在开发中经常这么组合-sS -sV -O -p-分别代表 SYN 半开扫描、服务版本探测、操作系统识别、全端口扫描。但请注意-sS在 Windows 下不可用需要换成-sTTCP connect 扫描。这类平台差异是开发中很容易踩到的坑后面我再细说。3. 核心功能实现3.1 目标管理与 CIDR 解析资产发现的第一步是拿到一批目标。目标来源通常有几种单个 IP、CIDR 网段比如192.168.1.0/24、域名列表、之前导出的资产清单文件。CIDR 解析我直接用 Python 标准库ipaddress处理非常方便import ipaddress def expand_targets(raw_targets): targets set() for item in raw_targets: item item.strip() if not item: continue if / in item: network ipaddress.ip_network(item, strictFalse) for ip in network.hosts(): targets.add(str(ip)) else: try: ipaddress.ip_address(item) targets.add(item) except ValueError: targets.add(item) # 域名或主机名直接交给 Nmap 解析 return list(targets)为什么要先展开成单个 IP因为后续要做并发调度每个线程负责一批 IP如果直接把一个192.168.1.0/24丢给一个 Nmap 进程去扫速度是有上限的。把网段拆开均分到多个线程每个线程扫描一部分 IP并发效率能提升好几个量级。这里还要注意去重目标里很容易出现既写了192.168.1.0/24又单独写了192.168.1.5这种情况用set去重是最基本的操作。另外我强烈建议加一层黑名单过滤。在内网资产发现场景里总有一些 IP 是不该扫的比如网关、打印机、重要业务服务器。如果扫描动作太猛可能会影响这些设备的正常运行。在黑名单里加一个判断扫描前过滤掉这批 IP能给后续沟通省掉不少麻烦。3.2 并发扫描调度目标解析完之后进入扫描调度环节。这是整个系统的核心也是性能差异的关键。我用ThreadPoolExecutor来管理并发每个工作线程负责一批 IP。为了让结果能回传采用as_completed模式from concurrent.futures import ThreadPoolExecutor, as_completed def run_scan_batch(ip_list, arguments): nm nmap.PortScanner() results {} try: nm.scan(hosts .join(ip_list), argumentsarguments, timeout300) for ip in ip_list: if ip in nm.all_hosts(): results[ip] nm[ip] except Exception as e: log.error(f扫描批次失败: {e}) return results def concurrent_scan(all_targets, batch_size50, max_workers20): batches [all_targets[i:ibatch_size] for i in range(0, len(all_targets), batch_size)] all_results {} with ThreadPoolExecutor(max_workersmax_workers) as executor: future_map {executor.submit(run_scan_batch, batch, -sS -sV): batch for batch in batches} for future in as_completed(future_map): batch future_map[future] batch_results future.result() all_results.update(batch_results) return all_results这里有两个关键参数batch_size和max_workers。batch_size决定每个 Nmap 进程一次扫多少个 IP我一般设 50 到 100max_workers决定同时运行多少个 Nmap 进程一般不超过 20。这两个参数直接影响扫描速度和系统负载需要根据机器性能调。实测下来一个/24网段254 个 IP只做 SYN 扫描和版本探测用 20 个 worker、每批 64 个 IP 的配置大约 3 到 5 分钟出结果。如果做全端口扫描加 OS 识别时间会到 15 到 20 分钟属于正常范围。注意并发不是越高越好。我在刚开始调优时把max_workers设成了 100结果直接把扫描机自己的网络协议栈干崩了Nmap 全部报错。扫描机本身需要稳定建议你先跑一个小网段测试慢慢调参不要一步拉到最激进。3.3 端口扫描与服务识别端口扫描是资产发现的核心动作。Nmap 的端口扫描方式有好几种我主要用两个SYN 扫描和 TCP Connect 扫描。SYN 扫描-sS是默认也是最快的扫描方式它不完成完整的 TCP 三次握手而是收到 SYN-ACK 就判断端口开放。这种半开扫描高效且隐蔽但需要 root/管理员权限。Windows 下用不了Linux 下也需要用sudo运行 Python 脚本。TCP Connect 扫描-sT是完整的三次握手不需要特殊权限Windows 和普通用户都能用但速度相对慢而且更容易被目标机器记录连接日志。扫描端口范围也需要规划。默认-p 1-1024只覆盖了大家熟知的常用端口对资产发现来说远远不够。我通常用-p 1-65535做全端口或者用--top-ports 1000扫最常用的 1000 个端口。全端口扫描耗时较长但有价值——很多安全事件都是从非标准端口上的服务开始的。如果是一次性全量梳理建议全端口如果是定期巡检用--top-ports 1000性价比更高。服务版本识别-sV是资产发现里的重头戏。它能识别 80 端口后面跑的是 Apache 还是 Nginx是哪个版本Tomcat 版本等。这些信息对安全评估、漏洞匹配极其重要。-sV的扫描速度明显比纯端口扫描慢因为它会主动连接端口、发送探测报文并根据响应特征判断协议和版本。对于需要快速出结果的场景可以把--version-intensity调低比如--version-intensity 2减少探测次数速度会快一些。3.4 结果解析与存储扫描结果到手后要做两件事解析成结构化数据、持久化存储。python-nmap 的对象解析方法前面演示过但我在生产代码里更倾向于直接解析 Nmap 的 XML 输出因为 XML 里包含的字段更全比如 CPE 信息、NSE 脚本输出、额外的 hostname 信息。具体做法是在运行 Nmap 时加上-oX参数指定输出文件nmap -sS -sV -oX scan_result.xml 192.168.1.0/24然后 Python 端用xml.etree.ElementTree解析import xml.etree.ElementTree as ET def parse_nmap_xml(xml_path): tree ET.parse(xml_path) root tree.getroot() assets [] for host in root.findall(host): addr host.find(address).get(addr) status host.find(status).get(state) if status ! up: continue asset { ip: addr, hostname: , os: , ports: [], services: [] } hostnames host.findall(hostnames/hostname) if hostnames: asset[hostname] hostnames[0].get(name, ) os_elem host.find(os/osmatch) if os_elem is not None: asset[os] os_elem.get(name, ) for port in host.findall(ports/port): port_id port.get(portid) state_elem port.find(state) if state_elem is None or state_elem.get(state) ! open: continue service_elem port.find(service) service_name service_elem.get(name, ) if service_elem is not None else version if service_elem is not None: product service_elem.get(product, ) ver service_elem.get(version, ) version f{product} {ver}.strip() asset[ports].append(int(port_id)) asset[services].append({port: int(port_id), name: service_name, version: version}) assets.append(asset) return assets拿到结构化的资产列表后写入 SQLite 做持久化。建表语句我用最简单的结构CREATE TABLE IF NOT EXISTS assets ( id INTEGER PRIMARY KEY AUTOINCREMENT, ip TEXT NOT NULL, hostname TEXT, os TEXT, scan_time TEXT NOT NULL, UNIQUE(ip) ); CREATE TABLE IF NOT EXISTS ports ( id INTEGER PRIMARY KEY AUTOINCREMENT, asset_id INTEGER NOT NULL, port INTEGER NOT NULL, service_name TEXT, service_version TEXT, FOREIGN KEY (asset_id) REFERENCES assets(id) );这里设计了一个小技巧assets表用UNIQUE(ip)做约束每次扫描前先判断 IP 是否已存在存在就更新不存在才插入。这样反复扫描不会产生大量重复记录数据库始终保持一份最新的资产状态。3.5 增量发现与变更告警资产发现系统不能只扫一次就完事真正的价值在于定期扫描 增量比对。比如每周扫一次扫描完成后对比上周的结果看有哪些新增的 IP、新开放的端口。实现增量比对有一种非常简单的方式每次扫描完成后把完整的资产列表输出为一个 JSON 快照下次扫描时加载上次的快照做 diff。import json import hashlib def asset_hash(asset): key f{asset[ip]}-{asset[hostname]}-{asset[os]}-{sorted([p[port] for p in asset[services]])} return hashlib.md5(key.encode()).hexdigest() def compare_snapshots(old_snapshot, new_snapshot): old_map {a[ip]: a for a in old_snapshot} new_map {a[ip]: a for a in new_snapshot} added [] removed [] changed [] for ip, asset in new_map.items(): if ip not in old_map: added.append(asset) elif asset_hash(asset) ! asset_hash(old_map[ip]): changed.append({ip: ip, before: old_map[ip], after: asset}) for ip, asset in old_map.items(): if ip not in new_map: removed.append(asset) return {added: added, removed: removed, changed: changed}这个 diff 结果可以输出成一份报告也可以接入告警系统。我在实际使用中是把变化结果写进一个changes.log再用一个简单的定时任务发邮件通知。整条链路跑通之后内网任何一台新机器一开机上线你第二天就能知道。定时任务这块最轻量级的方案就是 Linux 的crontab。系统设计成命令行入口后直接配置0 2 * * 1 cd /opt/asset-scanner /opt/scanenv/bin/python main.py --config config.yaml每周一凌晨两点执行一次全量扫描避开业务高峰期。Windows 环境下可以用任务计划程序原理一样。4. 常见问题与排查技巧实录4.1 扫描结果里全部显示filtered这是我在 windows 下开发时遇到频率最高的问题。所有端口显示filtered而不是open很像目标机器把所有端口都过滤了但实际那些服务明明可以正常访问。排查思路先用 Nmap 命令行手动扫一次看看结果是否一致。如果命令行也是filtered说明 Nmap 的探测包根本没有得到响应通常是防火墙拦截了 SYN 包。测试的时候可以先关掉防火墙验证一次。另一种情况更隐蔽某些 Windows 版本的 python-nmap 调用 Nmap 时默认参数不对。比如在 Windows 下如果没加-PnNmap 会先尝试 ICMP 探测判断主机是否在线Windows 默认防火墙会丢弃 ICMP 请求导致 Nmap 认为主机离线后面的端口扫描全部跳过结果自然是空或者filtered。解决办法是在参数里指定-Pn强制跳过主机发现直接对目标进行端口扫描。我在系统里加了一个配置项skip_host_discovery默认开就是为了应对这种情况。4.2 nmap not found 和端口扫描超时python-nmap 的PortScanner默认使用系统 PATH 里的nmap命令。如果安装路径比较特殊运行时会报错。解决办法是在创建PortScanner对象时指定 Nmap 的可执行文件路径import nmap nm nmap.PortScanner(nmap_search_path(/usr/bin/nmap, /opt/nmap/bin/nmap)) # Windows 示例 # nm nmap.PortScanner(nmap_search_path(rC:\Program Files (x86)\Nmap\nmap.exe,))扫描超时也是一个常见问题。Nmap 默认的速度本身不慢但遇到大量 IP 或者某些不响应 TCP 握手的目标时实际频率可能会远低于预期。python-nmap的scan()方法支持timeout 参数超过指定秒数会抛异常。我的经验是这种全局 timeout 不宜设太短否则大批量扫描时可能误杀一些正在进行的批次。我一般设置 300 到 600 秒如果真超时了说明这批 IP 里可能有大量不可达目标单独拿出来降速重扫。4.3 速度与准确率的平衡扫描速度和结果准确率永远是矛盾的。-sV能拿到服务版本但速度会慢一个量级-p-全端口扫描耗时更长但能发现非标准端口上的服务。我的建议是分级策略不要一套参数打天下扫描场景推荐参数耗时预期/24 网段适用时间快速存活探测-snping 扫描1 分钟内每天一次标准资产梳理-sS --top-ports 1000 -sV5~10 分钟每周一次全面深度扫描-sS -sV -O -p-20~40 分钟每月一次或手动触发-sn这个参数很多人容易忽略它只做主机发现不做端口扫描速度极快。我做每日巡检时先跑-sn拿到当前存活 IP 列表这比直接跑全端口扫描节省大量时间。只有 IP 列表发生变化的时候才触发后面的深度扫描。4.4 权限问题与 User-Agent 伪装在 Linux 上跑-sS需要 root 权限。如果你不想用sudo python main.py这种方式可以用setcap给 Nmap 二进制文件赋予 CAP_NET_RAW 能力sudo setcap cap_net_raw,cap_net_admineip /usr/bin/nmap这样普通用户也可以直接跑 SYN 扫描不需要管理员权限。但这个操作只有在你的运行环境完全可控时才推荐毕竟降低了对二进制文件的权限限制。最后再分享一个容易被忽略的细节Nmap 在扫描时会发起大量连接请求在目标机器的日志里会留下明显的扫描痕迹。如果你的使用场景对隐蔽性有要求可以在参数里加上-D伪造扫描来源或者用--spoof-mac伪装 MAC 地址。但在正常的内部资产管理工作里我建议保持标识清晰反而方便和网络管理员对齐工作内容。别把工具用歪了。4.5 数据一致性并发写入与重复扫描开发过程中还遇到过一个问题并发扫描的多个线程同时往 SQLite 写数据结果报database is locked。SQLite 的写入锁是全局的多线程同时写会发生冲突。解决办法很简单写数据库的操作串行化单开一个队列扫描线程只负责向队列提交结果一个专职的写入线程负责从队列取数据并写入数据库。简单说就是用生产-消费模式import queue import sqlite3 import threading result_queue queue.Queue() def db_writer(data_queue): conn sqlite3.connect(assets.db) while True: item data_queue.get() if item is None: break # 写入逻辑省略具体 SQL conn.execute(INSERT OR REPLACE INTO ..., item) conn.commit() data_queue.task_done() conn.close() writer_thread threading.Thread(targetdb_writer, args(result_queue,)) writer_thread.start() # 扫描线程处理完一批结果就塞到队列里 # result_queue.put(parsed_asset) # 全部完成后向队列发送 None 通知写入线程退出 # result_queue.put(None)这样数据库连接只在一个线程中操作彻底避开锁冲突问题。另一个重复扫描的问题如果你在同一台机器上跑多个扫描任务注意不要让它们的输出文件路径重叠。我最初没留意两个任务同时写了同一个 XML 文件导致解析时拿到的是混杂的结果。解决方式是在文件命名里加上时间戳和 pid比如scan_{hostname}_{timestamp}.xml保证文件名唯一。5. 系统扩展方向与个人经验这篇内容写到这工具本身已经能完整跑通了。你可能还会问这套系统还能怎么延伸。我根据自己的实践简单说几个方向。如果要把结果可视化可以用 FastAPI 起一个轻量接口把 SQLite 里的数据暴露出去前端用任意图表库做一个资产仪表板。IP、端口、服务的增删变化都能做成趋势图一眼看到内网资产的动态。如果公司有 CMDB 系统可以把这个工具的结果通过 API 定期同步过去效率和准确率远高于手工录入。如果是安全团队在用可以对接漏洞扫描器的接口将 Nmap 识别出的服务版本自动带入漏洞库进行匹配。这份工作我后来做过一阵极大减轻了人工核对版本的工作量。我个人在实际操作中最深的体会是资产发现工具的价值不在于扫得多快而在于数据多准、多一致。刚开始我把精力全放在加速扫描上后来发现真正能帮助到日常工作的其实是稳定的定时任务、可靠的增量比对和清晰的变更记录。不要一上来就追求大而全先确保最小闭环能被跑起来再逐步加功能。最后再送一个小技巧给系统加一个启动自检模式。运行参数中增加--self-check它会在正式扫描前先扫一下你指定的一个已知 IP比如本机 127.0.0.1验证 Nmap 环境、python-nmap 库、数据库连接都正常。这个自检在写好新功能后特别有用能够快速发现环境问题而不是等扫描跑了十几分钟才在最后一刻报错。
返回列表