☰
基于Python网络爬虫的Web漏洞检测工具原理与实现解析
2026/10/1 23:00:55 网站建设 项目流程

简介:这是一份基于Python爬虫技术的Web漏洞检测工具源码包,面向计算机专业学生、毕业设计开发者以及Web安全初学者,旨在帮助读者理解如何利用爬虫遍历网站并识别常见安全漏洞。项目整合requests、BeautifulSoup、正则表达式、Selenium等爬取与解析技术,代码模块划分清晰,可直接作为网络安全课程设计或毕业设计的基础框架。压缩包共13个文件,整体体积约1.55MB:6个Python源码文件覆盖爬虫抓取、漏洞检测模块、数据库管理、主控运行等流程;3个zbak配置备份文件可用于排错恢复和版本对比;2个文本文件记录漏洞字典与运行日志;另有1个Markdown说明文档和1个附赠内容包,结构紧凑且便于分角色阅读。目前已有36人学习/下载。通过完整源码和配套说明,读者能快速搭建运行环境,观察爬虫如何解析页面、构造请求并输出扫描结果,参照配置与日志理解常见Web漏洞的检测逻辑;附赠内容包含图片与教程,有助于以可视化方式掌握项目运作。资源整体轻量,适合本地实验和二次开发。

1. 爬虫驱动的Web漏洞检测:这套Python源码能不能直接上手用

拆开「基于网络爬虫的Web漏洞检测工具」这个源码包,第一感觉是结构比想象中干净:crawl.py、vulscan.py、dbms.py、config.py四个核心文件加一份README,没有花架子。它的逻辑一句话能讲明白——网络爬虫负责把目标站点的URL和页面内容尽量抓全,漏洞检测模块再去匹配特征库里的XSS、SQL注入、敏感信息等模式,命中就写进SQLite存档。这套路子的实际意义在于:站点链接一多,手工点页面测漏洞既不现实也不完整,而爬虫能二十秒把首页、文章页、搜索页全逛一遍,检测模块再接力筛查,十分钟跑完以前半小时的活。适合正在做毕业设计、想从爬虫往Web安全方向串知识的人,也适合想研究「网络爬虫原理怎么在安全场景落地」的开发者。能不能直接拿来用?能,但前提是你把每个文件读到能背出关键参数的程度,否则跑出来的报告根本不值得信。

2. 网络爬虫原理落地:crawl.py遍历、去重与请求伪装

2.1 广度优先与URL去重:先把站点的链接掏干净

我拆任何爬虫项目有个习惯:先找crawl.py,因为整份工具的探索能力全看它。这份代码的核心是经典的广度优先遍历:从一个种子URL出发,放进待抓队列,每取一个URL就发请求、解析HTML里的<a>标签和表单action,提取出新的链接再入队,同时用一个visited集合记录已经访问过的地址,防止死循环。

BFS在漏洞扫描场景是合理选择——目标站点的拓扑结构你事先不知道,广度优先保证先覆盖浅层页面,能以最快速度拿到站点入口、导航页和目录页。对比深度优先,后者容易一头扎进某个文章详情页的内部死胡同,等爬回来时队列里已经堆了几百个无关链接。

# crawl.py 简化核心逻辑:BFS遍历 + URL规范化去重 import requests from bs4 import BeautifulSoup from urllib.parse import urlsplit, urlunsplit, urljoin, urlparse class Crawler: def __init__(self, seed_url, max_depth=3, timeout=10): self.seed_url = seed_url self.queue = [seed_url] self.visited = set() self.max_depth = max_depth self.timeout = timeout self.session = requests.Session() def normalize_url(self, url): """URL去重前先规范化:去掉锚点、统一小写、处理结尾斜杠""" scheme, netloc, path, query, fragment = urlsplit(url) path = path.rstrip('/') or '/' return urlunsplit((scheme.lower(), netloc.lower(), path, query, '')) def extract_links(self, html, base_url): """解析页面里的a标签,拼接完整URL同时滤掉外域链接""" links = [] soup = BeautifulSoup(html, 'html.parser') for a in soup.find_all('a', href=True): full_url = urljoin(base_url, a['href']) if urlparse(full_url).netloc == urlparse(base_url).netloc: links.append(self.normalize_url(full_url)) return links def crawl(self): """从队列取URL逐层遍历,直到队列耗尽或达到深度上限""" while self.queue: url = self.queue.pop(0) url = self.normalize_url(url) if url in self.visited: continue self.visited.add(url) try: resp = self.session.get(url, timeout=self.timeout) if resp.status_code == 200: for link in self.extract_links(resp.text, url): if link not in self.visited: self.queue.append(link) except requests.RequestException as e: print(f"请求失败: {url} -> {e}")

