拓十年匠心定制 · 商业建站与技术教学双线并行 咨询热线:400-886-1026 service@lmnt.cn
ARTICLE DETAIL

资讯详情

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

Python轻量级XSS漏洞检测脚本:从反射型到DOM型的实战设计

Python轻量级XSS漏洞检测脚本:从反射型到DOM型的实战设计 简介这是一款基于Python开发的XSS漏洞检测脚本源码面向安全测试人员、渗透测试初学者及Web安全研究者用于快速识别网页中的跨站脚本注入风险。脚本通过构造多种XSS攻击载荷并监控服务器响应与页面行为变化判断目标是否存在漏洞适合在授权测试场景中做初步安全排查。资源包共87个文件、约3.19MB以37个py源文件与33个pyc编译文件为核心另有txt词表与说明、bat与sh运行脚本、readme文档及少量依赖压缩包目录中可见brutexss、mechanize、beautifulsoup等模块痕迹便于理解检测逻辑与请求解析流程。目前已有366人学习下载。对于希望掌握XSS检测原理的读者可从中获取完整的脚本实现、载荷词表、跨平台运行方式与模块组织思路既能直接用于安全测试练习也可作为二次开发与防御验证的参考基础。1. 从一次被反射型 XSS 打穿的登录页说起去年帮一个朋友看他那套刚上线的后台登录页有个?redirect参数前端直接把它塞进location.href。我随手构造了一个带javascript:伪协议的链接发过去页面当场执行。他一脸懵这也能算漏洞这就是 XSS 最容易被低估的地方——它不挑系统只挑开发者有没有把用户输入当数据看。这篇要聊的是用 Python 写一个简单高效的 XSS 漏洞检测脚本。注意关键词是「简单高效」和「脚本设计」不是让你造一个替代 Burp、AWVS 的扫描器而是做一个能塞进 CI、能批量跑、能自己改 payload 的轻量工具。适合两类人一类是做 Web 安全测试、想有个可控检测器的从业者另一类是写 Python 爬虫顺手想加一层安全校验的开发者。读完你能拿到一套可复现的检测思路、一份能直接跑的源码骨架以及几个我踩过的坑。XSS 检测这件事工具不是越重越好很多时候一个几百行的脚本比装一个几 G 的扫描器更贴合你的场景。2. XSS 检测脚本到底在检测什么反射、存储与 DOM 的判定边界2.1 三种 XSS 在脚本视角下的差异写检测脚本之前得先想清楚你要检测的是哪一类。XSS 分反射型、存储型、DOM 型它们在脚本里的检测逻辑完全不同混在一起写只会让代码变成一锅粥。反射型 XSS 的特征是你发出去的 payload会在同一个响应里原样或变形后出现。检测逻辑就是「发请求 → 收响应 → 在响应体里找 payload 的痕迹」。这是最容易自动化的一类也是脚本的主战场。存储型 XSS 的特征是payload 先被存进数据库之后在别的页面被读出来。脚本没法直接看到数据库只能通过「提交 → 再访问列表/详情页 → 检查是否出现」这个两步流程来间接判断。难点在于你不知道数据什么时候、在哪个页面被渲染所以存储型检测往往需要人工指定「回显页面」。DOM 型 XSS 更麻烦payload 根本不经过服务端是前端 JS 自己把location.hash、document.referrer这类源塞进了innerHTML或eval。服务端响应里看不到 payload传统「比对响应体」的方法直接失效。脚本层面只能做静态分析——扫 JS 代码里有没有危险的 sink 函数或者用无头浏览器跑一遍看有没有弹窗。我一般会先明确脚本的定位优先做反射型存储型做半自动DOM 型做静态提示。想一个脚本通吃三类最后往往三类都做不深。2.2 检测的核心判定回显、编码与上下文很多人以为「响应里出现了 payload」就是有漏洞这是最大的误区。真正的判定要看三件事payload 是否回显、回显时是否被编码、回显在什么上下文里。举个例子你发scriptalert(1)/script响应里出现lt;scriptgt;alert(1)lt;/scriptgt;这是被 HTML 实体编码了不构成漏洞。如果出现的是原样的scriptalert(1)/script那基本可以确认。但如果它出现在input value...的属性里script标签反而不触发得换成 onmouseoveralert(1) x这种闭合属性的 payload。所以一个靠谱的检测脚本判定逻辑不能只看「字符串是否出现」还要看特殊字符 有没有被转义payload 落在 HTML 正文、属性、script 块还是 URL 里有没有被 WAF 拦截返回 403 或空响应下面这段是我常用的「回显 编码」判定核心import re def analyze_reflection(response_text, payload): 判断 payload 在响应中的回显状态 返回: (是否原样回显, 上下文类型) if payload not in response_text: return False, none # 找到 payload 出现的位置取前后文判断上下文 idx response_text.find(payload) context response_text[max(0, idx - 50): idx len(payload) 50] # 判断是否被 HTML 实体编码编码后 payload 不会原样出现这里做二次确认 encoded payload.replace(, lt;).replace(, gt;) if encoded in response_text and payload not in response_text: return False, encoded # 判断上下文 if re.search(rscript[^]*[^]* re.escape(payload), context): return True, script if re.search(r[^]\s\w\s*\s*[\][^\]* re.escape(payload), context): return True, attribute return True, html这段代码的逻辑是先确认 payload 是否出现再排除被实体编码的情况最后用正则判断它落在 script 块、属性还是 HTML 正文里。context取前后 50 个字符是为了给正则足够的匹配空间。参数上payload必须和实际发送的一致如果你发送时做了 URL 编码这里要先解码再比对否则永远匹配不上。提示上下文判断直接决定你下一步用什么 payload。落在属性里就补 onmouseover...落在 script 块里就试/scriptscript...别拿着一个 payload 打天下。2.3 为什么选 Python 而不是现成扫描器现成扫描器AWVS、Xray 这类能力确实强但在几个场景下不如自己写脚本第一可控性。扫描器的 payload 是内置的你想加一个针对自家业务的绕过 payload得等它更新或者写插件。自己写脚本payload 就是一个列表想加就加。第二可集成。CI 流水线里跑一个几百行的 Python 脚本比部署一个扫描器服务轻太多。python xss_scan.py --url xxx一行命令就能接进 Jenkins 或 GitLab CI。第三可理解。扫描器报一个漏洞你未必清楚它怎么判定的。自己写的脚本每一行判定逻辑你都门儿清误报漏报都能自己调。代价是你得自己处理爬虫、参数提取、并发、去重这些脏活。所以「简单高效」的定位很重要——不要试图做全站爬取先支持「给定 URL 和参数」这一种输入方式把检测逻辑打磨好再考虑扩展。3. 用 requests BeautifulSoup 搭出可复现的检测骨架3.1 环境准备与依赖选择先把环境搭起来。Python 版本建议 3.8 以上依赖就三个requests发请求beautifulsoup4解析 HTML 提取表单和链接urllib.parse处理 URL 参数标准库自带不用装。pip install requests beautifulsoup4如果你要检测 DOM 型再加一个playwright但那是进阶部分先不引入。这里刻意不装selenium因为它要配浏览器驱动对「简单」这个定位来说是负担。选requests而不是httpx或aiohttp是因为它的同步模型写起来最直观调试也方便。检测脚本的瓶颈通常在目标站点的响应速度不在客户端并发所以同步够用。真要提速用concurrent.futures开线程池就行不必上异步。3.2 提取可测参数从 URL 和表单入手检测的第一步是找到「可以注入的地方」。最常见的是 URL 查询参数和 HTML 表单。下面这段负责把这两类输入点提取出来from urllib.parse import urlparse, parse_qs, urlencode, urlunparse from bs4 import BeautifulSoup def extract_url_params(url): 提取 URL 中的查询参数返回 {参数名: 原值} 字典 parsed urlparse(url) params parse_qs(parsed.query) # parse_qs 返回的是列表取第一个值即可 return {k: v[0] for k, v in params.items()} def extract_forms(html, base_url): 提取页面中所有表单的 action、method 和输入字段 soup BeautifulSoup(html, html.parser) forms [] for form in soup.find_all(form): action form.get(action, ) method form.get(method, get).lower() # 拼接完整 action URL if action.startswith(http): full_action action else: full_action base_url.rstrip(/) / action.lstrip(/) fields {} for inp in form.find_all([input, textarea]): name inp.get(name) if name: fields[name] inp.get(value, ) forms.append({action: full_action, method: method, fields: fields}) return formsextract_url_params用parse_qs把?a1b2拆成字典注意它默认返回列表因为同名参数可能多个这里取第一个值简化处理。extract_forms遍历所有form把 action 拼成完整 URL再收集每个 input 的 name 和默认 value。参数说明base_url要传当前页面的完整地址否则相对路径的 action 拼不出来。拿到这些输入点后检测逻辑就是对每个参数把原值替换成 payload重新构造请求发出去看响应。3.3 构造请求与注入 payload构造请求时有个细节GET 参数要重新编码POST 表单要用data提交。下面这段把「替换参数值 → 发请求 → 拿响应」封装成一个函数import requests def inject_and_request(url, param, payload, methodget, form_dataNone): 把指定参数替换为 payload 并发送请求 method: get 或 post form_data: POST 时的完整表单数据 headers { User-Agent: Mozilla/5.0 (XSS-Scanner-Test), Accept: text/html,application/xhtmlxml, } try: if method get: parsed urlparse(url) params parse_qs(parsed.query) params[param] [payload] # 替换目标参数 new_query urlencode(params, doseqTrue) new_url urlunparse(parsed._replace(querynew_query)) resp requests.get(new_url, headersheaders, timeout10, verifyFalse) else: data dict(form_data or {}) data[param] payload resp requests.post(url, datadata, headersheaders, timeout10, verifyFalse) return resp except requests.RequestException as e: print(f[!] 请求失败: {e}) return None关键点GET 用urlencode(params, doseqTrue)重新编码doseqTrue保证列表值被正确展开。POST 直接改data字典。timeout10防止卡死verifyFalse是为了测自签名证书的内网站点——但生产环境别关证书校验这里只是检测场景的妥协。注意verifyFalse会带来中间人风险仅限内网或测试环境使用。对外部站点检测时把它去掉。3.4 判定回显并输出结果把前面的判定函数接上主流程就成型了PAYLOADS [ scriptalert(1)/script, \ onmouseoveralert(1) x\, img srcx onerroralert(1), javascript:alert(1), ] def scan_url(url): 扫描单个 URL 的所有查询参数 params extract_url_params(url) findings [] for param in params: for payload in PAYLOADS: resp inject_and_request(url, param, payload, methodget) if resp is None: continue reflected, context analyze_reflection(resp.text, payload) if reflected: findings.append({ url: url, param: param, payload: payload, context: context, }) print(f[] 疑似 XSS: {param} | 上下文: {context} | payload: {payload}) break # 一个参数命中就不再试其他 payload return findingsPAYLOADS列表覆盖了 HTML 正文、属性闭合、img onerror、伪协议四种典型场景。break的作用是一个参数只要有一个 payload 命中就不再浪费请求试其他的这是「高效」的体现。analyze_reflection返回的context帮你判断下一步该用哪种 payload 深入验证。这套骨架跑起来对反射型 XSS 的检出率已经不错。但它有明显边界不处理 JS 动态渲染、不处理需要登录的页面、不处理 CSRF token。这些在下一章展开。4. 避坑与排查五个让脚本误报漏报的血泪经验4.1 现象明明有漏洞却报「未发现」原因目标页面需要登录脚本没带 Cookie请求被重定向到登录页响应里自然没有 payload 回显。解决加一个--cookie参数把登录后的 Cookie 传进来。或者用requests.Session()先模拟登录把 session 保持住。我一般会留一个session.headers.update({Cookie: cookie_str})的入口手动贴 Cookie 最省事。4.2 现象报了一堆漏洞人工一看全是误报原因判定逻辑只看「payload 是否出现」没排除被编码的情况或者 payload 出现在title、注释里这种不触发的位置。解决用 2.2 节的analyze_reflection先排除实体编码再判断上下文。另外script出现在textarea或pre里也不触发可以在上下文判断里加一条如果 payload 前面最近的标签是textarea/pre/code降级为「疑似」。4.3 现象脚本跑着跑着卡死或超时原因目标站点响应慢或者某个请求触发了服务端的慢查询requests默认没有超时会一直等。解决所有请求强制加timeout(5, 10)即连接 5 秒、读取 10 秒。再配合concurrent.futures.ThreadPoolExecutor加并发但线程数别超过 10否则容易把目标站打挂也容易触发 WAF。4.4 现象payload 被 WAF 拦截全部返回 403原因WAF 识别了script、onerror这类特征字符串。解决准备一套编码变形的 payload比如scrscriptipt、img srcx onerroralert(1)换成img srcx onerroralert(1)JS 字符串拼接、大小写混写ScRiPt。但要注意变形 payload 的判定逻辑也要跟着变不能拿原始 payload 去比对响应。4.5 现象存储型 XSS 检测时提交后不知道去哪找回显原因存储型的数据回显页面不确定可能是列表页、详情页也可能是后台。解决脚本层面加一个--verify-url参数让使用者手动指定「提交后去哪个页面找」。流程变成向提交接口发 payload → 访问 verify-url → 在响应里找 payload。这是半自动方案但比盲目爬全站靠谱得多。5. 从单点检测到批量扫描并发、去重与结果落盘5.1 用线程池把扫描速度提上来单线程逐个参数试 payload遇到参数多的站点会很慢。用ThreadPoolExecutor把「参数 × payload」的任务并行化from concurrent.futures import ThreadPoolExecutor, as_completed def batch_scan(urls, max_workers8): 批量扫描多个 URL每个 URL 内部参数串行URL 之间并行 all_findings [] with ThreadPoolExecutor(max_workersmax_workers) as executor: future_map {executor.submit(scan_url, u): u for u in urls} for future in as_completed(future_map): url future_map[future] try: findings future.result() all_findings.extend(findings) except Exception as e: print(f[!] {url} 扫描异常: {e}) return all_findingsmax_workers8是个经验值再高容易触发目标站的限流。as_completed保证谁先跑完谁先处理不用等最慢的那个。注意这里 URL 之间并行单个 URL 内部的参数还是串行——因为同一站点的请求太密集容易被封串行能降低风险。5.2 结果去重与落盘同一个漏洞可能被多个 payload 命中需要去重。去重键用「URL 参数名」import json def dedup_findings(findings): 按 url param 去重保留第一个命中的 payload seen set() result [] for f in findings: key (f[url], f[param]) if key not in seen: seen.add(key) result.append(f) return result def save_findings(findings, pathxss_result.json): 结果落盘为 JSON方便后续接入报告系统 with open(path, w, encodingutf-8) as fp: json.dump(findings, fp, ensure_asciiFalse, indent2) print(f[*] 结果已保存到 {path}共 {len(findings)} 条)落盘用 JSON 而不是 CSV是因为 findings 里有嵌套结构payload、contextJSON 更自然。ensure_asciiFalse保证中文正常显示。这份 JSON 可以直接喂给后续的报告生成脚本或者接进 CI 的产物归档。5.3 接入 CI 的一个最小示例把脚本包成命令行工具用argparse接收参数import argparse def main(): parser argparse.ArgumentParser(description轻量 XSS 检测脚本) parser.add_argument(--url, help单个目标 URL) parser.add_argument(--urls-file, helpURL 列表文件每行一个) parser.add_argument(--cookie, help登录 Cookie 字符串) parser.add_argument(--output, defaultxss_result.json, help结果输出路径) args parser.parse_args() urls [] if args.url: urls.append(args.url) if args.urls_file: with open(args.urls_file, encodingutf-8) as fp: urls.extend(line.strip() for line in fp if line.strip()) findings batch_scan(urls) findings dedup_findings(findings) save_findings(findings, args.output) # 有漏洞时返回非零退出码方便 CI 判断 if findings: exit(1) if __name__ __main__: main()exit(1)是关键CI 流水线靠退出码判断这一步是否失败。有漏洞就返回 1流水线红灯强制人工确认。这样 XSS 检测就从「想起来才跑一次」变成了「每次提交都跑」的常态化检查。提示CI 里跑的时候把--urls-file指向一份维护好的关键页面清单别全站爬。全站扫描又慢又容易误伤聚焦登录、搜索、评论这类高风险的输入点就够了。6. 让检测更准的一招用无头浏览器验证 DOM 型 XSS前面整套逻辑对反射型够用但 DOM 型 XSS 是它的盲区——payload 不进服务端响应analyze_reflection永远返回 False。补上这块最实用的办法是用 Playwright 跑一遍页面监听alert弹窗。from playwright.sync_api import sync_playwright def check_dom_xss(url, payload): 用无头浏览器加载页面监听 alert 是否触发 triggered False with sync_playwright() as p: browser p.chromium.launch(headlessTrue) page browser.new_page() # 拦截 alert触发即标记 page.on(dialog, lambda dialog: (setattr_check(), dialog.dismiss())) try: page.goto(url, timeout15000) page.wait_for_timeout(2000) # 等 JS 执行完 except Exception as e: print(f[!] 页面加载失败: {e}) browser.close() return triggered def setattr_check(): global triggered triggered True这段代码的核心是page.on(dialog, ...)Playwright 能捕获页面弹出的alert/confirm/prompt一旦触发就说明 payload 执行了。wait_for_timeout(2000)是给 JS 渲染留时间具体数值看页面复杂度调。headlessTrue保证在服务器上无界面运行。用法上把 DOM 型检测作为反射型检测的补充对每个 URL先跑反射型逻辑如果没命中再用无头浏览器加载一次看有没有弹窗。这样两类漏洞都能覆盖。不过要提醒一句无头浏览器很重启动一次几百毫秒到几秒不适合对每个参数都跑。我一般只对「页面里有明显 JS 参数处理」的 URL 启用它比如带#锚点参数、或者 URL 里有redirect、callback这类字段的。最后说个我自己的习惯这套脚本我从来不指望它「零误报」而是把它当成一个「筛子」——它负责把可疑的点筛出来人工再花几分钟确认。真正让我放心的不是脚本报了多少而是我知道它的判定逻辑每一行在干什么误报漏报都能自己调。XSS 检测这件事工具的可控性比能力上限更重要。希望帮到你。本文还有配套的精品资源点击获取
返回列表