☰
XSS跨站脚本攻击深度解析:原理、类型与前端安全防御实战
2026/9/25 2:43:17 网站建设 项目流程

前两天有个朋友在群里发了一个链接,问我:这个页面明明没有放任何支付按钮,为什么我登录完再点一下,账号就被改绑了。我随手把链接拆开,发现 query 参数里被人塞了一段很难用肉眼直接看出来的 payload,页面又把这段参数当成欢迎词原样回显出来了。浏览器执行脚本的那一刻,会话凭证就跟着丢了。这不是什么小众 bug,这正是 XSS 攻击(Cross-Site Scripting,跨站脚本攻击)里最常见的反射型玩法,而它长期稳居前端安全威胁的前列。

很多没有真正做过前端安全的人以为 XSS 就是弹个窗,修起来不过把<script>标签过滤掉。可现实是,从数据接口、管理后台、文件上传到富文本编辑器,每个位置都可能成为 XSS 的入口。这篇文章我想按自己的实践路径把这套体系讲透:为什么 XSS 配得上“核心威胁”这个形容,三种常见形态到底差在哪里,前端和后端的防御边界怎么划,以及怎么用 DVWA、Pikachu、PortSwigger 这些靶场把判断力练出来。无论你是刚入门的前端,还是被安全团队追着改漏洞的服务端同学,都可以找到能对照的场景。

1. 先弄明白 XSS 为什么能当上“前端安全头号威胁”

1.1 浏览器信任模型里的底层缺口

XSS 能存在这么多年,根子不在某个框架的 bug 里,而在浏览器的信任模型上。浏览器在面对一个页面时,默认会相信从同源服务器返回的所有内容,包括 HTML 标签、CSS 样式和 JavaScript 代码。当你请求一个网址,服务端返回了一段字符串,浏览器不会去思考“这段字符串里哪几段是程序自己的逻辑、哪几段是用户填进来的数据”,它只会按既定的解析流程执行。

很多开发者第一次接触 XSS 时都会问:服务器返回的明明是正常页面,怎么会把用户的输入当成代码执行?问题就出在拼接。如果服务端把用户输入直接用字符串拼到了 HTML 里,比如<p>欢迎回来,+ name +</p>,当 name 等于<img src=x onerror=alert(1)>时,拼出来的就不再是一句欢迎语,而是一个新的 HTML 标签和一个事件回调。浏览器不会为刚才那几行字符串做审查,它只负责把标签当作标签渲染、把脚本当作脚本执行。

这种信任关系很像一个小区的安保:只要穿了制服,执勤的人就放行,完全不看证件。攻击者利用的就是这一点,他们会想办法让自己输入的那段内容“穿上”HTML 或 JavaScript 元素的制服,混进执行区。当你真正理解了这一层,就不会再对“为什么删除<script>标签还不够”感到困惑。

1.2 危害边界:从盗 Cookie 到账号接管

XSS 不是只能弹窗,它最大的杀伤力在于继承受害者在当前站点上的权限。它能在当前页面上下文里执行任意 JavaScript,所以能做的事几乎取决于业务功能有多少:

  • 读取 Cookie、LocalStorage、SessionStorage 里的凭证。如果站点没有给 Cookie 加 HttpOnly,攻击者直接把document.cookie回传出去,就能伪装成用户身份。
  • 以用户身份调用接口。改密码、改绑定手机、下单、转账、删数据,脚本在用户会话里发请求,后端根本分不清是用户主动操作还是被脚本触发的操作。
  • 键盘记录与表单劫持。脚本可以监听键盘事件,也可以在页面上动态插入一个假的登录框,等用户自己把密码输进去再悄悄传走。
  • 钓鱼与诱导。XSS 可以直接修改页面内容,把原本的页面换成一套仿冒界面,用户看到的是带可信域名的页面,信任感天然就高。
  • 在社区类产品里还可能扩散成蠕虫。存储型 XSS 一旦被批量灌入,所有访问者都会中招,访问者又可能继续写入新的 payload,形成自动传播。