逻辑说明:normalize_url这一步很容易被新手漏掉,但它恰恰是去重的关键。同一个http://site.com/a#section1和http://site.com/a#section2如果不剥掉锚点,会被当成两个URL重复抓取;路径末尾的斜杠同理,/about和/about/在很多服务器上指向同一个页面。不规范化就去重,爬虫会把资源浪费在重复请求上,扫描效率直降一半。

参数说明:max_depth控制遍历深度,对课设场景3层足够——首页、列表页、详情页各占一层,再深就是个人中心、后台这类对扫描器意义不大的区域。pop(0)是拿列表模拟队列的写法,URL量在几百这个量级时完全够用,但到了几千条会明显变慢,届时要换成collections.deque。

2.2 请求头伪装与退避重试:别让requests一眼被认出来

看到crawl.py里预留了HEADERS的位置但没有默认值时,我就知道这是把「填不填、怎么填」的责任丢给了使用者。很多人第一次跑扫描器,requests带着默认的User-Agent就出门了——python-requests/2.x这种标识,哪怕对方站点没上什么高级防护,一眼也能认出是脚本在访问,第二个请求就该被拒了。

# 请求头配置:把爬虫伪装成正常浏览器访问 HEADERS = { "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) " "AppleWebKit/537.36 (KHTML, like Gecko) " "Chrome/120.0.0.0 Safari/537.36", "Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8", "Accept-Language": "zh-CN,zh;q=0.9,en;q=0.8", "Referer": "http://127.0.0.1:8080/", } def fetch_with_retry(session, url, retries=3, timeout=5): """带重试的GET请求:超时、限流都让它等等再说""" for attempt in range(retries): try: resp = session.get(url, headers=HEADERS, timeout=timeout) if resp.status_code == 200: return resp elif resp.status_code in (403, 429): # 被限流就递增等待,别硬刚 time.sleep(2 * (attempt + 1)) else: return resp except requests.RequestException: if attempt == retries - 1: raise time.sleep(1) return None

逻辑说明:sleep(2 * (attempt + 1))是我在安全类爬虫里常用的简易退避策略——403代表服务器识别出你是脚本,429代表请求频率触发了限流阈值,这两种情况都不适合继续猛打,等几秒再试反而有希望。普通爬虫可以无视状态码继续抓,漏洞扫描不行:你需要在对方的WAF还没把你拉进黑名单之前,让整个扫描过程尽可能「像人」。

参数说明:retries=3对课设够用,我扫外网授权站点习惯设5,同时把sleep改成随机区间time.sleep(random.uniform(0.5, 1.5)),避免固定间隔被特征检测。Referer别照抄示例里的本地地址,拿一个目标站点同域名的历史来源页填进去,伪装度会高一个档。如果目标站点需要登录态,还要在session里手动塞Cookie——F12从浏览器复制当前会话的Cookie字典,或者用requests.utils.cookiejar_from_dict转成cookies参数。

提示:HEADERS里别只改User-Agent就完事,Accept-Language缺失会让某些严格校验的服务端直接拒绝,因为正常浏览器一定会带这个头。

3. Web漏洞检测核心:特征库匹配与payload实证

3.1 vulfile.txt特征库与vulscan.py匹配机制

拆开vulscan.py最想确认的是:它凭什么判断一个页面「有问题」?读完之后答案是朴素的——特征库,也就是vulfile.txt。这个文件每一行是一条漏洞特征,最典型的格式是三段式:漏洞类型、正则表达式、风险等级,用竖线分隔。检测逻辑就是拿爬虫抓回来的响应内容,对每一条正则做re.search,命中就记为一条漏洞记录。

# vulscan.py 简化核心逻辑:加载特征库 + 正则匹配 import re def load_vuln_rules(filepath='vulfile.txt'): """读取漏洞特征库,返回[(漏洞类型, 正则pattern, 风险等级)]""" rules = [] with open(filepath, 'r', encoding='utf-8') as f: for line in f: line = line.strip() if not line or line.startswith('#'): continue parts = line.split('|') if len(parts) == 3: rules.append((parts[0], parts[1], parts[2])) return rules def scan_page(url, html, rules): """对单个页面做特征匹配,命中就返回漏洞记录""" findings = [] for vuln_type, pattern, risk in rules: try: if re.search(pattern, html, re.IGNORECASE): findings.append({ 'url': url, 'vuln_type': vuln_type, 'risk': risk, 'matched': pattern, }) except re.error as e: print(f"规则正则编译失败: {pattern}, 错误: {e}") return findings

