☰
爬虫驱动的Web漏洞扫描器:从URL到漏洞报告的一条龙思路
2026/9/28 22:26:57 网站建设 项目流程

简介:这是一份面向网络安全初学者与渗透测试爱好者的Python爬虫式Web漏洞扫描器源码包,适合用于学习漏洞扫描原理、爬虫与安全工具开发的结合方式。资源共包含9个文件,以6个py脚本为核心,辅以2个txt字典/日志文件和1个md说明文档,压缩包仅10KB,轻量易读,下载后可直接运行调试。目录中涵盖爬虫抓取、漏洞检测模块、数据库配置与主程序入口等结构,便于读者理解扫描器从URL采集到漏洞判定的完整流程。目前已有185人学习下载,说明其在入门实践场景中具有一定参考价值。通过阅读与二次修改,读者可掌握爬虫调度、漏洞规则匹配、结果记录等关键思路,并在此基础上扩展自己的检测模块,是学习Web安全工具开发的一份实用练手素材。

1. 爬虫驱动的 Web 漏洞扫描器:从 URL 到漏洞报告的一条龙思路

很多人第一次听到「基于爬虫的 Web 漏洞扫描器」,脑子里浮现的是 AWVS、Xray 这类成品工具。但真到自己动手,或者拿到一个「下载即用」的压缩包时,问题就来了:它到底怎么把爬虫和漏洞检测串起来的?为什么有的扫描器跑一遍能出几十个漏洞,有的跑完只有一堆 404?我见过太多人把扫描器当黑盒,点一下「开始扫描」,然后对着报告里的误报发呆。

这个方向解决的核心问题很明确:用爬虫把目标站点的攻击面尽可能完整地枚举出来,再对每个入口点做参数级的漏洞探测。它适合两类人——一是想理解扫描器内部运转逻辑的安全工程师,二是需要快速对自家资产做一轮基线检查的运维或开发。你不需要从零写一个商业级扫描器,但你需要知道爬虫抓什么、漏洞检测怎么挂上去、误报怎么压下来。这篇笔记就按这个顺序,把一条能跑通的路径拆开讲。

2. 爬虫层怎么设计:URL 去重、表单提取与动态渲染的取舍

2.1 爬虫在扫描器里到底抓什么

普通爬虫的目标是「抓内容」,扫描器的爬虫目标是「抓入口」。入口包括三类:带参数的 URL、表单(尤其是 POST 表单)、以及前端 JavaScript 里拼接出来的接口路径。很多人直接用 requests + BeautifulSoup 写个广度优先爬虫,结果跑完发现漏了一半页面,原因就是没处理动态渲染和 JS 生成的路由。

我一般会把爬虫层拆成三个模块:请求调度器、解析器、去重器。调度器负责维护待抓队列和并发控制;解析器从 HTML 里抽<a href>、<form action>、<script src>,同时用正则从 JS 文本里捞/api/、?id=这类模式;去重器用 URL 规范化后的指纹做集合判断。URL 规范化这一步特别关键,/page?id=1和/page?id=1&在扫描器眼里应该是同一个入口,否则队列会爆炸。

下面是一个最小可用的爬虫骨架,用 requests + BeautifulSoup 实现,重点看 URL 规范化和表单提取部分:

import re from urllib.parse import urljoin, urlparse, urlunparse, parse_qs, urlencode from bs4 import BeautifulSoup import requests def normalize_url(url): """去掉 fragment、排序 query 参数、统一末尾斜杠""" parsed = urlparse(url) query = parse_qs(parsed.query, keep_blank_values=True) sorted_query = urlencode(sorted(query.items()), doseq=True) path = parsed.path.rstrip('/') or '/' return urlunparse((parsed.scheme, parsed.netloc, path, '', sorted_query, '')) def extract_entries(html, base_url): """从 HTML 中提取链接和表单""" soup = BeautifulSoup(html, 'html.parser') entries = set() for tag in soup.find_all(['a', 'link']): href = tag.get('href') if href and not href.startswith(('javascript:', 'mailto:')): entries.add(normalize_url(urljoin(base_url, href))) forms = [] for form in soup.find_all('form'): action = urljoin(base_url, form.get('action', '')) method = form.get('method', 'get').lower() inputs = {i.get('name'): i.get('value', '') for i in form.find_all(['input', 'textarea']) if i.get('name')} forms.append({'action': normalize_url(action), 'method': method, 'params': inputs}) return entries, forms def crawl(start_url, max_depth=3, max_urls=500): visited, queue = set(), [(start_url, 0)] all_forms = [] while queue and len(visited) < max_urls: url, depth = queue.pop(0) if url in visited or depth > max_depth: continue visited.add(url) try: resp = requests.get(url, timeout=8, headers={'User-Agent': 'Mozilla/5.0'}) if 'text/html' not in resp.headers.get('Content-Type', ''): continue entries, forms = extract_entries(resp.text, url) all_forms.extend(forms) for e in entries: if e not in visited: queue.append((e, depth + 1)) except requests.RequestException: continue return visited, all_forms