还有一个容易被忽略的点:HttpOnly 能挡住document.cookie,但挡不住其他危害。XSS 脚本依然可以带着用户的身份直接发起请求、读取页面上的敏感数据、篡改页面展示,也可以从 LocalStorage 和 SessionStorage 里拿走前端保存的 Token。所以 HttpOnly 只是降低损失,不是解决 XSS 本身。

1.3 为什么现代框架年代它依然层出不穷

可能有人会说,现在 React、Vue 都默认转义变量,为什么 XSS 还是那么多?默认转义确实挡掉了最常见的文本回显型 XSS,但新问题出现在那些绕过框架默认保护的 API 上:dangerouslySetInnerHTML、v-html、innerHTML、富文本组件、URL 参数与前端路由的场景。这些 API 都是开发者主动把字符串当 HTML 或代码来用的地方,只要有一处没做规范处理,前面默认转义的保护就全部绕过了。

框架只能处理它认识的输出路径,处理不了业务上自己接进来的 DOM 操作。所以技术栈再新,也不能理所当然地认为“框架帮我安全了”。这也是为什么 XSS 直到今天仍然是前端安全里最值得投入精力去理解的一类漏洞。

2. 反射型、存储型、DOM 型三者的攻击链路拆解

2.1 反射型 XSS:一次精心构造的链接点击

反射型 XSS 最适合作为理解 XSS 的起点。攻击者不把 payload 长期保存在目标服务器上,而是放在请求的 URL 里,诱导受害者点击。服务器不做过滤就把参数拼进响应并返回,浏览器又把这个响应当合法 HTML 渲染。

举个例子,如果一个搜索页把关键字直接回显到页面标题区,构造这样的链接:

https://example.com/search?keyword=%3Cscript%3Ealert(document.domain)%3C/script%3E

用户点击后,服务器把 URL 解码后的 keyword 拼到<h1>里,浏览器解析时就会执行这段脚本。完整链路就是:构造恶意链接,诱导点击,服务器回显参数,浏览器执行脚本。攻击者不需要碰你的数据库,也不需要提前在页面上留任何东西,一条链接就够了。

现实里的反射型 XSS,诱导这一步通常靠钓鱼邮件、短链接、社交平台私信。只要你没有意识到 URL 里藏着 payload,点下去之后事情就发生了。在 CTF 或靶场环境里,这类题目经常要求你构造 payload 后拿一个回调平台确认脚本真的执行了,比如你在自己可控的服务器上搭一个简单接收点,观察目标浏览器是否带着 Cookie 访问过来。这套验证方式在防御测试里也经常用。

2.2 存储型 XSS:数据库里埋着最阴的定时炸弹

存储型 XSS 是三种 XSS 中危害面最大的。攻击者把 payload 提交到服务端,服务端存进数据库,之后任何一个用户访问展示该数据的页面都会触发。它不需要受害者点击特定链接,你什么都不做,只要打开那个页面就中招了。

典型的入口是评论区、用户昵称、个人签名、留言板、简历字段。比如一条评论里写了:

<script>fetch('/api/account/change-password', {method:'POST', body:'newpass=123456'})</script>

其他用户访问这个页面时,脚本就在他们各自的浏览器里以自己的身份发起请求。更糟的是,如果管理后台同样渲染这批评论,管理员打开后台时也会触发,攻击者可能借此以管理员身份执行高危操作。存储型 XSS 的一击,往往是全站用户级别的。

这类漏洞之所以容易被人忽略,是因为攻击者在提交时看到的只是“评论发表成功”,没有任何执行反馈,但数据已经在数据库里扎下了根。防御上需要重点关注:凡是“保存用户输入并在页面回显”的功能,都要按不可信数据处理。

2.3 DOM 型 XSS:前端代码自己把危险接进页面

DOM 型 XSS 在三者里最隐蔽。它的特点是:服务端可能根本没有在响应里拼接你的输入,漏洞只存在于前端 JavaScript 代码的执行流里。攻击者的输入通过 URL 参数、location.hash、postMessage、window.name这些来源进入页面,前端脚本把它取出来,又用innerHTML之类的方式写进 DOM,最终造成脚本执行。