逻辑说明:re.IGNORECASE在扫描场景是必备的——同一个页面里<script>可能出现在<Script>、<SCRIPT>各种写法里,不忽略大小写就会漏报。编码方面我统一用utf-8读特征库,如果原作者的vulfile.txt是按GBK存的,打开会直接乱码甚至抛异常,扫描结果就全废了。

参数说明:规则格式类型|正则|等级是我拆包时根据vulfile.txt推断的默认结构,你解压后如果格式有出入,以实际文件为准。risk分high/medium/low三档,输出报告时按等级降序排列,人工复核时优先盯高风险条目。这套逻辑的本质是黑名单匹配,天然有局限——它只能发现你特征库里写过的漏洞模式,遇到变种或0day就是睁眼瞎,这是所有基于特征的扫描器的共性天花板,不丢人,但你要心里有数。

3.2 从静态匹配到实证探测:XSS与SQL注入需要动态验证

静态匹配有个致命问题:它只验证「响应内容长得像漏洞」,不验证「这个漏洞真的能被触发」。一个搜索页面把用户的输入原样回显到结果里,这确实是XSS的温床,但能不能真打进去,取决于回显位置的上下文——反射在<script>标签内和反射在onclick属性里,payload写法完全不同,静态匹配根本区分不了。

# 动态验证:把payload塞进URL参数,再检查响应里有没有回显 import urllib.parse XSS_PAYLOADS = [ '<script>alert("vul")</script>', '<img src=x onerror=alert(1)>', ] SQLI_PAYLOADS = [ "'", "1' OR '1'='1", '" OR 1=1 --', ] def active_verify(session, base_url, param_name, payload_list): """替换目标参数值发送请求,回显标记出现则视为疑似漏洞""" results = [] parsed = urllib.parse.urlparse(base_url) params = urllib.parse.parse_qs(parsed.query) for payload in payload_list: test_params = dict(params) test_params[param_name] = [payload] test_url = urllib.parse.urlunparse( parsed._replace(query=urllib.parse.urlencode(test_params, doseq=True)) ) resp = session.get(test_url, timeout=5) if payload in resp.text: results.append((param_name, payload, "疑似存在")) return results

逻辑说明:这里把「看到特征就报漏洞」升级成「发送payload再验证回显」——payload真的出现在响应里,至少证明参数值被服务端接收并反射了出来,这是后续利用的前提。主动验证比纯静态匹配多一次实际请求,代价是扫描时间变长、触发WAF的概率变大,所以我把timeout压到5秒,并且只在静态匹配命中的页面上做这一步。

参数说明:<img src=x onerror=alert(1)>比<script>好用,因为它的标签不依赖闭合条件,即使用户输入被截断、引号被转义,onerror事件依然有可能触发。SQL注入里的单引号'是用来试探报错的——响应中出现数据库错误堆栈,基本就能确认存在注入入口;1' OR '1'='1则是验证布尔型注入的更完整payload。注意这套验证只确认「存在回显」,不确认「能实际利用」,中间还隔着编码、过滤、WAF这几道关卡,最终结论要人工复核后下。

4. 数据落地与参数控制:dbms.py存储与config.py调优

4.1 SQLite建表与去重约束:扫描结果怎么存才不乱

扫描器跑完不能只在控制台打几行print就完事,得把结果落盘,下次还能查。dbms.py在这个项目里承担的是数据层角色:建表、写库、查询、去重。选SQLite是合理决定——Python标准库自带sqlite3模块,不用装MySQL客户端,单文件存储,答辩演示时拷一个.db文件就能带走,零部署成本。

-- scan_results 表:核心字段与去重约束 CREATE TABLE IF NOT EXISTS scan_results ( id INTEGER PRIMARY KEY AUTOINCREMENT, scan_time TEXT NOT NULL, url TEXT NOT NULL, param TEXT, vuln_type TEXT NOT NULL, risk_level TEXT DEFAULT 'medium', detail TEXT, UNIQUE(url, param, vuln_type) );

逻辑说明:UNIQUE(url, param, vuln_type)是整个去重体系的灵魂。爬虫在遍历过程中极可能重复访问同一页面,没有这个约束,一个XSS漏洞会被记录十几条,扫描报告变成一坨重复列表,人工复核时根本分不清哪些是真实漏洞哪些是噪音。配套写法是下一条:插入用INSERT OR IGNORE,重复记录会被数据库直接忽略掉。

