XSS前端防御———对WHCTF中xss题目filter.js的分析

最早的时候是从今年的Bctf的xss题目中遇到的filter.js这种过滤形式,但当时对xss题目一直全程懵逼所以也没有留下代码来好好分析过这种过滤,这次Ben师傅出的xss题目恰好使用了这种防御机制,于是打算好好整理一下。(PS:一看到题目的背景图片就知道这题一定是Ben师傅出的,赛后一问果然是2333)
这种前端的XSS防御主要由两部分构成:

  • 静态检测
  • 动态检测

首先阐述下filter.js这种的前端xss防御基本原理:

在HTML文档的最开头部分就引入这些js代码,需要保证在这些代码执行的时候敏感标签还没有生成,只有这样才会触发我们的检测,比如最简单的一个html文件

1
2
3
4
5
6
7
8
<meta http-equiv="Content-Security-Policy" content="default-src 'self'; script-src 'self' 'unsafe-inline';style-src 'self' 'unsafe-inline';img-src *;">
//注意我们尽早的引入了filter.js,使其在下面的标签还没有渲染的时候就开始执行了,这样才能够成功监测到之后标签的各种行为
<script src='filter.js'></script>
<h2>Phone</h2> sdsd<br><h2>secret</h2><br>
<iframe src="flag.php" id="xie"></iframe>
<script>
alert('123');
</script>

我们从静态检测开始

在阐述具体的实现代码之前,我们必须了解静态检测的局限性,这十分重要所以我不得不先声明:

  • 如果用动态代码加载的话,就算监听到了标签执行,也无法得到加载的动态代码,就比如script的src
  • 使用js动态生成的标签不会触发任何事件,静态检测只能监听那些一开始在html文件里有的标签