看一段简单的示意:

// 假设这是页面上某段欢迎逻辑 const user = new URLSearchParams(window.location.search).get('user'); document.getElementById('welcome').innerHTML = 'Hello, ' + user;

访问:

https://example.com/?user=<img src=x onerror=alert(document.domain)>

浏览器解析 URL 时会自动解码,前端脚本拿到user值后直接塞进innerHTML,这段输入就被当成 HTML 解析了,onerror触发了脚本执行。

DOM 型 XSS 为什么更难发现?因为 payload 不经过服务端逻辑,服务端日志里干干净净,扫描器也很难从响应内容中找出异常。修复时也不能只依赖后端过滤器,必须从前端代码层面排查危险函数和不可信数据源的组合。常见的危险数据源包括location.search、location.hash、document.referrer、postMessage消息、window.name,常见的危险出口包括innerHTML、outerHTML、document.write、insertAdjacentHTML、eval、setTimeout、setInterval。防守方一定要对这两类函数做代码审计。

2.4 三种形态的对比

维度反射型存储型DOM 型
payload 存放位置URL 参数服务端数据库客户端内存 / URL 片段
服务端是否拼接通常是通常是往往不参与
攻击者是否需要诱导需要点击链接不需要,访问即触发通常需要点击带毒链接
检测难度低中高
典型入口搜索、跳转评论、昵称、留言前端路由、动态 DOM

反射型像别人递给你一把发烫的勺子,你得伸手去接才会伤到;存储型像有人在门口挖了个坑,只要你走那扇门,左脚必中;DOM 型的坑更深,有时候挖坑的和埋坑的都是前端自己。

3. 前端侧防御:输入校验与输出编码的取舍逻辑

3.1 输入校验的边界感:按业务类型管,别全站一刀切

先厘清分工:输入校验不是把全站的尖括号都删掉,而是根据字段的业务类型约束它的字符集和格式。用户名可以用白名单规则限制只允许字母、数字、下划线;邮箱和手机号做格式校验;订单号限制为固定格式。这类结构化数据,白名单校验既好用又安全。

但评论、文章、签名这些自由文本,你不可能禁止用户输入<或>,因为它们在内容表达里是有意义的。对这类字段,前端能做的是提示和辅助校验,真正的手段要放到输出时去处理,要么选择 HTML 实体编码,要么用白名单标签过滤。

不要因为想省事,就用一条全局规则把所有用户输入的< > &全部替换掉。输入校验解决的是“用户是否输入了非法业务值”,而不是“用户是否输入了代码”。把这两件事混在一起,轻则业务数据被改坏,重则过滤不彻底仍然被绕过。

3.2 输出编码需要上下文感知:五个位置,五套规则

输出编码是防御 XSS 的核心动作。关键点在于输出位置不同,需要的编码方案也不同,这就是安全领域常说的上下文感知编码。

输出位置典型场景对应手段
HTML 文本节点<p>{data}</p>HTML 实体编码,转义& < > " '
HTML 属性<input value="...">属性值编码,并避免拼接危险协议
URL 上下文<a href="...">URL 编码,协议白名单 http/https/mailto
CSS 上下文内联样式CSS 转义,不建议允许用户控制样式
JavaScript 上下文JS 字符串变量JS 字符串转义,JSON 用JSON.parse而不是eval

同一个输入放到不同上下文要套不同方案。比如输入&quot; onmouseover=&quot;alert(1),放到 HTML 文本节点时只是普通字符,但放到属性值里就可能是新增属性注入。如果你只做了一种全局转义,就一定会出现某个上下文漏网的情况。

3.3 框架转义的盲区:v-html、dangerouslySetInnerHTML 与富文本

现代框架默认转义,导致很多人产生安全错觉。真正平时最容易出事的三处:

第一,模板里的{{ }}或{ expression }是安全的,但只要你用了dangerouslySetInnerHTML(React)、v-html(Vue)或直接操作innerHTML,就绕过了默认转义。这些 API 设计出来是为了处理真正的 HTML,不是给用户输入直接用的。

