XSS正则过滤绕过:从编码技巧到浏览器解析差异的攻防实战

1. 项目概述:当“过滤”遇上“绕过”

在Web安全领域,跨站脚本攻击(XSS)就像是一个经久不衰的“猫鼠游戏”。开发者在输入点竖起一道道正则表达式(Regex)的过滤高墙,而安全研究者或攻击者则绞尽脑汁寻找墙上哪怕最细微的裂缝。这个项目标题“XSS-过滤特殊符号的正则绕过”,精准地指向了这个攻防对抗的核心战场。它探讨的不是一个宽泛的XSS概念,而是一个非常具体且实战性极强的技术点:当防御方试图通过正则表达式过滤掉<>&"'等特殊符号来阻断XSS攻击向量时,攻击方如何利用正则表达式本身的特性、浏览器的解析差异以及编码技巧,实现“曲线救国”,最终成功执行恶意脚本。

这不仅仅是CTF比赛中的常客,更是真实渗透测试和代码审计中必须掌握的技能。很多初级防护措施,往往依赖于一个简单的preg_replace或类似的字符串替换函数,试图“一劳永逸”地清除危险字符。但正如我们即将看到的,这种思路存在诸多盲点。理解这些绕过技巧,对于开发者而言,意味着能写出更健壮、更深度的防御代码;对于安全从业者而言,则是评估一个应用XSS防护是否“纸老虎”的关键。接下来,我们将深入拆解这个“矛与盾”的游戏,从正则过滤的原理出发,一步步揭示那些看似严密的防线是如何被巧妙绕过的。

2. 正则过滤的常见思路与固有缺陷

在深入绕过技巧之前,我们必须先理解防御方通常是怎么想的。基于正则表达式的XSS过滤,其核心逻辑是“黑名单”或“基于模式的拦截”。开发者会定义一个或多个正则模式,匹配他们认为危险的字符或字符串组合,然后进行删除、转义或替换。

2.1 典型的“过滤特殊符号”正则模式

一个非常常见但脆弱的防御代码可能长这样(以PHP为例):

function filter_xss($input) { $pattern = '/[<>\"\'\&]/'; return preg_replace($pattern, '', $input); }