这段代码里,normalize_url做了三件事:去掉#fragment、对 query 参数按 key 排序、统一路径末尾斜杠。extract_entries同时抓<a>和<form>,表单的method和params会被保留下来,后面漏洞检测模块直接复用。crawl用 BFS 控制深度和总量,max_depth=3和max_urls=500是我在中小型站点上比较常用的默认值,再大就要考虑分布式了。

2.2 动态渲染页面要不要上 Selenium

这是被问得最多的问题。我的判断标准很简单:如果目标站点的关键入口(比如商品详情、用户中心)是前端框架渲染出来的,requests 拿到的 HTML 里只有<div id="app"></div>,那你就必须上无头浏览器。但无头浏览器不是银弹,它慢、吃内存、容易被检测。

常见做法是混合模式:先用 requests 快速爬一遍静态链接,把能拿到的入口全部入队;对返回内容里包含__NUXT__、window.__INITIAL_STATE__这类前端状态标记的页面,再丢给 Selenium 或 Playwright 做二次渲染。下面是一个按需触发渲染的判断逻辑:

RENDER_HINTS = ['__NUXT__', 'window.__INITIAL_STATE__', 'id="app"', 'id="root"'] def needs_render(html): return any(hint in html for hint in RENDER_HINTS) def fetch_with_render(url, driver): driver.get(url) # 等待网络空闲,具体超时按站点调整 import time time.sleep(2) return driver.page_source

RENDER_HINTS里列的是常见前端框架的挂载标记,命中任意一个就说明静态 HTML 里没有真实内容。fetch_with_render里的time.sleep(2)是个粗糙但有效的等待,生产环境建议换成显式等待某个元素出现。注意,Selenium 渲染出来的页面同样要过一遍extract_entries,否则你只是换了个方式拿 HTML,入口还是没提取。

2.3 去重与队列控制:别让扫描器自己把自己拖死

扫描器爬虫和普通爬虫最大的区别是:它会对每个入口发大量探测请求。如果爬虫阶段不去重,同一个 URL 带不同无关参数被反复入队,后面漏洞检测就会重复劳动。我一般用两级去重:URL 级别用规范化后的字符串做 set;参数级别用(path, sorted(param_keys))做 key,因为同一个路径下参数名相同、值不同的入口,漏洞检测逻辑往往是一样的。

队列控制上,我会给每个域名设一个并发上限,比如 10 个线程,同时给总请求数设一个硬上限。没有上限的扫描器在稍大一点的站点上就是灾难,跑着跑着目标站挂了,你的 IP 也被封了。这块没有代码可抄,因为每个人的并发框架不同,但原则就一条:宁可慢,不要崩。

3. 漏洞检测模块怎么挂:从参数注入点到 PoC 调度

3.1 检测点的分类与优先级

爬虫给你的是入口列表,漏洞检测要做的是把入口变成检测点。我习惯把检测点分成四类:GET 参数、POST 表单字段、URL 路径片段、HTTP 头。优先级上,GET 和 POST 参数排最前,因为 SQL 注入、XSS、命令注入这些高频漏洞都挂在这上面;路径片段排第二,主要测目录穿越和未授权访问;HTTP 头排最后,主要测 Host 注入和 CRLF。

每个检测点需要携带的信息包括:完整 URL、请求方法、参数名、原始值、以及一个「基线响应」。基线响应是你用原始值请求一次拿到的状态码、响应长度、响应时间。后面所有 PoC 的判定都要和基线对比,没有基线的扫描器误报率会高得离谱。

3.2 一个可扩展的 PoC 调度器

我不建议把每种漏洞的检测逻辑写死在主流程里,那样加一个新 PoC 就要改核心代码。常见做法是定义一个 PoC 接口,每个 PoC 是一个独立模块,调度器只负责遍历检测点、调用 PoC、收集结果。下面是一个简化版的调度器:

