xss-labs 是一套基于 PHP 的 XSS 靶场,以闯关的形式让我们体验各种 XSS 漏洞利用方式。虽然已经是很多年前的了,但其中基本的 XSS 漏洞依然很有意义。

一共有 20 关,每一关的目标都是实现弹窗(alert(1)),如果成功就会自动进入下一关。

环境搭建

克隆这个代码仓库的代码,其中是 xss-labs 的 PHP 源文件。只要放在 PHP 环境下即可,推荐使用 XAMPP 或者 Docker。

部署完成后,访问 index.php,就可以看到入口了:

「点击图片开始你的 XSS 之旅吧」提示

Warm Up

前两关是对于 XSS 基本原理的应用,没有任何的防御。

Level 1:文本解析为 HTML

URL 为 level1.php?name=test 时,传入的参数是 test,显示的是「欢迎用户 test」。显然,这个页面会将我们传入的名字显示出来。

传入参数 <h1>test</h1> 作为 name,发现确实显示为了一级标题。看来传入的 name 直接被作为 HTML 显示了。于是尝试传入 <script>alert(1);</script> 作为 name,成功弹窗。

?name=<script>alert(1);</script>

Level 2:input 标签 value 注入

这关多了一个文本框。如果依然尝试传入 <script>alert(1);</script>,会发现在 h2 元素中符号被转义了(查看网页源代码能看到),比如 < 被转义成了 &lt; 等,所以不会被解析成 HTML。

然而,可以看到后面的文本框 input 元素,其 value 值并没有被转义。

<h2 align=center>没有找到和&lt;script&gt;alert(1)&lt;/script&gt;相关的结果.</h2><center>

<input name=keyword  value="<script>alert(1)</script>">

既然如此,我们可以闭合 value 的内容的后引号,然后闭合这个 input 标签,接下来再加入我们想要注入的 <script>alert(1);</script>。只需要在其之前加上 "> 即可。

?keyword="><script>alert(1);</script>

字符过滤绕过

Level 3:htmlspecialchars() 的弱点

本题要求 PHP 版本低于 8.1.0。在 8.1.0 版本中此漏洞已经修复。

HTML 标准规定了一个叫做实体(Entity)的概念。在 HTML 中,<h1> 会被解释为标题标签,那我们如何以文本形式显示出 <h1> 这四个字符呢?答案是使用字符实体,将 <&lt; 替代,将 >&gt; 替代。在 HTML 中,如果我们写 &lt;h1&gt;,就会显示为 <h1> 而不会被解析。

在 PHP 中,后端需要渲染出一个 HTML 给前端,对传来的字符串就要进行这样的处理,将 < 这样的字符转换为 &lt; 这样的实体。Level 1 就是没有进行这种处理的下场。

一个通常的做法就是:使用 PHP 自带的 htmlspecialchars() 函数处理字符串,进行字符转义。具体用法可以参考官方文档

在 8.1.0 及以上的 PHP 版本中,这个函数默认会转义 <>&'" 这五个字符,基本可以防范这里的 XSS 攻击。
但是,8.1.0 以下版本的 PHP 默认只会转义 <>&" 这四个字符,不会转义单引号 '。这就给这个函数带来了巨大的安全隐患。

回到这题,我们仍然尝试 ?keyword=<script>alert(1)</script>,打开页面源码,观察这个 input 输入框:

<input name=keyword  value='&lt;script&gt;alert(1)&lt;/script&gt;'>    

没错,我们的字符被转义了。

然而,可以发现这里 value 的值用的是单引号。既然单引号不会被转义,我们可以闭合 value 这个字符串。

但是,<> 都会被转义,似乎不能闭合这个标签。有什么办法能够不用 <script> 标签来注入 JavaScript 代码呢?答案是使用触发器,比如 onfocus 或者 onmouseover

<input name=keyword  value='' onmouseover=alert(1) ''>

所以,本题的 payload 是:

?keyword=' onmouseover=javascript:alert(1) '

Level 4:没有过滤双引号

还是先试试 ?keyword=<script>alert(1)</script>,发现 HTML 源码里是这样的:

<input name=keyword  value="";scriptalert(1);/script">

似乎代码的编写者使用了一个自定义的字符过滤函数,过滤了 <>(替换为空),但是却没有过滤 "。使用 onmouseover 即可。

