背景痛点:毕设场景下的“祖传”扫描器为何难用
做毕设选 Web 漏洞扫描器,听起来高大上,真动手才发现传统开源工具(w3af、OpenVAS、Nikto 等)在本科阶段几乎跑不通:
- 规则库动辄上万条,Git LFS 拉取就掉线,笔记本硬盘直接告急。
- 误报高到怀疑人生:一个「X-Powered-By」头没擦,直接报 20 条「信息泄露」;答辩时老师一句「你确定这是漏洞?」就卡壳。
- 插件语言百花齐放(C++、Lua、Python2.7),毕设周期只有 3 个月,光配环境就过去一半时间。
- 最致命:规则=硬编码,一旦目标站把登录口从
/login.php改成/sign/in,扫描器直接失明,而本科毕设不可能让你写几千条正则去“兜底”。
于是把希望寄托在「AI 辅助」——让大模型做苦力,我负责架构与验证,这才有了下面的实战记录。
技术选型对比:规则引擎 vs. LLM 辅助决策
| 维度 | 传统规则引擎 | LLM 辅助决策 |
|---|---|---|
| 维护成本 | 新增漏洞需人工写正则、插件,周期长 | 改 prompt 即可,分钟级迭代 |
| 误报率 | 高,依赖白名单过滤 | 中等,可要求模型“给出理由+置信度” |
| 漏报率 | 高,规则未覆盖就跳过 | 低,模型可泛化到未见过的模式 |
| 执行性能 | 毫秒级 | 秒级(GPU 预热甚至 10s+) |
| 离线场景 | 完全可离线 | 7B 模型可 CPU 跑,但需 6G+ 内存 |
| 可解释性 | 正则即证据,老师看得懂 | 自然语言推理,需额外落库留痕 |
结论:毕设不是商业项目,「能跑+能改+能讲清楚」优于「极致性能」。因此采用「混合架构」:
- 用规则做「高频低危」快速过滤(如 robots.txt 泄露)
- 用 LLM 做「低频高危」研判(如存储型 XSS、SQL 注入的语义确认)
核心实现:Python + Playwright 动态爬虫 × 开源 LLM
系统架构图
1. 动态爬虫模块(crawler.py)
职责:渲染 SPA、自动填表、维护 Cookie 池,输出「请求-响应」对给下游。
关键实现点:
- 每个页面最大深度 3,防止无限递归
- 相同 URL 仅爬一次,幂等靠
page.url + postData 的哈希 - 全局请求拦截器统一打时间戳,方便后续限速
import asyncio, hashlib, json from playwright.async_api import async_playwright class DeepCrawler: def __init__(self, max_depth=3, max_pages=200): self.visited = set() self.max_depth = max_depth self.max_pages = max_pages async def crawl(self, entry: str): async with async_playwright() as p: browser = await p.chromium.launch(headless=True) page = await browser.new_page() await self._dfs(page, entry, depth=0) await browser.close() async def _dfs(self, page, url, depth): if depth > self.max_depth or len(self.visited) >= self.max_pages: return key = self._url_key(url, await page.content()) if key in self.visited: return self.visited.add(key) # 真正发出请求 await page.goto(url, wait_until='networkidle') # 收集表单 forms = await page.locator('form').element_handles() for form in forms: await self._handle_form(page, form, depth) def _url_key(self, url, body): return hashlib.sha256(f"{url}{body[:500]}".encode()).hexdigest()幂等性说明:_url_key把 URL 与响应前 500 字节一起做摘要,确保重复跳转不会二次入库。
2. LLM 客户端(llm_gate.py)
采用 Llama.cpp 7B-chat 量化版,本地 CPU 可跑,延迟 2~3 秒。
职责:
- payload 生成(给定参数名、类型,让模型「脑补」攻击向量)
- 响应研判(把响应体切片喂给模型,要求输出「是否漏洞+理由+置信度 0-1」)
from llama_cpp import Llama class LlamaGate: def __init__(self, model_path: str, n_ctx=4096): self.llm = Llama(model_path=model_path, n_ctx=n_ctx, logits_all=False) def prompt_payload(self, param: str, context: str) -> list[str]: tpl = f"""Below is a web form parameter "{param}" in context: {context} List 5 classic payloads for testing XSS or SQLi. No explanation.""" output = self.llm(tpl, max_tokens=200, temperature=0.8) return [p for p in output['choices'][0]['text'].split('\n') if p] def judge(self, payload: str, response_snippet: str) -> tuple[bool, str, float]: tpl = f"""Payload: {payload} Response snippet: {response_snippet} Is the above response indicating a successful XSS or SQLi? Answer JSON only: {{"vuln": boolean, "reason": "...", "confidence": 0.0-1.0}}""" out = self.llm(tpl, max_tokens=120, temperature=0.1) try: obj = json.loads(out['choices'][0]['text']) return obj['vuln'], obj['reason'], obj['confidence'] except Exception as e: # 模型偶尔输出非 JSON,保守返回 return False, f"parse error: {e}", 0.0错误处理:解析失败即视为无漏洞,宁可漏报也不误报,符合毕设「演示优先」原则。
3. 扫描调度器(scanner.py)
把上面两个模块串起来,并加入速率限制、敏感字段脱敏。
import asyncio, re, time from crawler import DeepCrawler from llm_gate import LlamaGate class AIDrivenScanner: def __init__(self, llm: LlamaGate, rps=5): self.llm = llm self.rps = rps self.last_req = 0 async def scan(self, target: str): crawler = DeepCrawler() await crawler.crawl(target) for req_resp in crawler.collected: await self._test_with_ai(req_resp) async def _test_with_ai(self, rr: dict): # 速率控制 gap = 1 / self.rps await asyncio.sleep(max(0, gap - (time.time() - self.last_req))) self.last_req = time.time() # 脱敏:把手机号、邮箱替换成占位符 body = self._desensitize(rr['response_body']) for param in rr['params']: payloads = self.llm.prompt_payload(param, rr['context']) for p in payloads: vuln, reason, conf = self.llm.judge(p, body[:1500]) if vuln and conf > 0.7: print(f"[VULN] param={param} payload={p} reason={reason}") def _desensitize(self, text: str) -> str: text = re.sub(r'\b1[3-9]\d{9}\b', '<PHONE>', text) text = re.sub(r'\b[\w.-]+@[\w.-]+\.\w+\b', '<EMAIL>', text) return textClean Code 要点:
- 函数不超过 30 行,一眼看完
- 幂等:多次调用
_test_with_ai不会重复插入漏洞(去重 key 由 crawler 保证) - 错误隔离:LLM 抛异常只丢单条 payload,主流程继续
安全性与性能考量
- 请求频率:默认 RPS=5,可在路由器层再套令牌桶,防止把靶站打挂。
- 敏感信息脱敏:日志、prompt 里一旦出现身份证、手机号直接掩码,防止「扫描器本身成为泄露源」。
- 模型冷启动优化:Llama.cpp 支持
mmap,首次加载 3.8 GB 模型需 8-10 秒;在毕设演示前先把进程常驻,用unix socket做守护,避免老师点按钮后空等。 - 内存控制:7B 模型在 0.5 量化后约 3.8 GB,再留 1 GB 给 Playwright,8 GB 笔记本刚好跑得动;若目标站过大,可关浏览器多进程
--single-process省 30% 内存。 - 超时与重试:Playwright 默认 30 秒网络空闲,遇到大文件下载会卡;在
page.goto加timeout=10 000并捕获TimeoutError,重试一次即可。
生产环境避坑指南(别问为什么强调,踩过)
- 过度依赖 AI 导致漏报:LLM 对「时间盲注」这类延迟响应无感,务必保留传统
sleep()检测链。 - 输出不可控:模型可能返回「我认为这是漏洞」但置信度 0.51,答辩时被老师追问「依据呢?」——务必把
reason落库,并展示原始响应片段,人工复核。 - 法律红线:扫描公网需授权,毕设靶场建议用 Vulhub 或自建 Docker 镜像;日志里若记录真实学生手机号,属于「个人信息处理」,需做匿名化。
- GPU 云主机费用:AutoDL 按量 1.4 元/小时,演示 10 分钟花 2 元,可接受;但切记关机,否则通宵跑 30 块就没了。
可改进方向 & 留给读者的思考题
- payload 生成策略目前靠「无脑枚举」,能否让 LLM 先读 OpenAPI 文档,再针对性生成符合字段语义的攻击向量?
- 如何把「自动化」与「人工验证」量化平衡?例如设定业务阈值:置信度>0.9 自动报 bug,0.7-0.9 发企业微信待人工点确认,<0.7 直接丢弃。
- 响应体过大时,先让模型读摘要(html2text 后 2 000 字符),再逐步放大窗口,能否在保持精度的同时降低延迟?
动手改一改,你会发现毕设不止「能跑」,还能「讲出故事」。祝你答辩顺利,也欢迎把改进后的 prompt 和代码回馈到社区,一起把 AI 辅助安全做得更靠谱。