class BasePoc: name = "base" def check(self, target): """target: dict(url, method, params, baseline)""" raise NotImplementedError class ScanEngine: def __init__(self, pocs): self.pocs = pocs def run(self, targets): findings = [] for target in targets: for poc in self.pocs: try: result = poc.check(target) if result: findings.append({ 'poc': poc.name, 'url': target['url'], 'param': result.get('param'), 'evidence': result.get('evidence') }) except Exception as e: # 单个 PoC 失败不影响整体扫描 continue return findings

BasePoc定义了统一接口,check返回 None 表示无漏洞,返回 dict 表示命中。ScanEngine.run里对每个 PoC 做了异常隔离,这一点很重要——某个 PoC 因为目标站返回异常格式而抛错,不应该让整个扫描中断。findings里保留了evidence字段,后面生成报告时这是判断误报的关键依据。

3.3 SQL 注入与 XSS 的检测逻辑差异

SQL 注入检测的核心是「布尔盲注 + 报错注入」组合。布尔盲注发两组请求:一组让条件为真,一组让条件为假,对比响应长度和状态码。报错注入则是在参数里塞入会触发数据库报错的 payload,看响应里有没有数据库错误关键词。XSS 检测相对简单,反射型 XSS 就是看 payload 是否原样出现在响应里,但要注意 HTML 实体编码的情况。

下面是一个布尔盲注的检测片段:

import re SQL_ERROR_PATTERNS = [ r'SQL syntax.*MySQL', r'Warning.*mysql_', r'ORA-\d{5}', r'PostgreSQL.*ERROR', r'Microsoft OLE DB.*SQL Server' ] def check_sqli(target, session): url, params = target['url'], target['params'] baseline = target['baseline'] # 报错注入探测 for param in params: test_params = params.copy() test_params[param] = params[param] + "'" resp = session.get(url, params=test_params, timeout=8) for pattern in SQL_ERROR_PATTERNS: if re.search(pattern, resp.text, re.I): return {'param': param, 'evidence': resp.text[:200]} # 布尔盲注探测 for param in params: true_params = params.copy() true_params[param] = params[param] + " AND 1=1" false_params = params.copy() false_params[param] = params[param] + " AND 1=2" r_true = session.get(url, params=true_params, timeout=8) r_false = session.get(url, params=false_params, timeout=8) if abs(len(r_true.text) - len(r_false.text)) > 50 and len(r_true.text) != len(baseline): return {'param': param, 'evidence': f'len_true={len(r_true.text)}, len_false={len(r_false.text)}'} return None

SQL_ERROR_PATTERNS覆盖了 MySQL、Oracle、PostgreSQL、SQL Server 的常见报错特征。报错注入优先于布尔盲注,因为报错注入的误报率更低。布尔盲注里的50是响应长度差异阈值,这个值要根据目标站实际情况调,太小会误报,太大会漏报。baseline对比是为了排除那些本身响应长度就波动的页面。

XSS 检测我一般只做反射型,存储型需要人工确认。反射型检测就是发一个带唯一标记的 payload,比如<script>alert('xss_test_123')</script>,然后在响应里搜xss_test_123是否出现且没有被编码。如果出现的是&lt;script&gt;,那说明被编码了,不算漏洞。

4. 避坑与排查:扫描器跑起来之后最常见的五个翻车现场

4.1 扫描结果全是 404 或 403

现象:爬虫抓了几百个 URL,漏洞检测跑完报告里全是 404 和 403,没有任何有效漏洞。

原因:爬虫没有携带正确的 Cookie 或认证头,导致所有需要登录的页面都返回 403;或者爬虫抓到的 URL 里包含大量已经失效的链接,扫描器没有做状态码过滤。

解决:在爬虫和检测模块里统一注入认证信息,比如从浏览器导出的 Cookie 或 Token。同时在检测前加一步预检,对每个入口发一次基线请求,状态码不是 200 或 302 的直接跳过。这一步能砍掉 60% 以上的无效检测。

4.2 误报率高到没法看

现象:报告里 SQL 注入、XSS 一大堆,人工验证发现全是误报。

原因:没有做基线对比,或者基线对比的阈值设得太松。比如布尔盲注只看响应长度差异,但目标站本身就有随机广告位,每次响应长度都不一样。