第二,富文本编辑器输出不能只做简单标签替换。很多编辑器给的是带样式的 HTML 片段,直接回显时如果没做白名单过滤,<img onerror>、<a href="javascript:...">都可能被带进来。生产环境建议用 DOMPurify 或 sanitize-html 这类成熟的净化库,只允许你自己确认过的安全标签和属性,比如b, strong, i, em, u, p, span, a[href], img[src, alt],并且对href和src做协议白名单检查。

第三,URL 属性里的javascript:协议很阴。即使你把字符串存进去了,只要属性值是javascript:alert(1),用户点击时照样执行。所以对href、src、style这类属性,必须单独做协议白名单,不能只依赖整体转义。

4. 后端兜底防线:全局过滤器、HttpOnly、CSP 的组合拳

4.1 Spring Boot 全局过滤器能做到什么程度

如果你在一个 Java 技术栈的项目里,最顺手的第一步是在 Filter 里加一层全局 XSS 兜底。它的目的不是替业务做全部转义,而是把明显带标签的恶意输入挡在接口层,尽量不让其进入业务逻辑和数据库。

一个简化的思路是这样:定义 Filter,把原始请求包装成重写了参数读取方法的 RequestWrapper,在读取参数时统一做一次清理或编码。

@Component @Order(1) public class XssFilter implements Filter { @Override public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain) throws IOException, ServletException { HttpServletRequest req = (HttpServletRequest) request; chain.doFilter(new XssHttpServletRequestWrapper(req), response); } }

包装类里重写取值方法:

public class XssHttpServletRequestWrapper extends HttpServletRequestWrapper { @Override public String getParameter(String name) { String value = super.getParameter(name); return XssUtil.stripXss(value); } @Override public String getHeader(String name) { String value = super.getHeader(name); return XssUtil.stripXss(value); } }

如果是 JSON 请求体,还需要先缓存ServletInputStream,再从流里读出 body 做清理,否则原生的getParameter拿不到 body 里的内容,Controller 也可能因为流被消费掉而取不到参数。

但这里有一个度的问题:全局过滤器适合处理明显的恶意特征,但不要把所有用户输入的< > &全部 HTML 实体化。有的团队从这里开始,把用户名、文章、留言全转义了,结果用户写了个1 < 2,富文本渲染时数据全乱了。业务数据在输入层被无脑转换,是一条非常危险的路。

4.2 文件上传场景里 XSS 的隐藏入口:PDF 与文件名都有份

最近经常有人问 Spring Boot 全局过滤器怎么处理上传 PDF 时的 XSS 攻击。从实际踩坑来看,文件上传流程里的 XSS 大多出现在两个地方。

一是文件内容本身。用户上传一个包含 JavaScript 的 HTML 或 SVG 文件,如果浏览器直接在同源路径下打开,脚本就能运行;PDF 文件也可以内嵌脚本。所以不要把上传目录当作应用的静态资源目录随意展示。建议把上传文件放到独立的域名或 CDN 上,对用户上传的 HTML、SVG 这类文件强制返回Content-Disposition: attachment,让浏览器下载而不是打开渲染。对 PDF 等文件返回时也要加上X-Content-Type-Options: nosniff,减少 MIME 嗅探带来的风险。

二是文件名回显。这是很容易被忽略的坑。页面上任何出现原文件名的地方都在做输出,如果服务端没有转义,就存在反射型 XSS 的可能。攻击者上传一个文件叫:

1"><img src=x onerror=alert(document.domain)>.png

后台把原始文件名回填到列表页时,如果没有做转义或过滤,这段内容就会被浏览器当成 HTML 解析。更省事的做法是,服务端直接忽略原始文件名,给文件另存为一个新名字,用 UUID 或时间戳重命名,这样文件名本身就不再是可信输入了。

文件上传场景的防御重点应该是:存储隔离、响应头防护、文件名重命名,这三件事做对之后,比在过滤器里扫描整个 PDF 字节流简单可靠得多。

4.3 HttpOnly、SameSite、CSP:就算漏了,也让利用链条断掉

