☰
Python漏洞扫描系统实战:从端口扫描到定时巡检
2026/9/28 1:37:47 网站建设 项目流程

简介:面向Python学习者和毕业设计开发者的漏洞扫描系统完整项目,基于Django框架实现,包含项目源码、数据库脚本、配套论文文档及PPT,适合用于课程设计、毕业设计和技术学习。项目共380个文件,压缩包大小为83.89MB,文件类型涵盖Python源码(.py/.pyc)、数据库脚本(.sql)、前端样式与脚本(.css/.js)、演示动图(.gif)以及办公文档等,目录组织清晰,便于按模块查阅。目前已有182人学习,适合需要参考完整项目结构、学习Django开发或完成毕设课题的读者,可帮助掌握Web应用开发与常见漏洞扫描功能的实现思路。通过分析源代码能深入理解系统的整体架构、Django开发模式、数据库集成及接口实现;数据库脚本可直接导入数据库构建立即可用的运行环境,配套文档和PPT则便于项目说明与答辩展示,既支撑毕业设计答辩,也可作为技术分享与二次开发的实战基础。

1. Python漏洞扫描系统:毕设项目里你以为的“扫描”到底在扫什么

一个Python漏洞扫描系统的工程量,通常不在“扫描”本身,而在数据库脚本的初始化、指纹规则的维护、结果落库和页面展示这一整条链路上。很多拿到这种项目源码的人,第一步不是写代码,而是先把环境跑起来,再对照文档理解每张表是干什么用的。这个方向适合网络安全方向的毕设、转行安全岗位的项目经历,也适合想给团队做个极简巡检工具的后端工程师。

它扫的是授权目标。扫描器先判断目标端口是否开放,再识别端口上跑的中间件和版本,最后把命中规则的结果写进数据库,形成一份可以筛选、导出的报告。LW、PPT和源码包说明这是一套完整的毕业设计产出物,你需要做的不是重写,而是看懂它的分层结构,再把扫描任务从“一次跑完”改成“能定时跑、能看差异、能不误报”。

2. 先看系统的三层骨架:扫描引擎、规则库存哪、结果怎么落

2.1 TCP端口扫描为什么是这一切的地基

大多数漏洞扫描系统的第一步都长一个样:先探测目标主机哪些TCP端口是开放的。这一步决定了后续所有指纹识别和漏洞探测往哪儿发请求。常见的实现方式叫TCP connect扫描,也就是直接用socket去和目标端口完成一次完整的三次握手,握手成功就认为端口开放。Python里做这个动作的常用接口是connect_ex(),它比connect()更好用,因为connect()失败会直接抛异常,而connect_ex()返回一个整数,0表示连接成功,其余值对应常见的errno。

import socket def tcp_connect_scan(ip: str, port: int, timeout: float = 1.0) -> bool: # 创建一个TCP套接字,AF_INET表示IPv4,SOCK_STREAM表示流式连接 client = socket.socket(socket.AF_INET, socket.SOCK_STREAM) # timeout是单个端口的等待上限,单位秒,设太短容易漏报 client.settimeout(timeout) code = client.connect_ex((ip, port)) client.close() return code == 0

这段逻辑里需要注意的参数是timeout。1.0表示对一个端口最多等1秒,如果目标主机在公网上、网络抖动明显,1秒可能不够;但如果扫的是内网192.168.x.x段,1秒能明显加快整体速度。毕设项目里通常把这个参数放在扫描任务的配置里,而不是写死在函数里,方便按场景调整。connect_ex之所以比connect常用,就是因为它的返回值能直接进入分支判断,少一层try/except的语义噪音。

还有一种SYN扫描方式更快,它不完成完整握手,只发SYN包然后看回包,但Python标准库做不到这件事,需要scapy库或裸raw socket,而且在大多数操作系统上需要root权限。毕设和拿来做内网巡检的场景基本不碰这层,用TCP connect扫描就够应付论文里的“漏洞扫描系统”设计了。避免“扫不到就把锅甩到扫描方式上”的常见误解。

2.2 指纹匹配与Web探测:从端口开放到服务识别

端口开放只是第一个信号,真正给结果定性的是端口上跑的服务和版本。系统会先抓banner,也就是服务监听端口之后主动回给客户端的那段欢迎信息。SSH回SSH-2.0-OpenSSH_7.6p1,Nginx的HTTP响应头里带Server: nginx/1.18.0,这些字符串就是指纹规则要匹配的对象。