解决:基线请求至少发三次,取响应长度的中位数和波动范围。布尔盲注的判定条件改成「真条件响应长度在基线波动范围内,假条件响应长度超出波动范围」。另外,报错注入的关键词匹配要加白名单,有些站点正常响应里就包含SQL这个词。

4.3 扫描到一半目标站挂了

现象:扫描器跑了几分钟,目标站响应变慢甚至 502,你的 IP 也被封了。

原因:并发太高,或者爬虫陷入了无限循环(比如日历页面,每天一个链接,无限翻页)。

解决:给爬虫设深度上限和总量上限,给检测模块设 QPS 上限。我一般用令牌桶做限速,每个域名单独一个桶。另外,对 URL 里包含日期、页码参数的入口,要特别小心,很容易无限扩展。可以设一个规则:同一路径下参数值连续递增超过 20 个,就停止入队。

4.4 动态渲染页面漏抓严重

现象:目标站是 Vue 或 React 写的,爬虫只抓到了首页和几个静态页,接口一个没抓到。

原因:没有触发前端渲染,或者渲染后没有等待异步请求完成。

解决:对命中RENDER_HINTS的页面启用无头浏览器,并且等待网络空闲(Playwright 的wait_for_load_state('networkidle')比time.sleep靠谱)。另外,可以从浏览器的 performance log 里直接提取 XHR 请求,这比从 DOM 里解析更全。

4.5 PoC 之间互相干扰

现象:单独跑 SQL 注入 PoC 能出结果,和 XSS PoC 一起跑就什么都出不来。

原因:多个 PoC 共享同一个 session,前一个 PoC 修改了 session 的状态(比如登录态失效、CSRF Token 被消耗),导致后一个 PoC 请求异常。

解决:每个 PoC 用独立的 session,或者至少在 PoC 执行前后做状态重置。如果目标站有 CSRF Token,每次请求前都要重新从页面里提取。这个坑我在早期写扫描器时踩过,排查了一整天才发现是 Token 被前一个 PoC 用掉了。

5. 进阶技巧:用响应相似度做误报压制与扫描结果验证

5.1 响应相似度比长度对比更可靠

前面提到的基线对比,如果只比长度,在动态页面上基本没用。我后来换了一个思路:用响应正文的 SimHash 或简单的词频向量做相似度计算。具体做法是,对基线响应和探测响应分别做分词,去掉 HTML 标签和停用词,然后算余弦相似度。相似度高于 0.95 认为响应没变化,低于 0.8 认为有显著变化。

import re from collections import Counter import math def tokenize(html): text = re.sub(r'<[^>]+>', ' ', html) words = re.findall(r'\w+', text.lower()) return Counter(words) def cosine_sim(c1, c2): intersection = set(c1.keys()) & set(c2.keys()) numerator = sum(c1[w] * c2[w] for w in intersection) denominator = math.sqrt(sum(v**2 for v in c1.values())) * math.sqrt(sum(v**2 for v in c2.values())) return numerator / denominator if denominator else 0

tokenize先把 HTML 标签去掉,再提取单词并转小写,用 Counter 做词频统计。cosine_sim就是标准的余弦相似度。实际用的时候,我会对基线响应和探测响应各算一次,相似度低于阈值才认为探测生效。这个方法比长度对比稳得多,尤其是在有随机广告位或时间戳的页面上。

5.2 用差异定位具体漏洞参数

当一个页面有多个参数时,你发一个 payload 导致响应变化,但不确定是哪个参数起的作用。我的做法是逐个参数做差分:固定其他参数不变,只改一个参数,观察响应相似度变化。变化最大的那个参数就是嫌疑参数。这个思路在布尔盲注和 XSS 检测里都适用。

5.3 扫描结果的人工验证清单

自动扫描器出的报告,我一般按这个清单过一遍:第一,看 evidence 字段,如果 evidence 里只有状态码没有具体响应片段,大概率是误报;第二,对 SQL 注入,手动发一次AND 1=1和AND 1=2,看响应差异是否可复现;第三,对 XSS,把 payload 换成<img src=x onerror=alert(1)>,看浏览器是否弹窗;第四,对命令注入,用sleep 5看响应时间是否真的延迟了 5 秒。

这套流程跑下来,误报能压到 10% 以内。我自己的习惯是,扫描器只负责「发现可疑点」,最终确认必须人工做一遍。没有哪个扫描器能完全替代人工验证,但一个好的扫描器能把人工验证的工作量从「翻遍整个站点」降到「只看几十条可疑记录」。希望帮到你。

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

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

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

立即咨询