?keyword=" onmouseover=alert(1) "

Level 5:href 的危险

在这一关,当我们尝试之后发现,传入的字符串里 script 会被替换为 scr_ipton 会被替换为 o_n。也就是说,之前两种运行 js 脚本的办法这关里都无法使用。还有没有其他办法呢?

答案是肯定的。可以利用 JavaScript 的 URI。看看下面这个链接:

<a href=javascript:alert(1)>hack</a>

不同于许多人的印象,<a> 标签的 href 值并非只能是 URL,而是 URI。URI(Uniform Resource Identifier)可以视为 URL 的超集,其不仅包含以 protocol://address 开头的 URL,也包含 protocol:content 这种形式的地址(参见 RFC 3986)。我们经常用到的有:javascript: 后接 js 代码,这样的 URI 打开后会运行一段 js 代码;mailto: 后接一个邮箱,这样的 URI 打开后会开启系统中的邮箱类应用程序,创建发送给目标地址的一封邮件。

所以,这题我们也可以利用这个运行 js 代码。

?keyword="> <a href=javascript:alert(1)>hack</a>

Level 6:很蠢的字符过滤

还是使用上一题的伎俩,会发现 href 被替换为 hr_ef

事实上,这题使用了很蠢的字符过滤:区分大小写地搜索敏感词。而 HTML 并不区分大小写。所以将 href 写为 hREF 之类的即可。

?keyword="> <a hREF=javascript:alert(1)>hack</a>

Level 7:字符串替换,但只做一次

尝试过后,会发现这关里 script 这个字符串会被过滤,即替换为空字符串。

然而,将所有搜索到的这个字符串替换为空后,对于拼接得到的新字符串,没有再一次进行检测。这是一个非常常见的安全问题。我们只要「双写」关键词即可。例如,scriscriptpt 被扫描替换一次后,会变成 script

?keyword="> <scriscriptpt>alert(1);</scriscriptpt>

Level 8:URI 中的实体

从这关开始,input 的 value 也严格地使用了 htmlspecialchars() 函数进行转义,从而无法注入。好在提供了另一个注入点:a 标签的 href 属性。

尝试之前的 javascript:alert(1),发现 javascript 被替换成了 javascr_ipt

<center><BR><a href="javascr_ipt:alert(1)">友情链接</a></center>

这里要用到 href 属性的一个特性:href 传入的 URI 中,也可以使用 HTML 字符实体。在打开链接时,字符实体也会被转换为对应的字符。

HTML 实体有两种写法,第一种是之前提到的 &entity_name; 形式,比如 $lt; 表示小于号;第二种是 &#entity_number; 形式,其中 entity_number 是字符的实体编号,比如 &#60; 也能表示小于号。使用第二种方式,任何字符(包括 ASCII 字符)都有其实体表示。可以使用这个工具来转换。

对于这题,为了绕过 javascript 这个词的屏蔽,我们将 i 写为其字符实体 &#x69; 即可。

javascr&#x69;pt:alert(1)

Level 9:单纯的「必须包含」

如果使用上一题的 payload,会提示链接不合法。如果使用一个合法的链接,比如 http://test,则可以成功添加。

仅过尝试可以发现,后端只检测传入的链接字符串是否包含 http://,并不要求其在开头。非常弱智的检测方式(而且甚至没有考虑 https)。那么我们只要在注入的 js 代码里加上注释,里面写上 http:// 就行了。

javascr&#x69;pt:alert(1)//http://

字段注入

下面这四关和 level 2 差不多,区别只在于能够注入的字段不同。Level 2 中我们在 input 标签的 value 中注入,而下面几关分别在不同的注入点进行注入。

Level 10:隐藏表单字段注入

这关乍一看似乎并没有可用的注入点。其实查看源码可以看到这个隐藏的表单:

<h2 align=center>没有找到和well done!相关的结果.</h2><center>
<form id=search>
<input name="t_link"  value="" type="hidden">
<input name="t_history"  value="" type="hidden">
<input name="t_sort"  value="" type="hidden">
</form>

我们尝试请求的时候给出 t_linkt_historyt_sort 几个参数,发现 t_sort 的 value 字段对应其参数。举例来说,尝试这个 payload:

?keyword=test?t_link=tlink&t_history=thist&t_sort=tsort

看到 HTML 的对应部分变成了这样:

<h2 align=center>没有找到和test?t_link=tlink相关的结果.</h2><center>
<form id=search>
<input name="t_link"  value="" type="hidden">
<input name="t_history"  value="" type="hidden">
<input name="t_sort"  value="tsort" type="hidden">
</form>

看来可以用 t_sort 的 value 这个字段来注入。

仅过尝试,对这个参数的处理上过滤了 <>,没有过滤双引号。所以我们可以直接闭合双引号,添加 onmouseover 属性来达到我们的目的。

但是依然有一个问题:这个元素是 hidden 的,意味着不会显示出来,不管是 focus 还是 mouseover 都不能触发。隐藏的状态是由最后的 type="hidden" 设置的,那我们在其之前加入一个空的 type 就可以覆盖掉后面的设置。也就是说,注入之后的 input 会变成这样:

<input name="t_sort" value="" onmouseover=javascript:alert(1) type "" type="hidden">

Payload 如下;

?keyword=test&t_sort=" onmouseover=javascript:alert(1) type "

Level 11:Referer 注入

如果是完成了前面一关跳转过来的,可以看到源码里的 t_ref 字段是跳转来的 URL(也就是请求的 Referer):

<form id=search>
...
<input name="t_ref"  value="http://localhost/xss-labs/level10.php?keyword=test&t_sort=%22%20onmouseover=javascript:alert(1)%20type%20%22" type="hidden">
</form>

只要存在可以自定义的字段,我们就可以注入。一种方法是创建一个文件名为 " onmouseover=javascript:alert(1) type ".html 的 HTML 文件,在其中重定向到 level 11 的页面。
更简洁的做法是直接修改请求的 Referer 字段。

关于 Referer,推荐阅读:[HTTP Referer 教程 - 阮一峰的网络日志
](https://www.ruanyifeng.com/blog/2019/06/http-referer.html)。

Level 12:UA 注入

和前面的类似,查看源码可以看到有一个字段 value 被设置成了浏览器 UA。

浏览器 UA 是完全可以自定义的,随便下载一个自定义 UA 的浏览器插件,在里面将 UA 设置为 payload,然后直接访问 level 12 即可。

" onmouseover=javascript:alert(1) type "

Level 13:Cookie 注入

和 level 12 一样,有一个字段 value 设为了一个 Cookie。

直接打开浏览器 F12 工具,找到这个对应的名为 user 的 Cookie,设置为 payload,然后刷新网站即可。

" onmouseover=javascript:alert(1) type "

Level 14 嵌入了一个网站,这个网站已经打不开了。似乎是个查看图片 exif 信息的网站,猜测可能可以将 payload 写在 exif 信息里进行注入吧。

Level 16:空格过滤

这题似乎也是过滤,使用了综合的过滤规则。经过一番探究,script/ 都会被替换为 &nbsp;,每个空格也都会被换成 &nbsp;,这正是本题最麻烦之处。

HTML 中,有什么字符可以代替空格呢?答案是换行符(%0A)。

?keyword=<img%0Asrc=1%0Aonerror="alert(1)">

Special

Level 15:Angular ng-include

在 URL 中指定的 src(默认是 1.gif)会被引用进来,使用的是 ng-include。这是一个 Angular 框架的功能,特点是,如果引入的是 HTML 文件,不会执行其中 <script> 标签内的代码。(有一点安全意识,但不多……)

一种方法是写一个能够弹窗的简单 HTML,然后 include 进来,比如:

<html>
<h1>Hacked</h1>
<img src=1 onerror="alert(1)"></img>
</html>

假设放在网站根目录,payload 就是:

?src="/alert.html"

Level 17 到 Level 20 都是 Flash 的利用,现在 Flash 已经退出历史舞台了,所以这几关已经失去意义。

总结

XSS 攻击历久弥新,目前 XSS 漏洞依然广泛存在。其核心在于找到注入点并通过 payload 让浏览器执行 js 代码。

注入点不仅可以来源于用户提供的文本(请求参数或后端存储的字段),也可以是 Referer、UA、Cookie 等。

执行 js 代码一般有四种方式,在使用时可以根据条件灵活选择:

一般来说,使用 PHP 的 htmlspecialchars() 函数能够防御很大一部分 XSS(注意过滤单引号)。许多字符过滤上的漏洞都是没有使用 htmlspecialchars() 而使用自己编写的字符过滤函数引起。