# dbms.py 简化核心逻辑:INSERT OR IGNORE 配合UNIQUE去重 import sqlite3 from datetime import datetime def insert_finding(cursor, finding): """插入漏洞记录,重复记录由UNIQUE约束直接忽略""" now = datetime.now().strftime('%Y-%m-%d %H:%M:%S') cursor.execute( """INSERT OR IGNORE INTO scan_results (scan_time, url, param, vuln_type, risk_level, detail) VALUES (?, ?, ?, ?, ?, ?)""", (now, finding['url'], finding.get('param'), finding['vuln_type'], finding['risk_level'], finding.get('detail')) )

参数说明:scan_time用ISO格式的字符串存储,2026-03-14 10:22:31这种写法排序直接按字典序,比Unix时间戳直观,也方便按天分组统计扫描轮次。detail字段建议只存命中正则的那段原文,截前100个字符就足够——后面人工复核时需要看上下文,但不需要把所有页面原文都塞进数据库,否则文件体积膨胀得很快。

4.2 config.py参数表与线程数调优:先跑单线程不是怂

config.py在多数组件里扮演「所有可变参数的集中地」,这个项目也一样。我把几个真正影响扫描效果的参数列成了一张表:

参数默认值作用调整建议
max_depth3爬虫最大遍历深度本地靶场可以提到5,外网站点降到2防超时
max_urls200最多抓取URL数课设200够用,演示大站点再往上调
thread_num1并发线程数先1线程跑通,再逐步升到3-5
timeout10单请求超时秒数外网扫描建议5,本地靶场可以放宽到15
retry_times3失败重试次数网络不稳定保持3,内网环境可降到1
# config.py 核心参数配置 MAX_DEPTH = 3 MAX_URLS = 200 THREAD_NUM = 1 TIMEOUT = 10 RETRY_TIMES = 3 # 目标站点配置:先本地靶场跑通,再换授权站点 SEED_URL = "http://127.0.0.1:8080/" DB_FILE = "scan_results.db" LOG_FILE = "log.txt"

逻辑说明:THREAD_NUM从1开始是踩过坑之后改的习惯。爬虫开多线程顶多慢一点,但漏洞扫描不一样——并发线程同时发出大量带payload的请求,目标站点的WAF和反爬系统会立刻警觉,403响应可能比扫描结果来得更快。线程数要加,也必须在fetch_with_retry里配合随机延迟一起加,而不是只改一个数字就完事。

参数说明:SEED_URL指向本地8080端口的靶场,这是最稳的起步策略——先用DVWA这类自带漏洞的靶场环境验证扫描器,确认误报漏报都收敛了,再扫授权站点。max_urls和max_depth是双重刹车:深度控制层数,总量控制规模,任何一个先到阈值都会自动停,避免扫描器在某个动态生成页面的站点上无限跑下去。

5. 避坑指南:反爬拦截、动态渲染与编码乱码排查

5.1 现象:爬虫第二个页面就被403拦死,日志全是Forbidden

原因:requests的默认User-Agent暴露身份,加上扫描频率太快,目标站点的基础反爬规则直接把你列为异常访客。很多课设项目第一次跑真实站点,基本都死在这一步。

解决:必填完整浏览器请求头,尤其是User-Agent和Accept-Language,再加退避重试。我一般会在fetch_with_retry里把403和429单独拎出来处理,收到这两个状态码就指数退避,而不是无脑重试——重试次数过多且间隔固定,会被反爬系统进一步盯上。

5.2 现象:爬虫抓到一堆空壳页面,正文内容全是空白

原因:目标站点是Vue、React这类前端框架做的SPA单页应用,内容靠JavaScript在浏览器里动态渲染。requests直接请求拿到的HTML是空壳,只有<div id="app">骨架,真正的链接和文本全部通过ajax异步加载后填充。

解决:先确认页面是否需要渲染再看要不要上Selenium。简单判断方法是拿响应文本搜<script src=,如果页面里都是打包后的JS文件而没有实质内容,基本就是SPA。Selenium引入成本不低——要装浏览器驱动、速度慢、资源占用高,但面对SPA是唯一可行的路线。我一般会用Selenium只抓动态链接,拿到链接列表后仍用requests做漏洞检测,这样兼顾覆盖面和速度。

5.3 现象:扫描结果里莫名出现大量「疑似漏洞」,人工复核发现全是乱码文本