这里有一个很容易被忽略的点:HTTP服务虽然同时监听80和8080,但两种端口对应的应用可能完全不同。所以指纹规则的匹配条件里,一般会写上端口约束,避免把8080上跑的Tomcat当成Nginx。我见过不少毕设系统的规则库里没有端口字段,结果拿80端口规则去匹配8080端口,误报率直线上升。

FINGERPRINT_RULES = [ { "name": "nginx", "default_port": [80, 443], "hints": ["Server: nginx", "nginx/"], }, { "name": "Apache Tomcat", "default_port": [8080, 8000], "hints": ["Apache-Coyote/1.1", "Server: Apache-Coyote"], }, ] def match_fingerprint(service: str, banner: str, port: int) -> list: matched = [] for rule in FINGERPRINT_RULES: # 端口先过滤,避免跨端口误匹配 if port not in rule["default_port"]: continue for hint in rule["hints"]: if hint.lower() in banner.lower(): matched.append(rule["name"]) break return matched

这段代码里的大小写折叠很关键。Banner格式在真实环境中极不规范,有的回Server: nginx,有的回Server: Nginx,不统一转小写就会漏报。default_port数组的用意是让一条规则覆盖多个常见端口,但前提是确认业务确实可能部署在其中每个端口上。把规则挂在名称不存在的端口上,只会让报告里的服务识别结果看起来毫无章法。

2.3 数据库脚本里的三张核心表与存储逻辑

数据库脚本在这个项目里的地位被严重低估了。它不仅是建表,还承担了两项任务:初始化漏洞规则数据,以及给前端报表提供可查询的模型。大部分毕设项目的数据库脚本是一个.sql文件,里面顺序通常是建库、建表、插入规则数据。

三张表是避不开的:scan_task记录每次扫描任务,scan_result存具体端口和服务发现结果,vuln_rule是漏洞规则库。下面这张scan_task表是这类系统里最常见的形态:

DROP TABLE IF EXISTS scan_task; CREATE TABLE scan_task ( id INT AUTO_INCREMENT PRIMARY KEY, task_name VARCHAR(64) NOT NULL COMMENT '任务名称', target VARCHAR(128) NOT NULL COMMENT '目标地址:IP、网段或域名', port_range VARCHAR(64) DEFAULT '1-1000' COMMENT '端口范围', status TINYINT NOT NULL DEFAULT 0 COMMENT '0等待 1运行中 2完成 3失败', started_at DATETIME NULL COMMENT '开始时间', finished_at DATETIME NULL COMMENT '结束时间', KEY idx_status (status) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='扫描任务表';

port_range用字符串而不是用整数拆成两个字段,是因为不同来源的任务表达习惯不一样,有的写1-65535,有的写80,443,3306。改成字符串可以省去一层解析,坏处是排序和区间过滤变麻烦。系统内部一般把字符串转成端口列表,再交给扫描引擎。

scan_result表比scan_task表更容易出设计问题。常见字段是id、task_id、host、port、service、risk_level、rule_id、description、found_at,并且会给(host, port, rule_id)加唯一索引,防止同一任务重复上报同一条漏洞。risk_level一般为0到4的整数,对应低、中、高、严重,前端报表拿到这个字段后直接渲染颜色。

vuln_rule表则存规则的元信息:规则名称、漏洞类型、匹配指纹、修复建议、参考链接。匹配指纹这一列在毕设项目里可能就是一条正则表达式或一个包含多个关键字的JSON字符串。数据库脚本初始化时往这张表插几十条常见规则的记录,CVE和具体PoC一般不会全量放在这里,而是放在文档里说明原理。

3. 把项目源码部署起来:数据库脚本、依赖安装与第一次扫描

3.1 环境准备与依赖安装:requirements.txt里到底有什么

拿到源码包之后,不要急着跑主程序。这类项目十有八九依赖第三方库,直接把项目放在系统全局Python环境里装依赖,很可能会和机器上已有的包冲突。常见做法是建虚拟环境,把隔离问题挡在第一步。下面是我在部署这类源码包时的标准流程:

cd vuln_scanner python -m venv .venv source .venv/bin/activate pip install -r requirements.txt -i https://mirrors.aliyun.com/pypi/simple/

python -m venv .venv是Python 3.3之后标准库自带的能力,不需要额外安装virtualenv。-i参数指定的是国内镜像源,在不方便直连PyPI的网络环境里能明显缩短下载时间,如果你已有可用源,换掉这一行也不影响结果。

requirements.txt里通常锁着几个固定依赖:requests做HTTP请求,flask写管理页面,pymysql连MySQL,python-nmap可选,用来调用nmap做端口扫描增强。版本号一般会写成requests==2.28.1这种精确锁定形式,因为漏洞扫描系统对响应包解析很敏感,requests大版本升级可能改变默认行为,导致字段解析失败。如果requirements.txt里没有锁版本,建议你手动把它们固定成当前能跑通的版本,做好这件事能省掉后面一多半排错时间。

3.2 执行数据库脚本:建库、导规则、初始化管理员

数据库脚本是这个项目的核心资产,执行顺序错了或者字符集不对,后面全白搭。我比较推荐的验证方式是先建库,再导入脚本,最后查一下规则数量,确认脚本真的生效了。

mysql -uroot -p -e "CREATE DATABASE IF NOT EXISTS vuln_scan DEFAULT CHARACTER SET utf8mb4;" mysql -uroot -p vuln_scan < sql/init.sql mysql -uroot -p -e "SELECT COUNT(*) FROM vuln_rule;"

第一行命令指定了utf8mb4字符集,这一步不是可选项。如果漏掉,脚本里的中文漏洞描述大概率以latin1或utf8入库,后面页面展示就是一片问号。第二行把整个init.sql喂给vuln_scan库,脚本里通常会包含建表语句和INSERT语句,顺序是固定的,不要拆开执行。

init.sql里大概率还会插入一个管理员账号。因为论文和答辩演示需要展示登录页面,源码里默认账号密码一般是admin/admin123之类,这类默认口令只适合演示环境,部署到真实内网一定要先改掉。导入完成后,用第三行命令确认规则表有数据,如果COUNT(*)返回0,说明脚本里可能带了一个单独的数据文件,你需要继续找import_data.sql或者data.sql,把它也导入一遍。

3.3 命令行跑通第一次扫描并核对结果

大部分此类项目的入口是cli.py,它接收目标地址、端口范围和输出文件三个参数。第一次扫描建议先扫本机或者一台你完全可控的虚拟机,不要一上来就打网关或者公网段,先确认链路能通,再扩大范围。

python cli.py -t 127.0.0.1 -p 1-1000 -o report.html

这条命令的意思是:对127.0.0.1的1到1000号端口做扫描,结果渲染成report.html。扫描结束后,去数据库里验证结果有没有真正落进去:

SELECT task_id, host, port, service, risk_level FROM scan_result ORDER BY found_at DESC LIMIT 10;

如果表里查到刚才那台主机的开放端口,说明从扫描引擎到数据库的完整链路没问题。如果scan_result是空的,优先去查scan_task表里任务是不是处于“2完成”状态,如果任务状态还停在“1运行中”,多半是扫描线程卡在某个端口上,需要回头调整超时时间;如果状态已完成却没有结果,问题大概率出在结果上报环节,也就是扫描结果没有正确写入到数据库。

report.html只是给人看的展示层,真正可靠的数据源是数据库里的scan_result表。你应该养成习惯:以数据库查询结果为准,HTML报告只在答辩和汇报时使用。

4. 核心代码拆解:线程池、指纹规则与结果落库

4.1 单线程扫描器如何改成并发任务队列

刚跑通的扫描器大概率是单线程的,也就是一个端口一个端口连一遍,扫一个B段几百台主机可能要跑几小时。论文里能写,但实用性不行。改造思路是用线程池加队列把端口分发到多线程去并发连接,每个工作线程负责从队列里取端口,扫描结果写回共享列表。

import socket import threading from queue import Queue from typing import List def check_port(ip: str, port: int, timeout: float) -> bool: sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM) sock.settimeout(timeout) try: result = sock.connect_ex((ip, port)) return result == 0 finally: sock.close() def scan_ports_concurrent(ip: str, ports: List[int], workers: int = 50, timeout: float = 1.0) -> List[int]: task_queue = Queue() result_lock = threading.Lock() open_ports = [] for port in ports: task_queue.put(port) def worker() -> None: while True: try: port = task_queue.get_nowait() except Queue.Empty: break if check_port(ip, port, timeout): with result_lock: open_ports.append(port) task_queue.task_done() thread_list = [] for _ in range(min(workers, len(ports))): t = threading.Thread(target=worker) t.start() thread_list.append(t) for t in thread_list: t.join() return sorted(open_ports)