防御的第二层,是让已经发生的 XSS 无法转化成真正的危害。HttpOnly 是最熟悉的选项,它让浏览器禁止脚本访问document.cookie,于是前端 JS 就偷不走会话 Cookie。配合 Secure 和 SameSite,效果更好:

Set-Cookie: session=abc123; HttpOnly; Secure; SameSite=Lax

但要注意,Token 如果存在 LocalStorage 里,HttpOnly 管不着,XSS 依然能通过 JS 读走。所以这层只是断链,不能替代前面的修复。

CSP(内容安全策略)是现在越来越重要的一层兜底。一个相对合理的配置长这样:

Content-Security-Policy: default-src 'self'; script-src 'self' 'nonce-首页随机值'; object-src 'none'; base-uri 'none';

script-src限制脚本来源,object-src把 Flash 等遗留插件的利用面关掉,base-uri防止攻击者用<base>标签篡改页面里所有相对 URL。这种配置下,即使攻击者注入了一个<script>标签,只要它没有合法的 nonce,浏览器同样拒绝执行。

CSP 要作为纵深防御,而不是唯一防线,因为 DOM XSS 借由内联事件属性或具有 nonce 的动态脚本仍然可能打穿。建议先在Content-Security-Policy-Report-Only模式下跑一段时间,把业务上有影响的脚本来源先理清楚,再强制执行。老项目开启严格 CSP 时,常见的问题是第三方 SDK 脚本来源太杂,导致白名单越改越宽,最后失去意义。所以一开始就要尽量保持script-src收窄,能用 nonce 就别用域名白名单。

5. 在 DVWA、Pikachu、PortSwigger 靶场里练出来的判断力

5.1 靶场分级设计:低、中、高级各练什么

很多人学 XSS 时会搜到 DVWA、Pikachu、ctfshow 这类靶场。我赞成用靶场练习,因为靶场把环境隔离在一个安全可控的容器里,你可以反复尝试 payload 而不担心污染线上。

我的练习顺序是这样安排的:

  • 先在 DVWA 的 low 安全级别里,把 Reflected、Stored、DOM 三种类型各跑一遍,重点不是拿什么 payload 弹窗,而是观察输入提交后到了哪里、服务端响应里它出现在什么位置。
  • 再把安全级别调成 medium 和 high,看同一个漏洞在加了过滤规则后怎么变化。这个阶段你会开始理解“为什么过滤了<script>依然可能被绕过”。
  • 然后到 Pikachu 做场景补全。Pikachu 的中文界面更贴近实际业务,像留言板、个人信息、文件上传这些场景,和真实项目里的分布很像。
  • 最后用 PortSwigger 的 XSS labs 做专项训练。它的题目会把“输出上下文”明确指出来,比如“在属性中输出”“在 script 块内输出”“在 href 链接中输出”,练完你自然就会养成上下文敏感思维。
  • 在 CTF 题目里刷反射型 XSS 时,通常需要构造一个目标 URL,并用回调平台确认脚本真的执行成功。关键是先判断回显点在哪个 HTML 上下文,再决定 payload 的写法,而不是一上来就瞎试。

靶场不是只供攻击者参考,防御者同样要自己上手拉一遍,才知道自己搭的过滤规则和响应头防御到底能不能挡住 payload。

5.2 常见的过滤绕过思路:为什么要从防御者视角看一遍

很多项目上了简单过滤器之后,仍然在攻防演练里被突破,原因是对绕过形态没有概念。这里不是要教人去攻击线上系统,而是想说明:防御者如果不知道攻击者有哪些思路,就不知道该在哪个环节补测试用例。

常见绕过大于按:

  • 大小写混淆。<script>被过滤后,就换<ScRiPt>。HTML 标签名不区分大小写,依然能执行。
  • 换承载标签。<script>被过滤后,可以用<img src=x onerror=...>、<svg onload=...>,事件属性里照样可以放脚本。
  • 换协议入口。<a href="javascript:...">、<iframe src="javascript:...">,只要输出点能控制 URL 属性,就不需要标签本身。
  • 多层编码灌入。在最终渲染之前,URL 解码、HTML 实体解码、JSON 反序列化都可能发生,攻击者可以把 payload 编码一两次,到最后一层才还原出真正的<script>。