原因:响应解码出了问题。requests的resp.text默认按响应头里的charset解码,如果目标站点没写charset,或者写了utf-8但页面实际是GBK编码,就会得到乱码。乱码内容里碰巧出现某个字符组合,命中了过宽的正则,就制造一个假漏洞。

解决:解码时不要全信响应头,用resp.apparent_encoding做兜底:

resp = session.get(url, timeout=5) # 优先用meta声明的编码,缺失时让requests按字节内容推断 resp.encoding = resp.apparent_encoding or 'gbk'

apparent_encoding是requests根据字节内容自动推断编码,对中文页面准确率明显更高,代价是推断过程稍慢,所以只在不确定编码时才启用。

5.4 现象:扫描跑到一半中止,log最后一条是某个请求抛异常

原因:crawl.py里的异常处理粒度不够。很多初版代码只包了一层try...except requests.RequestException,但异常类型之间差别很大——连接超时是该跳过的,SSL证书错误也是该跳过的,数据库写入失败却需要终止整个流程。一刀切处理的结果是:不该死的地方死了,该停的地方反而没停。

解决:把异常按可恢复和不可恢复分开兜底:

def safe_crawl(crawler, url): try: crawler.process_url(url) except requests.Timeout: print(f"超时跳过: {url}") # 可恢复,记录后继续 except requests.ConnectionError: print(f"连接失败跳过: {url}") # 可恢复,记录后继续 except sqlite3.Error as e: raise SystemExit(f"数据库错误,终止扫描: {e}") # 不可恢复

5.5 现象:误报率高到无法人工复核,报告里全是无效漏洞

原因:特征库里的正则写得太宽泛。比如拿error去匹配页面,任何一个正常页面的错误提示、JS变量名、代码注释里都可能有这个词;拿select去匹配,十条命中里九条是正常英文。

解决:把单薄的关键字收敛成组合指纹。以SQL注入为例,我一般会匹配数据库报错中才会出现的组合:

# 收敛后的SQL注入特征:优先匹配数据库错误标识 SQLI_FINGERPRINTS = [ r"SQL syntax.*MySQL", r"Warning.*pg_query", r"ORA-[0-9]{4,5}:", r"Unclosed quotation mark", ]

单个error会误伤,ORA-00933这种Oracle报错编号,几乎不可能出现在正常页面文本里。定特征库的原则是:宁缺勿滥——漏报可以靠人补,误报会浪费整份报告的可信度。

6. 进阶玩法:用本地DVWA靶场把扫描器准确率测明白

6.1 搭一个自带漏洞的靶场,先让扫描器在「已知答案」上跑

我拿到这套源码后干的第一件事不是找站点测试,而是在本地搭了个DVWA(Damn Vulnerable Web Application)靶场。DVWA提供了SQL注入、XSS、CSRF、文件包含这些标准漏洞靶子,每个漏洞都有已知的JSESSION_ID、参数名、触发位置。把SEED_URL指到http://127.0.0.1:8080/dvwa/,跑一轮扫描,然后把结果和靶场的漏洞清单逐一核对,就知道自己的特征库哪些行、哪些漏。

校准的核心指标只有两个:漏报率和误报率。漏报等于漏洞存在但没扫出来,这是特征库缺条目;误报等于报告说有漏洞但靶场里其实是正常的,这是特征写太宽。第一次跑的时候,我把vulfile.txt里每一条正则的命中情况都打印出来,对着DVWA页面逐条查,筛掉了三条过宽的规则,又补了五条针对DVWA特定报错文本的指纹,误报率从七成降到两成以下。

6.2 从靶场到授权测试的检查清单

我给自己定了一套固定流程,现在每次都走一遍:

  1. 本地靶场跑通,核心漏洞命中率100%
  2. 扫描一个静态站点,验证爬虫完整性和去重逻辑
  3. 扫描一个带登录的站,验证Cookie注入没问题
  4. 再回到靶场,确认改配置后没引入新误报

从那以后,我每次拿这套扫描器测任何站点,都强制先走完靶场校准,确认配置没有破坏特征库的检出率,才会把SEED_URL换成真实授权目标。这个习惯救过我很多次——最典型的翻车是某次改了thread_num=4忘了加随机延迟,靶场里没感觉,换到外网站点直接整段IP被限流,整个扫描任务白跑。所以现在config.py里凡是涉及频率的参数,我都会写一行注释提醒自己:改速度之前先确保退避逻辑还在。

希望帮到你。

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

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

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

立即咨询