ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

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

爬虫驱动的Web漏洞扫描器:从URL到漏洞报告的一条龙思路 简介这是一份面向网络安全初学者与渗透测试爱好者的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?id1和/page?id1在扫描器眼里应该是同一个入口否则队列会爆炸。下面是一个最小可用的爬虫骨架用 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_valuesTrue) sorted_query urlencode(sorted(query.items()), doseqTrue) 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_depth3, max_urls500): 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, timeout8, 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_depth3和max_urls500是我在中小型站点上比较常用的默认值再大就要考虑分布式了。2.2 动态渲染页面要不要上 Selenium这是被问得最多的问题。我的判断标准很简单如果目标站点的关键入口比如商品详情、用户中心是前端框架渲染出来的requests 拿到的 HTML 里只有div idapp/div那你就必须上无头浏览器。但无头浏览器不是银弹它慢、吃内存、容易被检测。常见做法是混合模式先用 requests 快速爬一遍静态链接把能拿到的入口全部入队对返回内容里包含__NUXT__、window.__INITIAL_STATE__这类前端状态标记的页面再丢给 Selenium 或 Playwright 做二次渲染。下面是一个按需触发渲染的判断逻辑RENDER_HINTS [__NUXT__, window.__INITIAL_STATE__, idapp, idroot] 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_sourceRENDER_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 findingsBasePoc定义了统一接口check返回 None 表示无漏洞返回 dict 表示命中。ScanEngine.run里对每个 PoC 做了异常隔离这一点很重要——某个 PoC 因为目标站返回异常格式而抛错不应该让整个扫描中断。findings里保留了evidence字段后面生成报告时这是判断误报的关键依据。3.3 SQL 注入与 XSS 的检测逻辑差异SQL 注入检测的核心是「布尔盲注 报错注入」组合。布尔盲注发两组请求一组让条件为真一组让条件为假对比响应长度和状态码。报错注入则是在参数里塞入会触发数据库报错的 payload看响应里有没有数据库错误关键词。XSS 检测相对简单反射型 XSS 就是看 payload 是否原样出现在响应里但要注意 HTML 实体编码的情况。下面是一个布尔盲注的检测片段import re SQL_ERROR_PATTERNS [ rSQL syntax.*MySQL, rWarning.*mysql_, rORA-\d{5}, rPostgreSQL.*ERROR, rMicrosoft 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, paramstest_params, timeout8) 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 11 false_params params.copy() false_params[param] params[param] AND 12 r_true session.get(url, paramstrue_params, timeout8) r_false session.get(url, paramsfalse_params, timeout8) if abs(len(r_true.text) - len(r_false.text)) 50 and len(r_true.text) ! len(baseline): return {param: param, evidence: flen_true{len(r_true.text)}, len_false{len(r_false.text)}} return NoneSQL_ERROR_PATTERNS覆盖了 MySQL、Oracle、PostgreSQL、SQL Server 的常见报错特征。报错注入优先于布尔盲注因为报错注入的误报率更低。布尔盲注里的50是响应长度差异阈值这个值要根据目标站实际情况调太小会误报太大会漏报。baseline对比是为了排除那些本身响应长度就波动的页面。XSS 检测我一般只做反射型存储型需要人工确认。反射型检测就是发一个带唯一标记的 payload比如scriptalert(xss_test_123)/script然后在响应里搜xss_test_123是否出现且没有被编码。如果出现的是lt;scriptgt;那说明被编码了不算漏洞。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 0tokenize先把 HTML 标签去掉再提取单词并转小写用 Counter 做词频统计。cosine_sim就是标准的余弦相似度。实际用的时候我会对基线响应和探测响应各算一次相似度低于阈值才认为探测生效。这个方法比长度对比稳得多尤其是在有随机广告位或时间戳的页面上。5.2 用差异定位具体漏洞参数当一个页面有多个参数时你发一个 payload 导致响应变化但不确定是哪个参数起的作用。我的做法是逐个参数做差分固定其他参数不变只改一个参数观察响应相似度变化。变化最大的那个参数就是嫌疑参数。这个思路在布尔盲注和 XSS 检测里都适用。5.3 扫描结果的人工验证清单自动扫描器出的报告我一般按这个清单过一遍第一看 evidence 字段如果 evidence 里只有状态码没有具体响应片段大概率是误报第二对 SQL 注入手动发一次AND 11和AND 12看响应差异是否可复现第三对 XSS把 payload 换成img srcx onerroralert(1)看浏览器是否弹窗第四对命令注入用sleep 5看响应时间是否真的延迟了 5 秒。这套流程跑下来误报能压到 10% 以内。我自己的习惯是扫描器只负责「发现可疑点」最终确认必须人工做一遍。没有哪个扫描器能完全替代人工验证但一个好的扫描器能把人工验证的工作量从「翻遍整个站点」降到「只看几十条可疑记录」。希望帮到你。本文还有配套的精品资源点击获取
返回列表