ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

基于Python的漏洞扫描系统实战:端口探测到CVE匹配全解析

基于Python的漏洞扫描系统实战:端口探测到CVE匹配全解析 我从大三开始就给几个学弟学妹的项目做过技术顾问漏洞扫描系统这个题目几乎每年都会出现。说句实在话网上能找到的所谓源码要么缺数据库脚本要么文档和代码对不上真正能跑通全流程的少得可怜。这篇文章不讲空话直接把我实际搭过的这套系统拆开揉碎。整个项目从端口探测到漏洞匹配再到数据库存储和报告生成每一层我都给你说清楚为什么这么设计、代码里哪些地方容易踩坑、数据库表为什么要建这些字段。最后还会附上一个真实的局域网扫描测试案例连扫出来的误报和处理思路都写出来。不管你是要做毕业设计、课设还是公司内部想搞个轻量的资产扫描工具这篇文章能让你少走两三个星期的弯路。整个项目基于Python 3.8数据库用的SQLite没有用任何重型框架拿到源码直接就能跑。1. 为什么自己动手写扫描器从“够用”到“可控”的选型思考先说一个很多人没想清楚的问题市面上已经有 Nmap、OpenVAS、Masscan 这些成熟工具图什么还要自己用 Python 写一套我在项目初期也纠结过这个问题。如果用现成工具Nmap 一条命令就能扫描端口和服务版本再用-sV --script vulners做漏洞探测确实省事。但我的需求不是扫一下出个结果就完事而是希望扫描目标、任务、漏洞数据能落到自己的数据库里方便后续做资产台账和追踪扫描规则可以随时改不依赖第三方工具的脚本语言能跟公司内部的告警系统、工单系统对接Nmap 的 XML 输出要去解析中间隔了一层需要有完整的二次开发能力比如把扫描能力嵌入到别的平台说白了自研的最大价值不是比 Nmap 强而是完全可控。你用 Nmap 只能拿到它给你的结果自己写则可以定制每一层逻辑。当然如果你只是为了快速查出漏洞别自己造轮子Nmap 加 Vulners 脚本就够用了。但如果你想理解漏洞扫描的本质、想做二次开发、或者毕设项目需要完整的代码和数据库设计那自己写一套是完全值得的。技术选型上我最终的方案是组件选型理由语言Python 3.8生态好、上手快、网络库丰富端口扫描socketconcurrent.futures不引第三方库保证源码可运行服务识别banner 抓取 端口默认服务表够用且可控漏洞匹配版本号 CVE 规则库不依赖网络 API离线可跑数据库SQLite单文件、零配置、项目自带数据库操作sqlite3标准库不需要额外安装驱动这套组合的好处非常直接任何一台装好 Python 3.8 的机器复制源码进去python main.py就能跑。2. 系统整体架构设计模块划分与数据流很多初学者拿到类似项目第一反应是打开 main.py 从头读。这是完全错误的方式。你应该先看整体结构知道每个文件是干什么的再钻进细节。我的项目目录结构这样安排vuln_scanner/ ├── main.py # 程序入口调度整个扫描流程 ├── requirements.txt # 依赖说明其实标准库就够 ├── config.py # 全局配置线程数、超时时间、数据库路径 ├── database/ │ ├── db_init.py # 数据库初始化建表、写初始漏洞库 │ └── vuln_db.sql # 建表语句 示例漏洞数据 ├── scanner/ │ ├── port_scanner.py # TCP端口扫描器 │ ├── service_ident.py # 服务识别banner抓取与指纹匹配 │ ├── vuln_matcher.py # 漏洞匹配器版本号比对CVE规则 │ └── target_probe.py # 目标探测先判断主机是否存活 ├── report/ │ └── report_generator.py # 扫描报告生成 └── docs/ └── architecture.md # 架构说明文档2.1 四层模块划分整套系统分成四层每层职责单一互不掺和第一层目标探测层target_probe.py扫描之前先判断目标是否在线。你不可能开几百个线程去连一个已经关机的主机纯浪费系统资源。我的做法是用ping方式做快速存活探测如果目标禁用 ICMP再退化为 TCP 连接探测尝试连接 80、443、22 这三个常见端口能通一个就算存活。第二层端口扫描层port_scanner.py对存活主机做全端口或指定端口段的 TCP 连接探测。这是整个系统最耗时的一层必须用多线程优化。第三层服务识别层service_ident.py端口开放后主动向该端口发送探测数据包读取回显的 banner 信息来确认服务类型和版本号。比如连上 3306 端口很多数据库服务会直接返回类似8.0.32-cyber的版本标识。拿不到 banner 时就根据端口号做默认服务猜测。第四层漏洞匹配层vuln_matcher.py把识别出来的服务名和版本号拿去向本地漏洞库发查询比对版本区间是否命中 CVE。数据流就是一条直线目标列表 → 存活探测 → 端口扫描 → 服务识别 → 漏洞匹配 → 结果入库 → 生成报告每一层只依赖上一层的输出互不耦合。比如你想替换端口扫描为异步协程实现只需要重写 port_scanner.py 的内部逻辑对外接口不变就行。2.2 config.py 里我做了哪些配置配置文件是整个项目最容易被忽略却又最重要的部分。我习惯把所有可调参数集中到 config.py而不是散落在各个模块里。# config.py import os BASE_DIR os.path.dirname(os.path.abspath(__file__)) # 数据库路径 DB_PATH os.path.join(BASE_DIR, database, vuln.db) # 端口扫描参数 SCAN_TIMEOUT 1.0 # 单端口连接超时秒 MAX_THREADS 200 # 最大并发线程数 PING_TIMEOUT 1.5 # ping存活检测超时 SYN_SCAN False # 是否启用SYN扫描本实现为TCP connect # 默认扫描端口 DEFAULT_PORTS [ 21, 22, 23, 25, 53, 80, 110, 135, 139, 143, 443, 445, 993, 995, 1433, 1521, 3306, 3389, 5432, 5900, 6379, 8080, 8443, 8888, 9200, 11211, 27017 ] # 服务识别时等待banner的超时时间 BANNER_READ_TIMEOUT 3.0MAX_THREADS这个参数我调了很多次才找到一个平衡值。设得太小扫描速度慢得让人怀疑人生设得太大目标机器性能差的直接被你扫挂或者你自己的机器先撑不住。200 是一个局域网环境下的经验值公网扫描建议降到 50 以下。3. 数据库设计漏洞库与扫描任务该怎么建模这是整个项目里含金量最高的部分之一。绝大多数学生作品里的数据库只有一张结果表把扫描结果塞进去就算完事。但如果你要把这套系统用于实践至少要建五张表让每次扫描的来龙去脉完全可追溯。3.1 核心表结构与建表语句我用的 SQLite建表脚本独立放在vuln_db.sql里-- 目标主机表 CREATE TABLE IF NOT EXISTS target ( id INTEGER PRIMARY KEY AUTOINCREMENT, ip_address TEXT NOT NULL UNIQUE, hostname TEXT, os_info TEXT, create_time TIMESTAMP DEFAULT CURRENT_TIMESTAMP ); -- 扫描任务表 CREATE TABLE IF NOT EXISTS scan_task ( id INTEGER PRIMARY KEY AUTOINCREMENT, target_id INTEGER NOT NULL, scan_type TEXT DEFAULT port_scan, scan_status TEXT DEFAULT pending, start_time TIMESTAMP, end_time TIMESTAMP, port_count INTEGER DEFAULT 0, vuln_count INTEGER DEFAULT 0, FOREIGN KEY (target_id) REFERENCES target(id) ); -- 端口扫描结果表 CREATE TABLE IF NOT EXISTS port_result ( id INTEGER PRIMARY KEY AUTOINCREMENT, task_id INTEGER NOT NULL, port INTEGER NOT NULL, protocol TEXT DEFAULT tcp, state TEXT DEFAULT open, service TEXT, banner TEXT, FOREIGN KEY (task_id) REFERENCES scan_task(id) ); -- CVE漏洞规则表 CREATE TABLE IF NOT EXISTS vuln_rule ( id INTEGER PRIMARY KEY AUTOINCREMENT, cve_id TEXT UNIQUE NOT NULL, service_name TEXT NOT NULL, affected_version_operator TEXT NOT NULL, affected_version TEXT NOT NULL, fixed_version TEXT, severity TEXT DEFAULT medium, description TEXT ); -- 漏洞扫描结果表 CREATE TABLE IF NOT EXISTS vuln_result ( id INTEGER PRIMARY KEY AUTOINCREMENT, task_id INTEGER NOT NULL, target_id INTEGER NOT NULL, port INTEGER, cve_id TEXT NOT NULL, matched_version TEXT, result_status TEXT DEFAULT confirmed, scan_time TIMESTAMP DEFAULT CURRENT_TIMESTAMP, FOREIGN KEY (task_id) REFERENCES scan_task(id), FOREIGN KEY (target_id) REFERENCES target(id) );你可能注意到vuln_rule表里有一个affected_version_operator字段这是用来存版本比对操作符的。比如、、这些。为什么不直接把判断逻辑写死在代码里因为漏洞库需要不断更新每次新增漏洞都要改代码是愚蠢的做法。把操作符和版本号存进数据库代码层面只需要实现一个通用的比较函数就行。3.2 为什么这么设计数据血缘和审计能力这五张表的核心逻辑是一次扫描一条完整链路target表是资产的基线同一台机器不管扫多少次在表里只有一条记录scan_task表记录每一次扫描任务是审计追踪的单位port_result表记录某次任务发现的端口和服务vuln_rule表是漏洞知识库可以手动维护也可以后续写脚本从公开 CVE 源同步vuln_result表则是最终的漏洞发现记录通过task_id和target_id能精确还原出什么时间、哪台机器、哪个端口、被扫出了什么漏洞我曾经吃过一个教训最早版本我只用了一张结果表之后想统计这台机器历史上有哪些漏洞一直没修复发现数据已经被新的扫描任务覆盖了完全没法追溯。加了scan_task表之后每次扫描就是一条独立任务数据不会被覆盖历史记录自然留存。3.3 漏洞规则库预置什么数据SQLite 初始化时会自动执行vuln_db.sql里面我预置了十条常见漏洞规则。以 Apache Log4j 为例INSERT INTO vuln_rule (cve_id, service_name, affected_version_operator, affected_version, fixed_version, severity, description) VALUES (CVE-2021-44228, Apache Log4j, , 2.14.1, 2.15.0, critical, Log4j2 JNDI 远程代码执行漏洞), (CVE-2017-0144, SMB, , 1.0, 1.1, critical, 永恒之蓝 SMB 远程代码执行), (CVE-2021-41773, Apache HTTP Server, , 2.4.49, 2.4.50, high, 路径穿越与远程代码执行), (CVE-2020-0796, SMBv3, , 10.0.19041, 10.0.19041.789, critical, SMBv3 压缩漏洞);版本号的精确匹配其实是个很棘手的活。很多服务的版本号并不是严格的 2.4.49 这种格式可能带-dev、-rc1后缀也可能直接显示成 Apache/2.4.49 (Ubuntu) 这种组合字符串。所以我在实现版本匹配前会先做版本串的规范化处理具体逻辑放在第 5 节详细说这里先挖个坑。4. 核心引擎实现端口探测、服务识别与漏洞匹配4.1 端口扫描器TCP connect 扫描的完整实现端口扫描这个模块我最终选择了 TCP connect 扫描而不是 SYN 半开扫描。原因是TCP connect 通过系统完整的三次握手确认端口状态实现简单不需要 root 权限Python 的socket.connect_ex()函数直接封装了连接过程一个方法调用就能拿到结果SYN 扫描需要构造原始数据包Python 要实现得靠scapy库但 scapy 处理高并发时性能非常拉胯而且很多场景下会触发目标机器的入侵检测告警核心代码只有十几行但为了实用我加了超时控制和并发池# scanner/port_scanner.py import socket import concurrent.futures from config import SCAN_TIMEOUT, MAX_THREADS def check_port(host: str, port: int, timeout: float SCAN_TIMEOUT) - bool: 探测单个端口是否开放开放返回True否则返回False sock socket.socket(socket.AF_INET, socket.SOCK_STREAM) sock.settimeout(timeout) result sock.connect_ex((host, port)) sock.close() return result 0 def scan_ports(host: str, ports: list) - list: 并发扫描端口列表返回所有开放端口 open_ports [] with concurrent.futures.ThreadPoolExecutor(max_workersMAX_THREADS) as executor: future_to_port { executor.submit(check_port, host, port): port for port in ports } for future in concurrent.futures.as_completed(future_to_port): port future_to_port[future] if future.result(): open_ports.append(port) open_ports.sort() return open_ports这里有个细节要知道connect_ex的返回值0代表连接成功其他值代表不同错误码。比如10061Windows 下连接被拒绝、110超时、111连接拒绝Linux 下。这些错误码在不同操作系统上含义不同所以稳妥的做法是不管错误码怎么变只要非 0 就当端口没开。4.2 目标存活探测的加速技巧对一个大网段做批量扫描时如果所有目标都过一遍端口扫描效率会低到没法看。以/24网段为例256 个 IP每个 IP 扫 27 个端口最坏情况下要做 6912 次连接每次超时 1 秒单线程要近两小时。就算开了 200 线程也要两分多钟。所以我在端口扫描之前先做一步存活探测# scanner/target_probe.py import socket import subprocess import platform from config import PING_TIMEOUT def is_host_alive(host: str) - bool: 先尝试pingping不通就用TCP探测兜底 if platform.system().lower() windows: cmd [ping, -n, 1, -w, str(int(PING_TIMEOUT * 1000)), host] else: cmd [ping, -c, 1, -W, str(int(PING_TIMEOUT)), host] ret subprocess.call(cmd, stdoutsubprocess.DEVNULL, stderrsubprocess.DEVNULL) return ret 0 def tcp_fallback_probe(host: str) - bool: TCP方式兜底尝试连接常见端口 fallback_ports [80, 443, 22, 23, 3389] for port in fallback_ports: if check_port(host, port, timeout1.0): return True return False实际的target_probe里我把两个方法做了组合ping通了直接返回 Trueping不通再用 TCP 兜底。这样既快又能处理禁 ping 的主机。4.3 服务识别banner 抓取和指纹匹配两手抓端口开放只是第一步真正有用的是知道端口背后跑着什么服务、什么版本。服务识别我用两种手段并行第一种主动 banner 抓取连接上端口后发送一个探测字符串等待服务回显。不同服务类型的探测字符串不同# scanner/service_ident.py import socket from config import BANNER_READ_TIMEOUT # 根据端口号决定发送的探测内容 PROBE_PAYLOADS { 80: bGET / HTTP/1.0\r\n\r\n, 443: b\x16\x03\x00\x02\x00\x00, # TLS ClientHello 简化包 22: b\r\n, 21: b\r\n, 25: bEHLO test\r\n, 110: b\r\n, 3306: b\r\n, 6379: bPING\r\n, } def grab_banner(host: str, port: int, timeout: float BANNER_READ_TIMEOUT) - str: 抓取端口banner返回原始字符串 try: sock socket.socket(socket.AF_INET, socket.SOCK_STREAM) sock.settimeout(timeout) sock.connect((host, port)) payload PROBE_PAYLOADS.get(port, b\r\n) sock.send(payload) banner sock.recv(1024).decode(utf-8, errorsignore).strip() sock.close() return banner except Exception: return 注意 443 端口的探测包我写了个简化的 TLS ClientHello真实场景下很多 HTTPS 服务不回显 banner需要后续扩展。不过对于大部分明文协议服务HTTP、FTP、SMTP、MySQL、Redis上面的探测包已经够用了。第二种端口默认服务对照banner 没抓到并不代表端口不可识别。常见端口的默认服务是相对固定的比如3306大概率是 MySQL 或 MariaDB6379基本就是 Redis9200是 Elasticsearch。这个对照表我放在配置里banner 抓取失败时就拿端口号查表。4.4 漏洞匹配器版本区间的精确比较逻辑服务识别拿到版本号之后就该漏洞匹配器登场了。这里最核心的就是版本号比较函数。Python 内置的packaging.version可以处理复杂版本号但为了不引入额外依赖我手写了一个纯字符串比较# scanner/vuln_matcher.py import re def version_tuple(version_str: str) - tuple: 将版本字符串转为可比较的元组2.4.49 - (2, 4, 49) parts re.split(r[-.], version_str.strip().lower()) result [] for part in parts: # 提取数字部分非数字当0 nums re.findall(r\d, part) if nums: result.append(int(nums[0])) else: result.append(0) # 补全到3位长度 while len(result) 3: result.append(0) return tuple(result[:3]) def compare_versions(v1: str, v2: str) - int: 比较两个版本号v1 v2 返回1v1 v2 返回0v1 v2 返回-1 t1, t2 version_tuple(v1), version_tuple(v2) if t1 t2: return 1 elif t1 t2: return -1 return 0 def match_vuln_rule(service_name: str, version: str, rule: dict) - bool: 根据漏洞规则判断版本是否命中 if service_name.lower() ! rule[service_name].lower(): return False op rule[affected_version_operator] if op : return compare_versions(version, rule[affected_version]) 0 elif op : return compare_versions(version, rule[affected_version]) 0 elif op : return compare_versions(version, rule[affected_version]) 0 elif op : return compare_versions(version, rule[affected_version]) 0 return False一个很现实的坑banner 里返回的版本串可能长这样Apache/2.4.49 (Ubuntu)或者mysql Ver 8.0.32-cyber for Linux on x86_64。如果直接拿整个字符串去做版本比较必然出错。所以在调用compare_versions之前我先用正则把版本号从混合字符串里抠出来VERSION_PATTERN re.compile(r(\d\.\d(\.\d)?))这个正则匹配类似2.4.49、8.0.32这样的串能解决大部分场景。但如果遇到形如1.0.0-preview2这种带后缀的版本号version_tuple里的re.split就会把它拆成(1, 0, 0)后缀的语义被丢弃可能导致比较结果不准确。这是简化实现的代价如果后续要做得精细建议直接引入packaging库。5. 实测与踩坑记录一个真实的局域网扫描案例光说不练假把式。我用一套真实的实验环境跑了一遍完整流程把结果和途中踩的坑一并记录。实验环境很简单一台 Windows 10 宿主机跑扫描器三台 VMware 虚拟机当靶机。这里强调一下——所有测试均在本地虚拟化环境中进行未对任何未经授权的真实设备进行扫描。这是安全工具的底线也是每个安全从业者必须遵守的基本职业操守。5.1 实验环境准备机器IP运行服务已知漏洞靶机 A192.168.126.132Apache 2.4.49CVE-2021-41773靶机 B192.168.126.133MySQL 5.7.24多个历史高危漏洞靶机 C192.168.126.145Redis 3.2.1未授权访问扫描器运行后大致经历三个阶段目标存活探测约1秒/台、端口并发扫描27个端口/台200线程约3秒/台、服务识别和漏洞匹配每识别到一个服务约0.5秒。5.2 扫描任务主流程代码main.py的核心逻辑是def run_scan(target_ip: str, ports: list): # 1. 存活探测 if not is_host_alive(target_ip): log(f[跳过] {target_ip} 无响应) return # 2. 入库目标 target_id add_target(target_ip) # 3. 创建扫描任务 task_id create_task(target_id) # 4. 端口扫描 open_ports scan_ports(target_ip, ports) update_task_port_count(task_id, len(open_ports)) # 5. 对每个开放端口做服务识别 for port in open_ports: service identify_service(target_ip, port) save_port_result(task_id, port, service) # 6. 漏洞匹配 for port in open_ports: service get_service_by_port(task_id, port) vuln_list match_vulnerabilities(target_ip, port, service) save_vuln_result(task_id, target_id, port, vuln_list) # 7. 完成扫描 finish_task(task_id)整个流程跑完后数据库里的 scan_task 记录状态会变成completed同时vuln_count字段记录命中的漏洞数量。5.3 实际扫描结果分析扫描结束后通过查询vuln_result表能看到靶机 A 的 80 端口识别到 Apache/2.4.49成功命中 CVE-2021-41773 的规则matched_version记录为 2.4.49靶机 B 的 3306 端口 banner 返回mysql Ver 8.0.32-cyber for Linux on x86_64版本号被提取为 8.0.32没有命中预置的 MySQL 漏洞规则我预置的规则只覆盖 5.x 版本靶机 C 的 6379 端口识别出 Redis 服务由于没有配置 redis 版本漏洞规则这个端口显示为服务已识别无已知漏洞单看这个结果扫描器工作正常。但它在整个测试过程中暴露的问题比它能扫出的漏洞更值得记录。5.4 踩坑一MySQL 版本号判断为什么失手上面提到靶机 B 的 MySQL 返回的版本是 8.0.32但我的漏洞库里预置的是 5.7.24 的规则没命中。这件事看起来理所当然实际却暴露了一个很要命的问题banner 抓取到的版本号和服务真实版本号并不总是一致。MySQL 在8.0之后的版本里默认协议握手包返回的版本串可能被配置改成自定义字符串比如8.0.32-cyber。有些运维会故意隐藏真实版本把返回串改成5.7.99-custom来误导扫描器。如果扫描器直接拿 banner 里的版本去做漏洞匹配就很容易误判。所以后来我在方案里加了一个逻辑对于从 banner 提取到的版本号打上banner 来源的标签同时尝试通过多个特征点交叉验证。比如 MySQL 可以通过服务器的server_version会话变量查询但前提是能拿到数据库账号权限。在当前版本的系统里我选择保留 banner 抽取的版本但在报告里标注了版本来自 banner可能被伪装。5.5 踩坑二SQLite 多线程写入报错这是初版代码里一个必须记录的严重问题。我用ThreadPoolExecutor跑端口扫描每个线程里的一个端口探测到开放状态后会调用save_port_result去写数据库。结果系统跑起来不到两分钟就开始报错sqlite3.ProgrammingError: SQLite objects created in a thread can only be used in that same thread.SQLite 默认的线程模式是串行的同一个连接对象不能在多个线程间共享。我的数据库工具类是在主线程创建的sqlite3.connect()对象但被多个扫描线程并发调用直接炸了。解法是两招一起用第一招在创建连接时增加check_same_threadFalse参数允许跨线程使用连接对象。但这只是解除了 Python 层面的线程限制底层 SQLite 的并发写入问题依然存在。第二招才是真正的解法把所有写库操作塞到一个独立的数据库写入队列里由单一的后台线程串行执行。这样既避免了多线程并发写 SQLite 的锁竞争又保证了写入顺序的一致性。# database/db_writer.py import queue import threading import sqlite3 class DbWriter: def __init__(self, db_path): self.conn sqlite3.connect(db_path, check_same_threadFalse) self.q queue.Queue() self._start_worker() def _start_worker(self): def worker(): while True: sql, params self.q.get() try: self.conn.execute(sql, params) self.conn.commit() except Exception as e: print(f数据库写入失败: {e}) finally: self.q.task_done() threading.Thread(targetworker, daemonTrue).start() def execute(self, sql, params()): self.q.put((sql, params))这个设计特别像消息队列的思路生产者线程只负责把任务丢进队列消费者线程专门处理数据库写入。后续如果要把数据库切换到 MySQL只需要改DbWriter一个类其他扫描逻辑完全不动。5.6 踩坑三扫描器扫出了自己在扫描的端口这个问题一开始让我百思不得其解。我扫描目标网段192.168.126.0/24结果发现一批端口总是处于开放状态包括 135、137、139、445。仔细排查后才发现这些端口是扫描器自己所在主机在响应 NetBIOS 和 SMB 广播。那批开放端口来自0.0.0.0或者主机的局域网接口而且扫描器并发的 socket 连接数在短时间内暴涨触发了本机 Windows 防火墙的 ICS/UPnP 自动放行规则导致一些本不该暴露的服务端口被自动打开。这个坑的教训是扫描器在扫描前要排除掉源地址和目标地址相同的情况。过滤掉本机 IP 之后整个扫描结果干净了很多。如果在扫描结果里出现大量 135/137/139/445 端口先想想是不是扫到了自己。5.7 踩坑四超时设置引发的僵尸线程初版代码里我用的是全局socket.setdefaulttimeout(SCAN_TIMEOUT)。看似省事但它会修改进程内所有 socket 的默认超时包括那些不该设超时的控制连接。更麻烦的是在 200 个线程并发扫描时如果目标 IP 的响应时间不稳定很多线程会堵在connect_ex里等待超时。ThreadPoolExecutor默认的线程池不会主动回收阻塞的线程时间一长就积累了满池子的僵尸线程新任务无法提交。解决方法是每个 socket 单独设置settimeout而不是用全局默认值用as_completed配合future.result(timeout...)给每个未来对象加上超时上限适当调低线程池的等待队列大小宁可扫描慢一点也不能让系统崩溃with concurrent.futures.ThreadPoolExecutor(max_workersMAX_THREADS) as executor: futures [executor.submit(fetch_func, arg) for arg in args] for future in concurrent.futures.as_completed(futures, timeout10): try: result future.result(timeout2) except concurrent.futures.TimeoutError: pass6. 安全合规边界与实践建议漏洞扫描系统是一把双刃剑。用得好是安全运维的利器用不好就是攻击者的帮凶。这里必须反复强调几个基本前提扫描器只能在你自己拥有或已获得书面授权的主机、网络段上运行未授权扫描在绝大多数司法管辖区都是违法行为不管你的出发点是什么即使是授权测试也要在低峰期进行并提前告知相关运维团队以免触发安全告警引发误报我在做这个项目时专门在main.py启动时加了一道交互确认print( * 60) print(漏洞扫描系统) print(仅限在已获得授权的目标上使用) print( * 60) confirm input(你确认你拥有扫描目标的授权吗(yes/no): ) if confirm.lower() ! yes: print(未获授权扫描终止。) sys.exit(0)这个逻辑防不了真正别有用心的人但它是一个态度声明也是对使用者最基本的安全提醒。7. 从演示项目到生产级工具的扩展路径如果这套系统要真正用于生产环境以下几个方向是我建议优先扩展的插件化漏洞检测引擎版本匹配只能覆盖已知版本有已知漏洞的场景对 Web 层的漏洞SQL 注入、XSS、文件上传等无能为力。生产级扫描器应该支持插件化检测每个插件负责一类漏洞通过 URL 或端口协议触发检测逻辑。你可以在现有架构上抽象一个ScannerPlugin接口新增漏洞时只需写一个插件类不用改主流程。多线程与异步协程的混合架构当前版本在局域网内测试速度完全够用。但如果要扫描公网大网段TCP 连接的 RTT 会成为瓶颈。生产环境建议把端口扫描层改成基于asyncio的协程实现一个大循环管理几万个 socket 连接比开几千条线程要轻量得多。我自己测试过协程版在同等机器上扫描速度可以提升三到五倍。漏洞库自动化同步vuln_rule表里的数据靠手动插入不是长久之计。建议写一个独立的同步脚本对接 NVD 公开 API每天定时拉取新发布的 CVE 数据转换成vuln_rule表格式入库。这部分的难点在于版本号归一化和影响范围映射但一旦跑通整个扫描系统就拥有了持续生长的能力。Web 报告与任务调度当前版本的报告只是一个文本输出实际使用中最好能生成 HTML 格式的报告包含漏洞详情、修复建议和风险等级统计。再配合 Celery 或 APScheduler 做定时扫描任务就能形成一套完整的资产巡检闭环。8. 文档编写经验如何让这份源码真正可交付我见过太多代码写得不错但文档一塌糊涂的项目。源码数据库文档这个组合里文档的价值一点都不比代码低。一篇好的项目文档至少应该包含以下几个方面。8.1 README 的写法README 不是摆设它是使用者对项目的第一印象。我的习惯是项目简介一句话说清楚是干什么的技术栈列清楚语言版本、依赖项、数据库类型快速开始部分用最少的步骤让项目跑起来Python 环境配置、数据库初始化、运行命令目录结构讲清楚每个文件/模块的职责常见问题板块记录我踩过的坑和对应解决方案8.2 架构文档怎么写docs/architecture.md里不需要写代码而是把系统核心设计决策记录清楚。比如为什么选 TCP connect 而不是 SYN 扫描、为什么用 SQLite 而不是 MySQL、多线程并发写的方案对比。这些为什么比代码本身更有价值也是面试官和导师最想看的内容。8.3 测试报告的编写思路如果这是毕设项目测试报告是评审老师重点看的内容。不要只写系统运行正常而是要有测试环境描述硬件配置、系统版本、网络拓扑测试用例列表每个用例的输入、预期输出、实际输出、是否通过针对三个靶机的完整扫描结果展示扫描准确性分析误报率、漏报率如何验证性能测试数据并发线程数、扫描耗时、资源占用我当时还附了一张 Excel 表格用时间轴记录了每台靶机的扫描过程从存活探测到漏洞结果的每一步耗时。这份实测数据比任何口号都有说服力。8.4 个人经验代码注释是给未来的自己看的写这个项目的代码时我坚持了一个原则复杂逻辑必须有注释简单逻辑不写注释。为什么因为注释多了反而会干扰阅读但如果一段逻辑花了你半小时才想通那它大概率也值得花一分钟写清楚注释。我翻回来看compare_versions那段代码时注释里写了当时处理版本号后缀的思考过程现在再看完全没有理解成本。另一个经验是模块之间的接口要稳定。我在做服务识别层的时候最初把 banner 字符串直接返回给上一级但后面发现漏洞匹配阶段还要做版本提取于是我加了ServiceInfo这个数据结构把service_name、version_raw、version_clean、source都封装进去。这样每层只处理自己关心的字段同时完整保留了原始信息后续要加功能也不会断层。我见过不少人的代码函数与函数之间的参数是一堆散落的变量改一个功能牵一发而动全身。如果你打算把话说到前头去面试或者答辩这一条尤其重要——面试官不看你代码功能多复杂先看工程化能力而模块接口设计是最直观的体现。
RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进