静态检测的原理就是利用MutationObserver对象实现对标签行为的监测,设定在哪些标签行为下触发我们的自定义处理函数

  1. 首先是做一些配置
    var MutationObserver = window.MutationObserver || window.WebKitMutationObserver || window.MozMutationObserver;
  2. 然后我们实例化一个新的Mutation观察者对象

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    var observer = new MutationObserver(function(mutations) {
    mutations.forEach(function(mutation) {
    nodes = mutation.addedNodes;
    for (var i = 0; i < nodes.length; i++){
    var node = nodes[i];
    //这里就可以基于捕获的node来愉快的进行自定义的操作了!!
    });
    });
  3. 为观察者对象指定配置——声明观察者在哪些标签触发什么行为下调用函数
    这里推荐一组练习,看过之后基本就明白了

    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
    observer.observe(target, {childList:true}) //childList属性只观察子节点的新建与删除,子节点本身的任何变化都不会去理会
    target.appendChild(document.createElement("div")) //添加了一个元素子节点,触发回调函数.
    target.appendChild(document.createTextNode("foo")) //添加了一个文本子节点,触发回调函数.
    target.removeChild(target.childNodes[0]) //移除第一个子节点,触发回调函数.
    target.childNodes[0].appendChild(document.createElement("div")) //为第一个子节点添加一个子节点,不会触发回调函数,如果需要触发,则需要设置subtree属性为true.
    observer.observe(target, {childList:true,subtree:true}) //subtree属性让观察行为进行"递归",这时,以target节点为根节点的整棵DOM树发生的变化都可能会被观察到
    observer.observe(document, {childList:true,subtree:true}) //如果target为document或者document.documentElement,则当前文档中所有的节点添加与删除操作都会被观察到
    observer.observe(document, {childList:true,attributes:true,characterData:true,subtree:true}) //当前文档中几乎所有类型的节点变化都会被观察到(包括属性节点的变化和文本节点的变化等)
    observer.observe(target, {childList:true}) //假设此时target的outHTML内容为<div>foo<div>,则:
    target.childNodes[0].data = "bar" //不会触发回调函数,因为childList只观察节点的新建与删除,而这里target节点的子节点仍然只有一个,没有多,没有少
    observer.observe(target, {childList:true,characterData:true}) //加上characterData属性,允许观察文本节点的变化,行不行?
    target.childNodes[0].data = "bar" //还是不会触发回调函数,因为发生变化的是target节点的子节点,我们目前的目标节点只有一个,就是target.
    observer.observe(target, {childList:true,characterData:true,subtree:true}) //加上subtree属性,观察所有后代节点
    target.childNodes[0].data = "bar" //触发了回调函数,发生变化的是target节点的文本子节点(必须同时有characterData和subtree属性,才能观察到一个元素目标节点里的文本内容的变化)
    observer.observe(target, {attributes:true}) //只观察目标节点的属性节点
    target.setAttribute("foo","bar") //不管foo属性存在不存在,都会触发回调函数
    target.setAttribute("foo","bar") //即使前后两次的属性值一样,还是会触发回调函数
    target.removeAttribute("foo") //移除foo属性节点,触发回调函数
    target.removeAttribute("foo") //不会触发回调函数,因为已经没有属性节点可移除了
    observer.observe(target, {attributes:true,attributeFilter:["bar"]}) //指定要观察的属性名
    target.setAttribute("foo","bar") //不会触发回调函数,因为attributeFilter数组不包含"foo"
    target.setAttribute("bar","foo")

嗯,然后我们就可以实现对所有标签的触发事件监听了,下面是filter.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
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
function interceptionStaticScript()
{
var MutationObserver = window.MutationObserver || window.WebKitMutationObserver || window.MozMutationObserver;
//实例化一个新的Mutation观察者对象
var observer = new MutationObserver(function(mutations) {
mutations.forEach(function(mutation) {
begininterceptionstatic(mutation)
});
});
//为观察者对象传入要观察的对象,并设置config
//这里的对象为document,配置为subtree+childList,也就表示会在所有的节点的子节点新建或时触发事件
observer.observe(document, {
subtree: true,
childList: true
});
}
//事件触发时的响应函数
function begininterceptionstatic(mutation)
{
var nodes = mutation.addedNodes;
for (var i = 0; i < nodes.length; i++)
{
var node = nodes[i];
//挑选出来script标签与iframe标签
if (node.tagName === 'SCRIPT' || node.tagName === 'IFRAME')
{
//如果iframe标签有srcdoc的话就在dom里删除此标签
if (node.tagName === 'IFRAME' && node.srcdoc)
{
node.parentNode.removeChild(node);
console.log('f4ck you! don\'t use iframe', node.srcdoc);
}
//检测script以及iframe标签的匹配方式,只有通过白名单的才行
else if (node.src)
{
if (!whileListMatch(whiteList, node.src))
{
node.parentNode.removeChild(node);
console.log('f4ck you!', node.src);
}
}
//对iframe以及script标签的内容也就是innerHTML进行黑名单检测
else if (blackregmatch(jswordblacklist,node.textContent))
{
node.parentNode.removeChild(node);
console.log('f4ck you! '+node.textContent);
}
}
}
}

开始我们的动态检测部分

动态检测相比于静态检测更加准确,毕竟这是从原生代码层面上进行的重写,所有的行为最终还是要调用这些系统原生代码的,就算是静态检测无法防御的动态标签生成也可以被检测到
动态检测采用了Hook的原理,通过对一些敏感函数进行Hook后,在其运行期间加上一些代码以起到防御的作用。

首先我们来尝试所有标签src属性的Hook

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
//保存原始的setter变量
var raw_setter = HTMLScriptElement.prototype.__lookupSetter__('src');
//重写Setter访问器,这个访问器是会在src属性添加时调用的系统代码
HTMLScriptElement.prototype.__defineSetter__('src', function(url) {
//自定义对src的操作
if (!whileListMatch(whiteList,url))
{
console.log('f4ck you! '+url);
return ;
}
//处理结束的时候通过刚才被保存的原始setter变量继续执行系统代码
raw_setter.call(this, url);
});

类似的,Hook所有的innerHTML生成:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
//同样先保存原始的setter变量
var raw_setter = HTMLScriptElement.prototype.__lookupSetter__('innerHTML');
//然后重写setter访问器
HTMLScriptElement.prototype.__defineSetter__('innerHTML', function(url) {
//然后添加任意处理代码
if (blackregmatch(wordblacklistinnerHTML, url))
{
console.log('f4ck you! '+url);
return ;
}
//处理结束的时候通过刚才被保存的原始setter变量继续执行系统代码
raw_setter.call(this, url);
});

尝试Hook一个系统函数document.write(),对其参数进行操作处理

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
//首先获得这个函数的对象
var old_write = window.document.write;
//然后重写此函数
window.document.write = function(string)
{
//基于函数的参数进行自定义的操作
if (blackregmatch(wordblacklistDocumetnWrite, string))
{
console.log('f4ck you! ', string);
return;
}
//接着运行函数原生代码:使用函数对象.apply(document, arguments)这种写法
old_write.apply(document, arguments);
}

同理,我们Hook敏感的ajax函数

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
//存储xhr对象open函数以便之后继续运行
var s_ajaxListener = new Object();
s_ajaxListener.tempOpen = this.XMLHttpRequest.prototype.open;
s_ajaxListener.tempSend = this.XMLHttpRequest.prototype.send;
//开始重写xhr的open函数
this.XMLHttpRequest.prototype.open = function(a,b)
{
//在执行xhr的open函数前插入自定义的操作
if (!a) var a=' ';
if (!b) var b=' ';
var open_method=a;
//拿到xhr的url,并对其做白名单检测
var open_url=b;
if (!whileListMatch(whiteList,open_url))
{
console.log('f4ck you! url:'+open_method+':'+open_url);
return ;
}
//接着运行函数原生代码
s_ajaxListener.tempOpen.apply(this, arguments);
}

我们这些对象都是在当前的window窗口下操作,并不能Hook到iframe标签内的各种行为,所以我们还需要将所有的iframe标签的窗口都进行Hook,这里代码是使用静态检测完成的

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
var MutationObserver = window.MutationObserver || window.WebKitMutationObserver || window.MozMutationObserver;
var observer = new MutationObserver(function(mutations)
{
mutations.forEach(function(mutation)
{
var nodes = mutation.addedNodes;
for (var i = 0; i < nodes.length; i++)
{
//将installHook的操作对所有iframe中的标签也进行Hook,但是这里还是存在静态检测的固有问题的
var node = nodes[i];
if (node.tagName == 'IFRAME')
{
installHook(node.contentWindow);
}
}
});
});

过滤代码的一些问题

实际上这种过滤方式如果配置严格可以起到很强的过滤性,但据Ben师傅说他出题时不小心将filter.js用成了修改之前的,所以过滤效果微乎其微,通读下来,是有这些原因的

  • 首先过滤方式分为黑名单过滤与白名单过滤,黑名单那个倒是没多少问题,但是白名单的正则式里面有这一条.*?,这是会匹配到所有字符串的,所以白名单根本就没有起到过滤效果。。。
  • 另外一点是,js毕竟是种写法多样的语言,一种写法被过滤了,总有相应的其他写法来代替,而本filter.js文件中其实并没有对多少关键写法进行限制,像<iframe src="javascript:">,xhr,等等都没有限制严格,所以实际上很多写法都是可行的