所以过滤器的存活能力不能只看“删没删掉<script>”,而是要看“即使被删了一层,浏览器在最终解析时还认不认识这段内容”。测试用例里至少要覆盖上面这四类,结果才有一点参考价值。

5.3 手工验证与自动化扫描的配合时机

我见过不少团队买了 WAF 和扫描器就认为高枕无忧了。但在 DOM XSS 面前,WAF 的作用有限,因为 payload 经常不经过服务端,比如location.hash根本不会发到服务器上。所以测试时必须结合手工手段。

手工验证时,我会打开浏览器 DevTools,先看 Network 里页面真实返回的响应,再在 Elements 面板观察输入插到了哪个节点,最后在 Sources 面板里跟踪危险函数和不可信数据源的调用链。危险出口之外的入口,比如postMessage监听器、页面间传参,都是自动化覆盖率很低的地方,只能靠人推代码逻辑。

自动化扫描器适合做全站范围的回归测试,但它的漏报率很高。像“先发表评论,再跳到列表页触发”这种多阶段交互,扫描器往往走不通,出了报告最后还是需要人来做二次验证。

我的做法是给每个关键接口写一张测试用例表,包含输入点、回显位置、payload、预期输出、实际结果。上线后在 CI 里跑一轮自动化回归,可以显著减少“明明修好了,上线又复现”的尴尬。

6. 我在真实项目里反复踩过的 4 个 XSS 防御误区

6.1 以为前端过滤就算处理,后端直接存原始数据

很多团队在前端提交接口前把<script>全局替换为空,后端一看数据里没有标签就放心入库了。但攻击者完全可以绕过前端界面,直接给后端接口发一段完整 payload。只要后端的入库口没有限制,XSS 依然成立。验证的目光必须放在服务端入站校验和输出转义两端,而不是某一端。

6.2 把 innerHTML 换成 textContent 就以为没风险了

textContent 会把内容作为纯文本处理,确实不会执行<script>。但只要用户数据被拼进href、src、onclick这些属性里,或者通过document.createElement拼接 URL,就还是会掉进javascript:这类协议陷阱。有一次我修完一个弹窗,把innerHTML改成了textContent,却忘了另一处imgSrc是从 URL 参数里取的,攻击者构造javascript:alert(1)之后照样触发。正确做法是对属性类输出单独做协议白名单校验。

6.3 在输入层把全站特殊字符转义,业务数据被改坏

这个坑特别常见。强行在过滤器里把所有< >替换成&lt;&gt;,用户的数学笔记、技术文档、代码示例会全部错乱;存入数据库后再取出,还得做一层反转义,反转义又可能引入新的绕过。正确归属是:输入层只做业务格式校验和恶意特征拦截,真正按上下文编码放到输出渲染层去做。这样数据在存储时是原始的,展示时才按场景决定怎么编码。

6.4 只验证一条 payload,没测编码和解码链路

以前我习惯确认alert(1)不弹了就以为修好了,后来安全团队用双重编码的 payload 绕过,原因是中间某层做了一次解码,到了最终渲染位置才把 payload 还原成标签。从那次之后,我的测试要求变成:所有输入参数至少用三组不同形态的样本测,分别是正常文本、标签类 payload、编码类 payload,并且每次都要在浏览器的 Elements 面板里看最终渲染位置是否出现了不该出现的节点。

踩过这几轮坑之后,我现在的经验是:XSS 防御不是加一个过滤器就完事,而是把“用户输入永远是不可信数据”这条原则贯穿到所有数据流转环节。前端负责交互体验和第一层约束,后端负责入库边界和最终输出转义,CSP、HttpOnly 这些响应头再兜一层。如果你所在的团队只有你一个人懂这块,可以先从第 4 节的 Spring Boot 全局过滤器和响应头做起,再逐步推动前端把危险 API 收敛掉。最后提醒一句:在自己能完全控制变量的靶场里测试 payload,不要在线上环境试,这是底线。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询