AI 辅助实现 XSS 漏洞扫描系统:毕业设计实战与安全加固
一、为什么正则救不了 XSS
做 Web 安全作业时,我最早写的扫描器只有 200 行正则:把常见<script>、javascript:等关键词一股脑丢进规则文件,跑靶场时却惨不忍睹——
- 换行、Unicode 编码、HTML 实体嵌套,正则直接失效
- 业务接口返回 JSON,误把
"<script>"字符串当漏洞报,结果 90% 都是噪音 - 新增一条绕过 payload 就要改一次规则,维护成本直线上升
一句话:正则只能“匹配形状”,理解不了上下文。浏览器解析 HTML 的容错逻辑、JS 字符串转义规则、模板引擎的转义策略,这些动态行为靠静态规则根本追不上。
二、规则引擎 vs. 轻量 ML:谁更适合本科生
毕业设计时间只有 12 周,我给自己定了两条硬指标:
- 模型训练 < 30 min,CPU 笔记本能跑
- 代码量 < 2 k 行,方便导师看懂
于是把可选方案压缩成两类:
| 方案 | 优点 | 缺点 | 结论 |
|---|---|---|---|
| 规则引擎(w3af、XSStrike 核心) | 0 训练成本,召回快 | 误报高、需人工补规则 | 作为 baseline |
| 深度学习(LSTM/BERT) | 能抓复杂变形 | 需要 GPU、样本大、解释性差 | 放弃 |
| 轻量 ML(TF-IDF + 逻辑回归) | 训练快、可解释、可增量 | 对语序敏感、需特征工程 | 采用 |
最终组合:规则做召回,ML 做排序。先用正则“广撒网”把疑似点捞出来,再用模型给每条 payload 打分,过滤掉明显误报。训练数据直接拿 PortSwigger 公开靶场 + 开源 XSS 仓库,正负样本各 6 k 条,30 min 收敛,F1 0.94,足够毕业答辩。
三、核心实现拆解
代码仓库结构如下,单文件即可跑通,全部模块 350 行左右,下面按流程拆开讲。
xss-ai-scan/ ├─ scanner.py # 调度器 ├─ crawler.py # 广度优先爬链接 ├─ payload_gen.py # AI 辅助生成 ├─ detector.py # 上下文污点追踪 ├─ model/ │ ├─ tfidf.pkl │ ├─ logistic.pkl └─ utils/ ├─ html_parser.py └─ requester.py1. 爬虫:只抓“带参数”的入口
很多教程一上来就全站递归,结果扫描出几千个静态页面,浪费时间。我的策略是只保留能回显用户输入的入口:
- 解析表单,提取
input/textarea/select的name和默认值 - 把 JSON 接口的
GET/POST参数也当成潜在输入槽 - 用布隆过滤器去重,避免在分页循环里打转
2. 请求解析:把响应拆成“上下文块”
回显可能出现在:
- HTML 标签之间
- 属性值
- JavaScript 字符串
- JSON 键值
用html5lib把响应解析成 DOM,再按以下顺序切片:
- 文本节点
- 属性节点
<script>内联块- 事件处理器属性(
onerror/onclick等)
每个切片生成三元组(context_type, prefix, suffix),后面污点追踪全靠它。
3. 污点追踪:知道“输入从哪来、到哪去”
把用户可控的参数标记为taint,向后传播:
- URL 参数、POST body、JSON 字段全部打标签
- 在服务器端若被拼进 SQL、命令、模板,就继续传递标签(静态分析模拟)
- 最终看响应里哪些切片含 taint,且未经过转义函数(
htmlspecialchars/escapeJs等)
这一步用正则也能做,但容易漏掉多重编码。我的做法是模拟后端转义函数,把常见转义表跑一遍,如果 taint 被完整包裹在引号、反斜线或实体里,就摘掉标记。
4. AI 评分:给每条可疑 payload 打风险分
特征工程很朴素,共 8 维:
- 是否包含
<或javascript: - 是否出现事件处理器关键词
- 上下文类型(文本/属性/JS)
- 左右引号是否闭合
- 长度与熵值
- 是否全角编码
- 是否出现 DOM 操作函数
- 模型预测概率(TF-IDF + 逻辑回归)
逻辑回归输出 0–1 概率,> 0.7 判高危,0.4–0.7 中危,其余忽略。经 200 个手工样本验证,误报率从 28% 降到 7%,基本能看。
5. 关键代码片段
以下代码去掉异常处理与日志,保留主干,可直接跑通 Python 3.9+。注意遵循 Clean Code:函数 < 20 行、单一职责、显式命名。
# payload_gen.py import random, re from sklearn.feature_extraction.text import TfidfVectorizer from sklearn.linear_model import LogisticRegression import joblib class PayloadGenerator: def __init__(self, model_path='model/logistic.pkl', vectorizer_path='model/tfidf.pkl'): self.clf = joblib.load(model_path) self.vec = joblib.load(vectorizer_path) self.base_payloads = [ "'><script>alert('x')</script>", "javascript:alert(1)", "\"><img src=x onerror=alert(1)>", "';alert(1);//", ] def mutate(self, payload): """简单变异:大小写、注释、换行""" tricks = [ lambda s: s.replace("script", "script"), lambda s: s.replace("(", "(\x00"), lambda s: s.upper(), ] return random.choice(tricks)(payload) def score(self, payload): feat = self.vec.transform([payload]) return self.clf.predict_proba(feat)[0, 1] def generate(self, context): """context: {'type': 'text'|'attr'|'js', 'prefix': '', 'suffix': ''}""" candidates = [self.mutate(p) for p in self.base_payloads] candidates += [context['prefix'] + p + context['suffix'] for p in candidates] scored = [(p, self.score(p)) for p in candidates] return sorted(scored, key=lambda x: x[1], reverse=True)[:3]# detector.py from bs4 import BeautifulSoup import re class Detector: def __init__(self, payload_gen): self.payload_gen = payload_gen def _extract_slices(self, html): soup = BeautifulSoup(html, 'html5lib') slices = [] for node in soup.find_all(text=True): parent = node.parent.name if node.parent else '' slices.append({'type': 'text', 'content': node, 'parent': parent}) for tag in soup.find_all(True): for attr, val in tag.attrs.items(): if isinstance(val, str): slices.append({'type': 'attr', 'content': val, 'attr': attr}) return slices def _is_tainted(self, content, param_value): return param_value in content def _is_escaped(self, content, taint): escaped = [ taint.replace("<", "<").replace(">", ">"), taint.replace("\"", "\\\""), taint.replace("'", "\\'"), ] return any(e in content for e in escaped) def check(self, html, param_value): slices = self._extract_slices(html) issues = [] for s in slices: if not self._is_tainted(s['content'], param_value): continue if self._is_escaped(s['content'], param_value): continue context = { 'type': s['type'], 'prefix': s['content'].split(param_value)[0][-20:], 'suffix': s['content'].split(param_value)[1][:20], } for p, score in self.payload_gen.generate(context): issues.append({'payload': p, 'score': score, 'context': context}) return issues调度器部分用asyncio限制并发 20,防止把靶站打挂;同时加随机 UA、延迟 0.2–1 s,低调做人。
四、安全性与性能:别让扫描器自己成漏洞
避免自注入
所有前端展示用flask.escape,日志写文件时把 payload 先做 base64,防止双击 HTML 报告触发 XSS。拒绝服务控制
- 单域名并发连接 ≤ 20,总 QPS ≤ 50
- 大文件响应(> 2 MB)直接截断,防止内存爆炸
- 使用
aiohttp超时 8 s,卡死直接丢弃
冷启动优化
模型文件 1.3 MB,在__init__.py里用functools.lru_cache预加载,实测第一次扫描从 3 s 降到 0.4 s。
五、生产环境踩坑笔记
CSP 绕过漏报
有些站开启script-src 'self',前端用 JSONP 回调拼脚本,静态检测看不到实际执行。解决:把callback参数值也当 JS 上下文,触发模型打分。动态 DOM 盲区
Vue/React 先把内容塞进v-html,扫描器拿到的初始 HTML 是空 div。解决:用pyppeteer无头浏览器拍快照,把渲染后 HTML 再喂给 detector,虽然慢,但能把误漏降低 4 个百分点。双重编码
%2526lt%253B这种两层 URL 编码,正则基本抓瞎。解决:在污点匹配前先unquote_plus两次,再判断。WAF 拦截
扫描一跑就被 403。解决:把 User-Agent 池化,每次随机挑;对 403 响应自动降速,指数退避。
六、可扩展:把思路搬到 CSRF 与 SQLi
整个框架本质是“入口采集 → 污点传播 → 上下文切片 → 模型打分”,只要换特征、换 payload 就能迁移:
CSRF:
特征换成“是否带 token”“Referer 是否同源”,模型一样用逻辑回归,payload 是伪造表单自动提交。SQLi:
上下文切片改成 SQL 词法,特征用“单引号数目/关键字密度/注释符号”,训练数据换 DWVA 公开 SQL 注入样本,检出率也能看。
毕业设计答辩时,老师问“后续怎么深化?”我就把这张迁移表甩上去,直接通关。
七、小结与动手邀请
正则救不了 Web 安全,轻量 ML 是课业与工程之间的甜蜜点。本文把 12 周踩过的坑浓缩成一条可复制流水线:爬虫 → 污点追踪 → AI 评分 → 报告输出,完整代码已推 GitHub(搜索xss-ai-scan)。欢迎 fork 回去魔改,比如换成 XGBoost、加多语言支持、或者把 Vue 前端集成进来。下一步,你会先挑战 CSRF 自动检测,还是直接上 SQL 注入?代码仓库的 issue 区等你晒 PR。