这段代码里有三个参数值得留意:workers控制并发线程数,线程数不是越大越好,50在大多数普通网络环境比较均衡,设到200以上容易触发目标主机上的防护策略,导致后续请求被ban;timeout沿用前面的单端口超时;result_lock是必需的,因为多个线程同时往open_ports里append会在极端情况下丢数据,加锁后屏蔽掉这个风险。

Queue.Empty是新版Python里推荐的写法,用来替代捕获queue.Empty的旧写法。get_nowait()在队列为空时立刻抛异常,配合while True构成优雅退出条件,不会出现线程卡死在get()上的问题。t.join()的作用是等所有线程都退出后再返回结果列表,保证外面拿到的列表是完整的。

4.2 指纹匹配:用响应头判定中间件

指纹匹配在扫描链路中的位置,是端口确认开放后、写结果表之前。它的输入是探测时抓到的banner,输出是服务名称和风险等级。HTTP服务通常直接发一个HEAD请求,然后把Server和X-Powered-By两个响应头作为匹配素材;SSH、MySQL这类非HTTP协议就依赖端口回显的握手包。

import requests def detect_http_server(host: str, port: int, timeout: float = 3.0) -> str: # 只取响应头,不下载body,减少扫描流量和时间 url = f"http://{host}:{port}/" try: resp = requests.head(url, timeout=timeout, allow_redirects=True) header_map = dict(resp.headers) except requests.RequestException: return "unknown" server_value = header_map.get("Server", "").lower() powered_by = header_map.get("X-Powered-By", "").lower() if "nginx" in server_value: return "nginx" if "apache" in server_value: return "apache" if "openresty" in server_value or "openresty" in powered_by: return "openresty" return server_value or powered_by or "unknown"

requests.head()比requests.get()更适合指纹探测,因为HEAD请求不会被Web应用真正解析,能把对目标的影响降到最低,速度也更快。allow_redirects=True是必须保留的,因为很多站会把根路径重定向到/index.html,不走重定向就拿不到真正的响应头。

这个方法能识别常见中间件,但覆盖不了加了WAF或反代混淆的站点。真实场景里,Nginx前面可能挂一层云WAF,返回的Server头变成WAF或一串随机字符串,这时候指纹库再怎么加规则都匹配不上。问题不在于代码逻辑,而在于规则集的覆盖度。我一般会在设计文档里写清楚:指纹识别只能作为参考,不能作为漏洞判定的唯一依据。

4.3 结果入库与前端展示:结果表的字段设计

扫描结果入库是整套系统里最容易出性能问题的一环。每扫到一个端口就执行一次INSERT,扫描量大了之后,数据库连接会被频繁打开关闭,拖慢整体速度。常见做法是把单条入库改成批量入库,要么攒一批再写,要么用executemany()一次提交多行。

import pymysql from datetime import datetime def save_results_batch(results: list, task_id: int, db_config: dict) -> int: connection = pymysql.connect(**db_config, charset="utf8mb4") try: now = datetime.now() rows = [] for r in results: rows.append(( task_id, r["host"], r["port"], r["service"], r["risk_level"], r["description"], now, )) with connection.cursor() as cursor: sql = ( "INSERT INTO scan_result " "(task_id, host, port, service, risk_level, description, found_at) " "VALUES (%s, %s, %s, %s, %s, %s, %s)" ) # executemany提交一个列表,一次网络往返写入多行,优于循环execute cursor.executemany(sql, rows) connection.commit() return len(rows) except Exception as e: connection.rollback() raise RuntimeError(f"批量入库失败: {e}") finally: connection.close()

executemany()会把二维列表压缩成一条带多个参数组的SQL发给MySQL,传输次数从N次降到1次。connection.rollback()放在异常分支是防止写了一半时数据库停留在不确定状态。charset="utf8mb4"这句必须和建库时保持一致,否则中文描述写入MySQL后可能变成乱码标记,前端展示时再读出来又是一堆问号。

前端展示这一层,Flask模板通常只做两件事:按任务查询结果列表,按risk_level渲染彩色标签。状态统计卡片的数据来源是SQL聚合查询,比如按风险等级分组统计数量,这部分逻辑简单,但要注意在模板里不要把None和0混为一谈,否则前端可能出现空白行。

5. 避坑指南:漏报、误报、扫不动的四类现场

5.1 端口明明开放却扫不到:超时参数没调对

现象是同一台主机,用nmap扫出了80、443,自己的扫描器却只报443。原因是默认timeout=1.0在公网或者目标主机CPU被打满的时候,TCP握手响应超过1秒,连接被当作超时丢弃。超时太短导致漏报,在实时扫描任务里很常见。