这个函数意图很明确:删除输入字符串中所有的尖括号(<,>)、双引号(")、单引号(')和&符号。在开发者看来,没有了尖括号,就无法构造HTML标签;没有了引号,就无法闭合属性值;没有了&,就无法使用HTML实体。逻辑似乎无懈可击。

另一种稍微复杂点的模式,可能针对<script>标签:

$pattern = '/<script.*?>.*?<\/script>/is';

这个模式试图匹配并删除完整的<script>...</script>标签及其内容,i标志表示不区分大小写,s标志让.也能匹配换行符。

2.2 为什么这种过滤天生脆弱?

这种基于正则的过滤方法,从设计之初就埋下了几个致命的缺陷:

  1. 过度依赖单一层级的过滤:它通常只在数据进入应用的某个节点(如接收$_GET[‘input’]时)执行一次过滤。但数据在应用中可能流经多个上下文(HTML标签内、属性值、JavaScript字符串、CSS样式、URL),每个上下文对特殊字符的解析规则都不同。一次性的、针对HTML的过滤,无法应对其他上下文。
  2. 正则表达式的局限性:正则引擎本身是“模式匹配”工具,不是“语法解析器”。它无法理解HTML或JavaScript的复杂语法结构。攻击者可以利用正则引擎的匹配逻辑(如贪婪/非贪婪、回溯)和浏览器的容错性之间的差异来制造绕过。
  3. “黑名单”思维定势:列出“坏”的字符,永远会有遗漏。新的HTML特性、JavaScript API、浏览器解析怪癖层出不穷,黑名单难以穷尽。
  4. 编码与解码的时机错位:这是绕过中最常见的突破口。如果过滤发生在URL解码或HTML实体解码之前,那么攻击者提交的经过编码的载荷就能轻松绕过过滤,最终在浏览器端被解码还原成恶意代码。

理解了防御的薄弱环节,我们就可以像一位耐心的锁匠,开始寻找锁芯里的每一处机关。

3. 核心绕过技巧深度解析

绕过“过滤特殊符号”的正则,本质上是寻找一条从“用户输入”到“浏览器执行”的路径,这条路径上至少有一个环节,过滤逻辑是缺失、错误或可以被欺骗的。我们将从易到难,剖析几种经典的绕过思路。

3.1 利用编码与多重编码

这是最基础、也最有效的绕过方式。核心原理是:过滤函数处理的是“字面量”字符,而浏览器解析的是“解码后”的字符

  • HTML实体编码绕过: 假设过滤函数删除了<>。我们提交的载荷不是<script>alert(1)</script>,而是:

    &lt;script&gt;alert(1)&lt;/script&gt;

    对于那个简单的/[<>\"\'\&]/正则来说,这个字符串里没有字面上的<>,只有&lt等字符。如果过滤函数也删除了&,那这个载荷也会被拦截。但很多初级过滤只删尖括号和引号,会放过&。当这个字符串被输出到HTML页面时,浏览器会将其解码为<script>alert(1)</script>,从而执行。

    实操心得:测试时,不仅要测试&lt;&gt;,还要测试十进制(<)和十六进制(<)实体编码。有时过滤可能只拦截了命名实体,而忽略了数字形式。

  • URL编码绕过: 如果输入点最终会被放入一个URL上下文(比如<a href=”INPUT”>),那么URL编码就可能生效。例如,构造javascript:alert(1)被过滤了,可以尝试:

    javascript%3aalert%281%29

    如果服务器端在过滤前没有进行URL解码,那么%3a(冒号)和%28(左括号)就不会被识别为特殊字符。当这个字符串被设置为href属性并点击时,浏览器会对其进行URL解码,还原出可执行的javascript:协议。

  • 多重编码与混合编码: 这是对付“过滤后解码”逻辑的利器。假设防护逻辑是:先过滤危险字符,然后对剩余内容进行HTML实体编码(一种错误但存在的逻辑)。攻击者可以提交双重编码的载荷:

    &amp;lt;script&amp;gt;alert(1)&amp;lt;/script&amp;gt;

    第一层过滤看到的是&amp;lt;,里面没有<,放行。然后系统对其进行HTML编码,变成&amp;amp;lt;&被编码成&amp;)。输出到浏览器后,浏览器第一次解码得到&lt;,第二次解码就得到了<,绕过成功。

    注意:多重编码的成功与否极度依赖于服务器端处理数据的顺序。在实战中,需要通过模糊测试(Fuzz)来探测数据处理流水线。

3.2 利用正则表达式本身的特性

正则引擎不是万能的,它的匹配方式可以被利用。

  • 换行符绕过: 回顾那个匹配<script>标签的正则:/<script.*?>.*?<\/script>/iss标志让.可以匹配换行符,但如果我们遇到一个没有s标志的正则呢?比如/<script.*?>.*?<\/script>/i。这时,.不能匹配换行符。我们可以构造:

    <script> alert(1) </script>

    或者更隐蔽地,在script>之间插入换行:

    <script >alert(1)</script>

    这样,正则模式<script.*?>可能因为.无法跨越换行而匹配失败,导致整个过滤失效。

  • 非贪婪匹配的陷阱: 模式.*?是非贪婪匹配,它会匹配尽可能少的字符。考虑这样一个场景和载荷:过滤正则/<script.*?<\/script>/i攻击载荷<script>alert(1)</script><script>innocent</script>非贪婪匹配可能会匹配到第一个</script>就结束,即匹配到<script>alert(1)</script>并将其删除。然而,如果正则替换函数使用不当(例如preg_replace默认替换所有匹配项),它仍然会继续匹配并删除第二个<script>标签。但如果替换逻辑是“只删除第一次匹配”,那么第二个<script>innocent</script>就会被保留,而我们的恶意代码alert(1)实际上已经在前一个被删除的标签中执行了?不,这里有个关键点:脚本执行发生在DOM解析时,而不是正则过滤时。如果过滤后页面变成了<script>innocent</script>,那确实没有执行恶意代码。这个例子更常用于演示“绕过删除特定标签”的逻辑,例如过滤<script>但不过滤<img>,我们可以用<img src=x onerror=alert(1)>。但非贪婪匹配的脆弱性在于,它可能被标签嵌套或特殊结构欺骗。

  • 回溯限制绕过(PCRE): 这是一个比较高级的技巧。PHP的PCRE正则引擎有回溯次数限制。可以构造一个超长的、复杂的字符串,使得正则引擎在尝试匹配时耗尽回溯次数,从而匹配失败,让恶意载荷溜过去。例如,一个包含大量交替选择(a|a)*的模式,在匹配特定字符串时可能产生指数级回溯。但在实际XSS绕过中,这种技巧应用场景相对较窄,更常见于DoS正则引擎本身。

3.3 利用浏览器解析的容错性与怪异模式

浏览器HTML解析器的容错能力极强,这为绕过提供了肥沃的土壤。

  • 省略闭合标签或使用无效标签: 某些情况下,浏览器并不严格要求标签闭合。例如:

    <script>alert(1)<!--

    或者利用某些标签的自闭合特性,但使用错误语法:

    <img src="x" onerror=alert(1) //

    这里的//充当了JavaScript的单行注释,避免了需要闭合引号或尖括号的麻烦。如果过滤函数执着于寻找成对的尖括号或引号,这种畸形写法可能被放过。

  • 利用属性解析规则: HTML属性解析有多种方式。过滤函数可能只检查了用双引号包裹的属性,却忽略了单引号或无引号的属性。

    • 双引号<img src=”x” onerror=”alert(1)”>
    • 单引号<img src=’x’ onerror=’alert(1)’>(如果过滤了双引号但没过滤单引号)
    • 无引号<img src=x onerror=alert(1)>(如果过滤了所有引号,但允许空格和等号) 更进一步,属性值可以包含换行符和制表符,这有时也能干扰简单的正则匹配。
  • JavaScript上下文中的绕过: 当输入点位于<script>标签内部或事件处理器(如onerror)中时,规则完全不同。这里过滤的是JavaScript元字符。

    • 闭合字符串与语句:假设输出点在<script>var a = ‘INPUT’;</script>。过滤了单引号。我们可以输入\’; alert(1);//。最终代码为var a = ‘\’; alert(1);//’;。这里的\’转义了第一个单引号,使其成为字符串的一部分,然后我们用自己的单引号闭合字符串,接着执行新语句,并用//注释掉后面的内容。
    • 模板字符串:在现代JS中,如果输出点使用反引号,可以尝试闭合它。
    • evalsetTimeout动态执行:如果字符串最终被传递给evalsetTimeout,那么编码、字符串拼接等技巧就有了更大的发挥空间。

3.4 利用事件处理器与伪协议

当直接插入<script>标签被严防死守时,事件处理器(Event Handlers)和伪协议(Pseudo-protocols)是两大备选入口。

  • 事件处理器onload,onerror,onmouseover,onfocus,onblur等。这些属性可以挂在很多标签上,如<img>,<body>,<svg>,<input>。例如:<img src=invalid onerror=alert(1)>。即使过滤了<script>javascript:,这些事件属性也可能被忽略。
  • JavaScript伪协议:主要用于<a href><iframe src>等。如<a href=”javascript:alert(1)”>click</a>。绕过时需要对javascript:这个协议声明进行编码或变形,例如:
    • Java&#x53;cript:alert(1)(HTML实体编码)
    • javascript:%61%6c%65%72%74%28%31%29(URL编码)
    • 利用IE等老旧浏览器的特性,如jAvAsCrIpT:(大小写混淆)或javascript:(插入换行符,某些浏览器会忽略)。

4. 实战绕过流程与案例拆解

让我们通过一个模拟的实战场景,将上述技巧串联起来。假设我们面对一个简单的留言板,其后端过滤函数如下:

function sanitize_input($data) { // 移除所有 < > " ' & 字符 $data = preg_replace('/[<>\"\'\&]/', '', $data); // 将 script 标签替换为空(不区分大小写) $data = preg_replace('/<script.*?>.*?<\/script>/i', '', $data); return htmlspecialchars($data, ENT_QUOTES, ‘UTF-8’); // 注意:这里又进行了一次转义 }

初看之下,这个函数做了三件事:1. 删除危险符号;2. 删除<script>标签;3. 使用htmlspecialchars进行HTML实体转义。似乎很安全?但顺序是关键!

攻击流程推演:

  1. 信息收集:我们提交一个测试载荷‘“<>&,观察输出。发现页面上显示为&#039;&quot;&lt;&gt;&amp;。这说明htmlspecialchars生效了,并且是在过滤之后执行的。这意味着,我们提交的任何字符,只要逃过了第一层的preg_replace,就会被第三层转义成实体,从而无害化。所以,主攻方向是第一层正则删除

  2. 分析第一层正则/[<>\"\'\&]/。它删除字符<,>,",',&。我们的目标是让一个可执行的XSS向量通过这些过滤。

  3. 尝试编码绕过:直接提交&lt;img src=x onerror=alert(1)&gt;。第一层正则看到的是&lt;img src=x onerror=alert(1)&gt;,它包含&字符,所以&被删除,字符串变成lt;img src=x onerror=alert(1)gt;。这显然不行。

  4. 尝试无符号事件处理器:构造<img src=x onerror=alert(1)>。第一层正则会删除<,>,=两边的空格?不,正则只删除列表里的字符,空格和字母不会被删。所以过滤后变成img src=x onerror=alert1onerror变成了onerroralert(1)变成了alert1,括号被删了。失败。

  5. 利用正则删除后的字符串拼接:这是一个关键思路。如果我们能让过滤后的字符,在浏览器解析时重新组合成危险字符呢?考虑HTML实体编码的另一种形式:&#xHH;(十六进制)或&#DDD;(十进制)。如果我们提交&#x3c;img src=x onerror=alert(1)&#x3e;,第一层正则会删除&;吗?正则模式是/[<>\"\'\&]/,它只删除&,不删除;。所以过滤后变成#x3c;img src=x onerror=alert(1)#x3e;。这仍然不是有效的HTML实体。

  6. 换个思路:聚焦属性值:假设输出不在标签内容区,而是在一个标签的属性值里,并且属性值没有用引号括好(这是开发者的错误)。例如,后端代码是<input value=<?php echo sanitize_input($_GET[‘input’]); ?>>。 我们提交1 onfocus=alert(1) autofocus。经过第一层过滤,单引号双引号尖括号和&都被删,但这个字符串里没有这些字符,所以原样通过。最终生成的HTML是:<input value=1 onfocus=alert(1) autofocus>。浏览器解析时,会将value的值视为1,然后看到onfocus属性,并将其作为事件处理器绑定。autofocus属性使输入框自动获得焦点,从而触发onfocus事件,执行alert(1)绕过成功!

    这个案例的要点在于:

    • 过滤逻辑只删除了特定的几个字符,但XSS载荷(onfocus=alert(1) autofocus)完全由字母、数字、空格、等号和括号组成,不在黑名单内。
    • 开发者在输出时没有给属性值加上引号(value=INPUT),使得我们的输入可以轻易地“逃逸”出属性值的范围,引入新的属性。
    • htmlspecialchars在最后一步转义,但因为它接收到的输入里已经没有<>&”‘这些字符了(第一层删掉了),所以它无事可做。
  7. 如果属性值有引号呢?如果代码是<input value=”<?php echo sanitize_input($_GET[‘input’]); ?>”>。我们提交1” onfocus=”alert(1),双引号会被第一层正则删除,变成1 onfocus=alert(1),然后被放入value=”…”中,最终结果为<input value=”1 onfocus=alert(1)”>onfocus成了value字符串的一部分,不会被执行。这时,我们需要闭合引号。但引号被过滤了。我们可以尝试用&quot;(HTML实体)吗?不行,&会被过滤。所以,在这个特定过滤函数下,如果属性值被正确引号包裹,且过滤了引号,直接注入事件处理器会非常困难。这时可能需要寻找其他注入点(如标签内容区、CSS、URL参数等)。

案例总结:这个实战推演展示了绕过思维的核心——精确理解数据流和上下文。我们不仅需要知道过滤了什么,更需要知道过滤后的数据被用在了哪里(什么标签、什么属性、是否有引号包裹、是否在JS块中)。一次成功的绕过,往往是特定过滤漏洞与特定输出上下文漏洞的结合。

5. 防御之道:从源头杜绝绕过

分析了这么多绕过技巧,作为开发者,应该如何构建真正有效的XSS防护呢?答案是:放弃单纯依赖正则过滤特殊符号的黑名单思维,转向白名单和上下文相关的输出编码

  1. 输出编码(Output Encoding)是王道

    • 原则:在数据输出到特定上下文时,对其进行正确的编码或转义,而不是在输入时进行“消毒”。
    • HTML内容上下文:使用如htmlspecialchars($data, ENT_QUOTES, ‘UTF-8’),将<,>,&,,转义为HTML实体。确保设置正确的字符集(如UTF-8),并始终使用ENT_QUOTES标志以转义单双引号。
    • HTML属性上下文:同上,使用htmlspecialchars务必为所有属性值加上引号(单双皆可,保持一致),这是防止属性逃逸的生命线。
    • JavaScript上下文:将数据放入<script>标签或事件处理器属性值时,不能只用HTML编码。应使用专门的JavaScript编码函数,如json_encode()(用于生成JS字面量),或确保数据被放在引号内并进行正确的转义(如将\转义为\\转义为\’,换行转义为\n等)。
    • URL上下文:在将数据拼接到URL(如href,src)时,使用urlencode()rawurlencode()
    • CSS上下文:极少需要将用户输入放入CSS,如果必须,需进行严格的CSS编码。
  2. 使用成熟的防护库

    • 不要自己造轮子。使用经过严格安全审计的库,如OWASP的Java Encoder Project、PHP的HTML Purifier(用于富文本HTML)、.NET的AntiXSS Library(现并入System.Web.Security.AntiXss)等。这些库提供了针对不同上下文的编码方法。
  3. 内容安全策略(CSP)

    • CSP是一个终极的深度防御策略。它通过HTTP头告诉浏览器,只允许加载和执行来自特定来源的脚本、样式、图片等。即使网站存在XSS漏洞,攻击者也无法注入并执行不在白名单内的脚本。例如,一个严格的CSP头可以是:Content-Security-Policy: default-src ‘self’; script-src ‘self’ https://trusted.cdn.com;。这能极大缓解XSS的影响。
  4. 输入验证与白名单

    • 在接收输入时,根据预期的数据类型进行严格的白名单验证。例如,如果是电话号码字段,只允许数字、加号、括号和短横线;如果是姓名字段,可以限制字符集和长度。这能在最早阶段阻断畸形数据。
  5. 避免危险的操作

    • 绝对避免将用户输入直接传递给eval(),setTimeout(string),innerHTML,outerHTML,document.write()等可以执行字符串代码的API。如果必须使用innerHTML,务必先对不可信数据进行严格的HTML编码,或者使用textContent属性。

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

在研究和测试XSS绕过时,你一定会遇到各种奇怪的现象。这里记录一些典型的“坑”和排查思路。

问题1:载荷明明构造对了,为什么弹窗不出现?

  • 检查点1:浏览器控制台(Console)。打开开发者工具,查看是否有JavaScript错误。常见错误是语法错误,比如因为过滤导致括号缺失、字符串未闭合等。
  • 检查点2:网络请求(Network)。查看提交的载荷是否被后端修改了?可能有多层过滤、WAF(Web应用防火墙)拦截。
  • 检查点3:源代码(Elements)。右键查看页面源代码(不是检查元素),看看你的输入被最终渲染成了什么样子。检查HTML实体是否被正确解码,标签是否被浏览器修正。
  • 检查点4:CSP限制。检查响应头是否有Content-Security-Policy。如果有,它可能阻止了内联脚本(unsafe-inline)的执行。这种情况下,基于标签或事件的XSS可能失效,需要寻找其他利用方式(如CSP绕过,或利用允许域上的脚本)。

问题2:如何高效地Fuzz测试过滤逻辑?

  • 手动测试:从简单的<‘“>开始,观察输出。然后系统性地测试:
    • 各种编码:HTML实体(命名、十进制、十六进制)、URL编码、Unicode编码。
    • 大小写变换:<ScRiPt>
    • 插入非常规空白:<script/x><script%0a>
    • 尝试无闭合标签、畸形标签。
  • 使用工具:Burp Suite的Intruder或Turbo Intruder,配合强大的载荷字典(如SecLists中的XSS字典)。可以自动化地发送大量变种载荷,观察响应差异。
  • 理解上下文:最重要的还是判断输出点在哪里。是HTML标签之间?属性值里?JavaScript字符串里?还是CSS里?针对不同上下文使用不同的测试载荷。

问题3:遇到WAF(Web应用防火墙)怎么办?

  • 识别WAF:发送一些恶意载荷(如<script>alert(1)</script>),观察响应状态码(如403)、是否有特殊的响应头(如X-WAF-Info)、返回的错误页面是否包含WAF厂商信息(如Cloudflare, ModSecurity等)。
  • 绕过技巧
    • 编码混淆:WAF通常基于正则匹配,多层编码或使用罕见编码(如UTF-7)可能绕过。
    • 语法变形:利用HTML/JS的容错性,如标签和属性之间加换行、制表符,使用JavaScript:伪协议的各种变体。
    • 分块传输:利用HTTP分块传输编码(Chunked Transfer Encoding)技术,将恶意载荷拆分成多个数据块,可能绕过一些基于完整请求体检测的WAF。
    • 研究已知绕过:关注安全社区对特定WAF(如Cloudflare, AWS WAF, ModSecurity核心规则集)的绕过研究。

问题4:在CTF或靶场中,如何系统性地解题?

  • 第一步:信息收集。查看页面源码,找输出点。尝试输入普通文本,看它出现在页面的哪个位置(是直接输出,还是放在标签属性里?)。
  • 第二步:探测过滤。输入一系列测试字符< > ‘ “ &/等,看哪些被过滤、删除、转义或编码。
  • 第三步:判断上下文。根据输出位置,确定是HTML内容、HTML属性、JavaScript还是其他上下文。
  • 第四步:尝试通用绕过。根据上下文,尝试本章提到的各种编码、事件处理器、伪协议、畸形语法等。
  • 第五步:利用提示。CTF题目往往有提示,比如题目名“过滤了特殊符号”,那就明确指向了编码或利用未过滤字符构造事件处理器等技巧。
  • 第六步:利用浏览器特性。某些题目可能依赖特定浏览器(如老版本IE)的怪异解析模式。

一个排查案例:在某次测试中,输入<img src=x onerror=alert(1)>被转义成了<img src=x onerror=alert(1)>,但弹窗依然没出现。查看控制台发现错误:“alert is not defined”。原来页面运行在严格模式(strict mode)下,并且alert函数被重命名或未定义。这说明即使脚本执行了,环境也可能受限。最终通过window.alerttop.alert来调用成功。这个案例告诉我们,成功注入脚本标签或事件处理器只是第一步,确保其中的代码能在当前页面上下文中正确执行同样重要。

理解XSS过滤与绕过,是一场关于细节的战争。它要求你对前端技术栈(HTML、JS、浏览器)、后端处理逻辑(编码、解码、过滤顺序)以及正则表达式都有深入的理解。对于开发者,这意味着必须采用“安全默认”的原则,在任何地方输出不可信数据时,都问自己一句:“这个上下文,我编码对了吗?”对于安全人员,这意味着需要像浏览器一样思考,像解析器一样分析,才能发现那些隐藏在看似无害字符背后的致命裂缝。