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 为什么这种过滤天生脆弱?
这种基于正则的过滤方法,从设计之初就埋下了几个致命的缺陷:
- 过度依赖单一层级的过滤:它通常只在数据进入应用的某个节点(如接收
$_GET[‘input’]时)执行一次过滤。但数据在应用中可能流经多个上下文(HTML标签内、属性值、JavaScript字符串、CSS样式、URL),每个上下文对特殊字符的解析规则都不同。一次性的、针对HTML的过滤,无法应对其他上下文。 - 正则表达式的局限性:正则引擎本身是“模式匹配”工具,不是“语法解析器”。它无法理解HTML或JavaScript的复杂语法结构。攻击者可以利用正则引擎的匹配逻辑(如贪婪/非贪婪、回溯)和浏览器的容错性之间的差异来制造绕过。
- “黑名单”思维定势:列出“坏”的字符,永远会有遗漏。新的HTML特性、JavaScript API、浏览器解析怪癖层出不穷,黑名单难以穷尽。
- 编码与解码的时机错位:这是绕过中最常见的突破口。如果过滤发生在URL解码或HTML实体解码之前,那么攻击者提交的经过编码的载荷就能轻松绕过过滤,最终在浏览器端被解码还原成恶意代码。
理解了防御的薄弱环节,我们就可以像一位耐心的锁匠,开始寻找锁芯里的每一处机关。
3. 核心绕过技巧深度解析
绕过“过滤特殊符号”的正则,本质上是寻找一条从“用户输入”到“浏览器执行”的路径,这条路径上至少有一个环节,过滤逻辑是缺失、错误或可以被欺骗的。我们将从易到难,剖析几种经典的绕过思路。
3.1 利用编码与多重编码
这是最基础、也最有效的绕过方式。核心原理是:过滤函数处理的是“字面量”字符,而浏览器解析的是“解码后”的字符。
HTML实体编码绕过: 假设过滤函数删除了
<和>。我们提交的载荷不是<script>alert(1)</script>,而是:<script>alert(1)</script>对于那个简单的
/[<>\"\'\&]/正则来说,这个字符串里没有字面上的<或>,只有&、l、t等字符。如果过滤函数也删除了&,那这个载荷也会被拦截。但很多初级过滤只删尖括号和引号,会放过&。当这个字符串被输出到HTML页面时,浏览器会将其解码为<script>alert(1)</script>,从而执行。实操心得:测试时,不仅要测试
<和>,还要测试十进制(<)和十六进制(<)实体编码。有时过滤可能只拦截了命名实体,而忽略了数字形式。URL编码绕过: 如果输入点最终会被放入一个URL上下文(比如
<a href=”INPUT”>),那么URL编码就可能生效。例如,构造javascript:alert(1)被过滤了,可以尝试:javascript%3aalert%281%29如果服务器端在过滤前没有进行URL解码,那么
%3a(冒号)和%28(左括号)就不会被识别为特殊字符。当这个字符串被设置为href属性并点击时,浏览器会对其进行URL解码,还原出可执行的javascript:协议。多重编码与混合编码: 这是对付“过滤后解码”逻辑的利器。假设防护逻辑是:先过滤危险字符,然后对剩余内容进行HTML实体编码(一种错误但存在的逻辑)。攻击者可以提交双重编码的载荷:
&lt;script&gt;alert(1)&lt;/script&gt;第一层过滤看到的是
&lt;,里面没有<,放行。然后系统对其进行HTML编码,变成&amp;lt;(&被编码成&)。输出到浏览器后,浏览器第一次解码得到<,第二次解码就得到了<,绕过成功。注意:多重编码的成功与否极度依赖于服务器端处理数据的顺序。在实战中,需要通过模糊测试(Fuzz)来探测数据处理流水线。
3.2 利用正则表达式本身的特性
正则引擎不是万能的,它的匹配方式可以被利用。
换行符绕过: 回顾那个匹配
<script>标签的正则:/<script.*?>.*?<\/script>/is。s标志让.可以匹配换行符,但如果我们遇到一个没有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中,如果输出点使用反引号,可以尝试闭合它。
eval或setTimeout动态执行:如果字符串最终被传递给eval或setTimeout,那么编码、字符串拼接等技巧就有了更大的发挥空间。
- 闭合字符串与语句:假设输出点在
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:这个协议声明进行编码或变形,例如:JavaScript: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实体转义。似乎很安全?但顺序是关键!
攻击流程推演:
信息收集:我们提交一个测试载荷
‘“<>&,观察输出。发现页面上显示为'"<>&。这说明htmlspecialchars生效了,并且是在过滤之后执行的。这意味着,我们提交的任何字符,只要逃过了第一层的preg_replace,就会被第三层转义成实体,从而无害化。所以,主攻方向是第一层正则删除。分析第一层正则:
/[<>\"\'\&]/。它删除字符<,>,",',&。我们的目标是让一个可执行的XSS向量通过这些过滤。尝试编码绕过:直接提交
<img src=x onerror=alert(1)>。第一层正则看到的是<img src=x onerror=alert(1)>,它包含&字符,所以&被删除,字符串变成lt;img src=x onerror=alert(1)gt;。这显然不行。尝试无符号事件处理器:构造
<img src=x onerror=alert(1)>。第一层正则会删除<,>,=两边的空格?不,正则只删除列表里的字符,空格和字母不会被删。所以过滤后变成img src=x onerror=alert1。onerror变成了onerror,alert(1)变成了alert1,括号被删了。失败。利用正则删除后的字符串拼接:这是一个关键思路。如果我们能让过滤后的字符,在浏览器解析时重新组合成危险字符呢?考虑HTML实体编码的另一种形式:
&#xHH;(十六进制)或&#DDD;(十进制)。如果我们提交<img src=x onerror=alert(1)>,第一层正则会删除&和;吗?正则模式是/[<>\"\'\&]/,它只删除&,不删除;。所以过滤后变成#x3c;img src=x onerror=alert(1)#x3e;。这仍然不是有效的HTML实体。换个思路:聚焦属性值:假设输出不在标签内容区,而是在一个标签的属性值里,并且属性值没有用引号括好(这是开发者的错误)。例如,后端代码是
<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在最后一步转义,但因为它接收到的输入里已经没有<>&”‘这些字符了(第一层删掉了),所以它无事可做。
- 过滤逻辑只删除了特定的几个字符,但XSS载荷(
如果属性值有引号呢?如果代码是
<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字符串的一部分,不会被执行。这时,我们需要闭合引号。但引号被过滤了。我们可以尝试用"(HTML实体)吗?不行,&会被过滤。所以,在这个特定过滤函数下,如果属性值被正确引号包裹,且过滤了引号,直接注入事件处理器会非常困难。这时可能需要寻找其他注入点(如标签内容区、CSS、URL参数等)。
案例总结:这个实战推演展示了绕过思维的核心——精确理解数据流和上下文。我们不仅需要知道过滤了什么,更需要知道过滤后的数据被用在了哪里(什么标签、什么属性、是否有引号包裹、是否在JS块中)。一次成功的绕过,往往是特定过滤漏洞与特定输出上下文漏洞的结合。
5. 防御之道:从源头杜绝绕过
分析了这么多绕过技巧,作为开发者,应该如何构建真正有效的XSS防护呢?答案是:放弃单纯依赖正则过滤特殊符号的黑名单思维,转向白名单和上下文相关的输出编码。
输出编码(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编码。
使用成熟的防护库:
- 不要自己造轮子。使用经过严格安全审计的库,如OWASP的Java Encoder Project、PHP的HTML Purifier(用于富文本HTML)、.NET的AntiXSS Library(现并入System.Web.Security.AntiXss)等。这些库提供了针对不同上下文的编码方法。
内容安全策略(CSP):
- CSP是一个终极的深度防御策略。它通过HTTP头告诉浏览器,只允许加载和执行来自特定来源的脚本、样式、图片等。即使网站存在XSS漏洞,攻击者也无法注入并执行不在白名单内的脚本。例如,一个严格的CSP头可以是:
Content-Security-Policy: default-src ‘self’; script-src ‘self’ https://trusted.cdn.com;。这能极大缓解XSS的影响。
- CSP是一个终极的深度防御策略。它通过HTTP头告诉浏览器,只允许加载和执行来自特定来源的脚本、样式、图片等。即使网站存在XSS漏洞,攻击者也无法注入并执行不在白名单内的脚本。例如,一个严格的CSP头可以是:
输入验证与白名单:
- 在接收输入时,根据预期的数据类型进行严格的白名单验证。例如,如果是电话号码字段,只允许数字、加号、括号和短横线;如果是姓名字段,可以限制字符集和长度。这能在最早阶段阻断畸形数据。
避免危险的操作:
- 绝对避免将用户输入直接传递给
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.alert或top.alert来调用成功。这个案例告诉我们,成功注入脚本标签或事件处理器只是第一步,确保其中的代码能在当前页面上下文中正确执行同样重要。
理解XSS过滤与绕过,是一场关于细节的战争。它要求你对前端技术栈(HTML、JS、浏览器)、后端处理逻辑(编码、解码、过滤顺序)以及正则表达式都有深入的理解。对于开发者,这意味着必须采用“安全默认”的原则,在任何地方输出不可信数据时,都问自己一句:“这个上下文,我编码对了吗?”对于安全人员,这意味着需要像浏览器一样思考,像解析器一样分析,才能发现那些隐藏在看似无害字符背后的致命裂缝。