解决分两步。第一步把单一timeout拆成连接超时和读超时,连接阶段等3秒,读banner阶段再多给2秒;第二步在扫描配置里允许按网段覆盖默认值,比如内网段用1.0,公网段用3.0。这个参数应该暴露成命令行参数而不是写死在代码里,因为不同批次的巡检任务,网络质量可能完全不同。

5.2 数据库写入乱码和中文规则丢失

现象是漏洞描述在MySQL客户端里看着正常,但扫描系统页面上全是?????,或者导入init.sql之后,规则库里的中文修复建议变成乱码。原因基本可以锁定在两处:建库时没有指定utf8mb4字符集,或者连接数据库时没有显式声明charset="utf8mb4"。

解决的检查顺序是:先看建库语句是否带了DEFAULT CHARACTER SET utf8mb4,如果没有,把库删掉按正确字符集重建;再确认init.sql文件本身是UTF-8编码而不是Windows记事本默认的ANSI编码,可以通过file -bi sql/init.sql查看编码;最后检查代码里的数据库连接参数,pymysql.connect()里加上charset="utf8mb4"。这三步做完,乱码问题基本不会再出现。

5.3 requests库升级后出现SSL报错

现象是某一个依赖版本升级后,原本能正常扫描的HTTPS站点突然全部报SSLError,连日志都被刷屏。原因是目标站点还在用TLS 1.0或TLS 1.1协议,而新版本requests和urllib3默认只启用TLS 1.2以上,握手直接失败。这在老企业内部系统上尤其常见。

解决方式有两条路。稳妥一点是给requests的HTTPS请求指定适配器,允许它降级到TLS 1.0;图省事的话,对已知的测试站点关闭证书校验,即verify=False,但这一步要在代码注释里明确标注“仅限授权测试环境”,避免被误当成生产系统的默认行为。我的建议是优先走降级方案,而不是关证书校验,因为一旦开了verify=False,规则里所有HTTPS请求都会失去证书信任问题,之后排查别的连接问题时会多一个干扰源。

5.4 并发扫描把结果表写乱了

现象是单线程扫描结果正常,改成线程池之后就出现结果缺失、重复记录、甚至任务状态没更新。原因是多线程同时连接MySQL,共用同一个连接对象,或者结果列表被多个线程同时写入。数据库连接不是线程安全的,这是并发改造里最典型的坑。

解决方法是给每个线程用独立的数据库连接,或者把扫描结果先汇总到内存列表,等所有线程结束之后再统一做批量入库。优先级最高的是杜绝多个线程读写同一个connection,这会造成连接内部状态错乱。第二个选择是给scan_result表加唯一索引(host, port, rule_id),这样即使偶发重复上报,数据库也能拦截掉重复行,让报告数据保持干净。

6. 进阶:把扫描任务做成定时巡检并接入告警

扫描器跑通一次只是起点,更有价值的用法是让它按计划巡检,对比两次结果之间的差异,把新出现的开放端口及时报出来。这里的骨架是调度器加扫描命令,调度器负责触发时机,扫描命令负责执行任务。我一般用APScheduler而不是系统cron,因为APScheduler可以随应用一起启停,不需要修改系统的crontab配置。

from apscheduler.schedulers.blocking import BlockingScheduler import subprocess from datetime import datetime def nightly_scan() -> None: timestamp = datetime.now().strftime("%Y%m%d_%H%M") report_name = f"report_{timestamp}.html" subprocess.run( ["python", "cli.py", "-t", "192.168.1.0/24", "-p", "1-65535", "-o", report_name], check=False, ) scheduler = BlockingScheduler() # 每天凌晨2点跑一次,避开业务高峰 scheduler.add_job(nightly_scan, "cron", hour=2, minute=0, id="night_scan") scheduler.start()

做完定时扫描之后,可以把两次任务的结果表做一次对比查询,用LEFT JOIN找出本次新发现的端口,再把差异结果发到Webhook或邮件。这个能力才是扫描系统从毕设作品走向实用工具的拐点。而把这个增量对比做成一张报告表,前后两次端口开放一目了然,也就解决了“扫描结果没人看”的问题。

我自己习惯在每个规则入库时都留一条可复现命令,比如curl -v http://10.0.0.5/test.jsp,这样后续出现误报时能快速回放,而不是翻半天代码。把扫描器从“跑通一次”变成“每天都在跑”,才是这类项目最大的价值所在。希望帮到你。

本文还有配套的精品资源,点击获取

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

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

立即咨询