JavaScript防http劫持与XSS

本篇整理一些常见情况下前端对于网络攻击的防范。

常见网络攻击

web前端常见的需要注意和防范的就是 XSS , CSRF跨站请求伪造 , HTTP劫持、DNS劫持。

XSS跨站脚本

XSS指的是攻击者漏洞,向 Web 页面中注入恶意代码,当用户浏览该页之时,注入的代码会被执行,从而达到攻击的特殊目的。

关于这些攻击如何生成,攻击者如何注入恶意代码到页面中本文不做讨论,只要知道如 HTTP 劫持 和 XSS 最终都是恶意代码在客户端,通常也就是用户浏览器端执行,本文将讨论的就是假设注入已经存在,如何利用 Javascript 进行行之有效的前端防护。

CSRF跨站请求伪造

CSRF攻击大概的意思就是,攻击者盗用了你的身份,以你的名义发送恶意请求。CSRF能够做的事情包括:以你名义发送邮件,发消息,盗取你的账号,甚至于购买商品,虚拟货币转账……造成的问题包括:个人隐私泄露以及财产安全。

HTTP劫持

什么是HTTP劫持呢,大多数情况是运营商HTTP劫持,当我们使用HTTP请求请求一个网站页面的时候,网络运营商会在正常的数据流中插入精心设计的网络数据报文,让客户端(通常是浏览器)展示“错误”的数据,通常是一些弹窗,宣传性广告或者直接显示某网站的内容,大家应该都有遇到过。

DNS劫持

DNS劫持就是通过劫持了DNS服务器,通过某些手段取得某域名的解析记录控制权,进而修改此域名的解析结果,导致对该域名的访问由原IP地址转入到修改后的指定IP,其结果就是对特定的网址不能访问或访问的是假网址,从而实现窃取资料或者破坏原有正常服务的目的。

DNS 劫持就更过分了,简单说就是我们请求的是 http://www.a.com/index.html ,直接被重定向了 http://www.b.com/index.html

页面被嵌入iframe ,重定向iframe

这种页面被嵌入iframe的情况,也就是,网络运营商为了尽可能地减少植入广告对原有网站页面的影响,通常会通过把原有网站页面放置到一个和原页面相同大小的 iframe 里面去,那么就可以通过这个 iframe 来隔离广告代码对原有页面的影响。

image

  • 检测我们的页面是否存在于iframe
    • window.self : 返回一个指向当前 window 对象的引用。
    • window.top : 返回窗口体系中的最顶层窗口的引用。
    • 对于非同源的域名,iframe子页面无法通过parent.location 或者 top.location 拿到具体的页面地址,但可以写入top.location,也就是可以控制父页面的跳转。
1
2
3
4
5
//两个属性可以简写为self 与 top,当发现我们的页面被嵌套在iframe中,可以重定向到父级页面。
if(self != top){
var url = location.href; //我们的正常页面
top.location = url; //父级页面重定向
}

使用白名单放行正常iframe嵌套

很多时候,由于运营商需要,我们的页面会被以各种方式推广,也有可能是正常业务需要被嵌套在iframe中,这个时候我们需要一个白名单或者黑名单,当我们的页面被嵌套在iframe中,且父级页面域名存在白名单中,则不做重定向操作。

  • 使用top.location.href 是无法拿到父级页面的 URL 的,这时候,就需要使用document.referrer。
  • 通过document.referrer 可以拿到跨域iframe父页面的URL。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
//白名单
var whiteList = {
'www.aaa.com',
'www.bbb.com'
};

if(self != top){
//document.referrer 可以拿到跨域iframe父页面的URL
var parentURL = document.referrer;
var length = whiteList.length;
var i = 0;
for(;i<length;i++){
// 建立白名单正则
var reg = new RegExp(whiteList[i],'i');

// 存在白名单中,放行
if(reg.test(parentUrl)){
return;
}
}
//重定向
var url = location.href;
top.location = url;
}

更改 URL 参数绕过运营商标记

你以为这样就完了么?没有,虽然我们重定向页面,但是在重定向的过程中,既然第一次可以被嵌套,那么这一次重定向的过程也可能被嵌套。

当然,运营商这种劫持通常是有迹可寻的,

  • 最常规的手段在页面URL中设置一个参数,例如http://www.example.com/index.html?iframe_hijack_redirected=1 ,其中iframe_hijack_redirected=1 表示页面已经被劫持过了,就不再嵌套 iframe 了。所以根据这个特性,我们可以改写我们的 URL ,使之看上去已经被劫持了:
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
var flag = 'iframe_hijack_redirected';
// 当前页面存在于一个 iframe 中
// 此处需要建立一个白名单匹配规则,白名单默认放行
var whiteList = {

};
if (self != top) {
var
// 使用 document.referrer 可以拿到跨域 iframe 父页面的 URL
parentUrl = document.referrer,
length = whiteList.length,
i = 0;

for(; i<length; i++){
// 建立白名单正则
var reg = new RegExp(whiteList[i],'i');

// 存在白名单中,放行
if(reg.test(parentUrl)){
return;
}
}

var url = location.href;
var parts = url.split('#');
if (location.search) {
parts[0] += '&' + flag + '=1';
} else {
parts[0] += '?' + flag + '=1';
}
try {
console.log('页面被嵌入iframe中:', url);
top.location.href = parts.join('#');
} catch (e) {}
}
  • 当然,如果这个参数一改,防嵌套的代码就失效了。所以我们还需要建立一个上报系统,当发现页面被嵌套时,发送一个拦截上报,即便重定向失败,也可以知道页面嵌入 iframe 中的 URL,根据分析这些 URL ,不断增强我们的防护手段,这个后文会提及。

内联事件和内联脚本拦截

在 XSS 中,其实可以注入脚本的方式非常的多,尤其是 HTML5 出来之后,一不留神,许多的新标签都可以用于注入可执行脚本。

列出一些比较常见的注入方式:

1
2
3
4
5
1. <a href="javascript:alert(1)" ></a>
2. <iframe src="javascript:alert(1)" />
3. <img src='x' onerror="alert(1)" />
4. <video src='x' onerror="alert(1)" ></video>
5. <div onclick="alert(1)" onmouseover="alert(2)" ><div>

除去一些未列出来的非常少见生僻的注入方式,大部分都是 javascript:… 及内联事件 on*。

我们假设注入已经发生,那么有没有办法拦截这些内联事件与内联脚本的执行呢?

对于上面列出的 (1) (5),这种需要用户点击或者执行某种事件之后才执行的脚本,我们是有办法进行防御的,这种拦截,就涉及事件模型相关的原理。

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
//建立黑名单
var keywordBlackList = {
'xss',
'blackList'
};

document.addEventListener('click',function(e){
var code = "";
//扫描 <a href="" ></a>内部脚本
if(elem.tagName="A" && elem.protocol="javascript:"){
var code = elem.href.substr(11);
if (blackListMatch(keywordBlackList,code)) {
elem.href = "javascript:void(0)";
console.log("拦截可疑事件"+code);
}
}
})

function blackListMatch(blackList,value){
var length = blackList;
var i = 0;
for (; i < length; i++) {
//建立黑名单正则
var reg = new RegExp(blackList[i],'i');
if(reg.test(value)){
return true;
}
}
return false;
}

静态脚本拦截

XSS 跨站脚本的精髓不在于“跨站”,在于“脚本”。

通常而言,攻击者或者运营商会向页面中注入一个 是页面加载一开始就存在的静态脚本(查看页面结构),我们使用 MutationObserver 可以在脚本加载之后,执行之前这个时间段对其内容做正则匹配,发现恶意代码则 removeChild() 掉,使之无法执行。

使用白名单对src进行匹配过滤

上面的代码中,我们判断一个js脚本是否是恶意的,用的是这一句:

if (/xss/i.test(node.src)) {}

当然实际当中,注入恶意代码者不会那么傻,把名字改成 XSS 。所以,我们很有必要使用白名单进行过滤和建立一个拦截上报系统。

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
// 建立白名单
var whiteList = {
www.a.com,
www.b.com
};
/*
* [白名单匹配]
* @param {[Array]} WhiteList[白名单]
* @param {[String]} value {需要验证的字符串}
* @return {[Boolean]} false-验证不通过 , true-验证通过
*/
function whiteListMatch(whiteList,value){
var length = whiteList.length,
i = 0;

for (; i < length; i++) {
// 建立正则
var reg = new RegExp(whiteList[i],'i');

if (reg.test(value)) {
return true;
}
}

return false;
}

动态脚本拦截

前面我们使用 MutationObserver 拦截静态脚本,出了静态脚本,与之对应就是动态生成的脚本

1
2
3
4
5
var script = document.createElement('script');
script.type = 'text/javascript';
script.src = 'http://www.example.com/xss/b.js';

document.getElementsByTagName('body')[0].appendChild(script);

要拦截这类动态生成的脚本,且拦截时机要在它插入DOM树中,执行之前,本来是可以监听Mutation Events中的DOMNodeInserted事件的。

Mutation Events 与 DOMNodeInserted
打开 MDN ,第一句就是:该特性已经从 Web 标准中删除,虽然一些浏览器目前仍然支持它,但也许会在未来的某个时间停止支持,请尽量不要使用该特性。

这里不展开描述,有兴趣的同学去了解,由于DOMNodeInserted 不再建议使用,所以监听动态脚本的任务也要交给 MutationObserver。

  • 在实际实践过程中,使用 MutationObserver 的结果和 DOMNodeInserted 一样,可以监听拦截到动态脚本的生成,但是无法在脚本执行之前,使用 removeChild 将其移除,所以我们还需要想想其他办法。

重写setAttribute 与 document.write

假设现在有一个动态脚本是这样创建的:

1
2
3
4
5
var script = document.createElement('script');
script.setAttribute('type', 'text/javascript');
script.setAttribute('src', 'http://www.example.com/xss/c.js');

document.getElementsByTagName('body')[0].appendChild(script);

而重写 Element.prototype.setAttribute 也是可行的:我们发现这里用到了 setAttribute 方法,如果我们能够改写这个原生方法,监听设置 src 属性时的值,通过黑名单或者白名单判断它,就可以判断该标签的合法性了。

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
// 保存原有接口
var old_setAttribute = Element.prototype.setAttribute;

// 重写 setAttribute 接口
Element.prototype.setAttribute = function(name, value) {

// 匹配到 <script src='xxx' > 类型
if (this.tagName == 'SCRIPT' && /^src$/i.test(name)) {
// 白名单匹配
if (!whileListMatch(whiteList, value)) {
console.log('拦截可疑模块:', value);
return;
}
}

// 调用原始接口
old_setAttribute.apply(this, arguments);
};

// 建立白名单
var whiteList = [
'www.yy.com',
'res.cont.yy.com'
];

/**
* [白名单匹配]
* @param {[Array]} whileList [白名单]
* @param {[String]} value [需要验证的字符串]
* @return {[Boolean]} [false -- 验证不通过,true -- 验证通过]
*/
function whileListMatch(whileList, value) {
var length = whileList.length,
i = 0;

for (; i < length; i++) {
// 建立白名单正则
var reg = new RegExp(whiteList[i], 'i');

// 存在白名单中,放行
if (reg.test(value)) {
return true;
}
}
return false;
}

可以看到如下结果:可以戳我查看DEMO(http://sbco.cc/demo/httphijack/index.html)。(打开页面后打开控制台查看 console.log)

重写 Element.prototype.setAttribute ,就是首先保存原有接口,然后当有元素调用 setAttribute 时,检查传入的 src 是否存在于白名单中,存在则放行,不存在则视为可疑元素,进行上报并不予以执行。最后对放行的元素执行原生的 setAttribute ,也就是 old_setAttribute.apply(this, arguments);

重写嵌套 iframe 内的 Element.prototype.setAttribute

当然,上面的写法如果 old_setAttribute = Element.prototype.setAttribute 暴露给攻击者的话,直接使用old_setAttribute 就可以绕过我们重写的方法了,所以这段代码必须包在一个闭包内。

当然这样也不保险,虽然当前窗口下的 Element.prototype.setAttribute 已经被重写了。但是还是有手段可以拿到原生的 Element.prototype.setAttribute ,只需要一个新的 iframe 。

1
2
3
4
var newIframe = document.createElement('iframe');
document.body.appendChild(newIframe);

Element.prototype.setAttribute = newIframe.contentWindow.Element.prototype.setAttribute;
  • 通过这个方法,可以重新拿到原生的 Element.prototype.setAttribute ,因为 iframe 内的环境和外层 window 是完全隔离的。wtf?

怎么办?我们看到创建 iframe 用到了 createElement,那么是否可以重写原生 createElement 呢?但是除了createElement 还有 createElementNS ,还有可能是页面上已经存在 iframe,所以不合适。

  • 那就在每当新创建一个新 iframe 时,对 setAttribute 进行保护重写,这里又有用到 MutationObserver :
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
/**
* 使用 MutationObserver 对生成的 iframe 页面进行监控,
* 防止调用内部原生 setAttribute 及 document.write
* @return {[type]} [description]
*/
function defenseIframe() {
// 先保护当前页面
installHook(window);
}

/**
* 实现单个 window 窗口的 setAttribute保护
* @param {[BOM]} window [浏览器window对象]
* @return {[type]} [description]
*/
function installHook(window) {
// 重写单个 window 窗口的 setAttribute 属性
resetSetAttribute(window);

// MutationObserver 的不同兼容性写法
var MutationObserver = window.MutationObserver || window.WebKitMutationObserver || window.MozMutationObserver;

// 该构造函数用来实例化一个新的 Mutation 观察者对象
// Mutation 观察者对象能监听在某个范围内的 DOM 树变化
var observer = new MutationObserver(function(mutations) {
mutations.forEach(function(mutation) {
// 返回被添加的节点,或者为null.
var nodes = mutation.addedNodes;

// 逐个遍历
for (var i = 0; i < nodes.length; i++) {
var node = nodes[i];

// 给生成的 iframe 里环境也装上重写的钩子
if (node.tagName == 'IFRAME') {
installHook(node.contentWindow);
}
}
});
});

observer.observe(document, {
subtree: true,
childList: true
});
}

/**
* 重写单个 window 窗口的 setAttribute 属性
* @param {[BOM]} window [浏览器window对象]
* @return {[type]} [description]
*/
function resetSetAttribute(window) {
// 保存原有接口
var old_setAttribute = window.Element.prototype.setAttribute;

// 重写 setAttribute 接口
window.Element.prototype.setAttribute = function(name, value) {
...
};
}

我们定义了一个 installHook 方法,参数是一个 window ,在这个方法里,我们将重写传入的 window 下的 setAttribute ,并且安装一个 MutationObserver ,并对此窗口下未来可能创建的 iframe 进行监听,如果未来在此 window 下创建了一个 iframe ,则对新的 iframe 也装上 installHook 方法,以此进行层层保护。

重写 document.write

根据上述的方法,我们可以继续挖掘一下,还有什么方法可以重写,以便对页面进行更好的保护。

document.write 是一个很不错选择,注入攻击者,通常会使用这个方法,往页面上注入一些弹窗广告。

  • 我们可以重写 document.write ,使用关键词黑名单对内容进行匹配。

什么比较适合当黑名单的关键字呢?我们可以看看一些广告很多的页面:

image

这里在页面最底部嵌入了一个 iframe ,里面装了广告代码,这里的最外层的 id 名id=”BAIDU_SSP__wrapper_u2444091_0” 就很适合成为我们判断是否是恶意代码的一个标志,假设我们已经根据拦截上报收集到了一批黑名单列表:

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
// 建立正则拦截关键词
var keywordBlackList = [
'xss',
'BAIDU_SSP__wrapper',
'BAIDU_DSPUI_FLOWBAR'
];

接下来我们只需要利用这些关键字,对 document.write 传入的内容进行正则判断,就能确定是否要拦截document.write 这段代码。

// 建立关键词黑名单
var keywordBlackList = [
'xss',
'BAIDU_SSP__wrapper',
'BAIDU_DSPUI_FLOWBAR'
];


/**
* 重写单个 window 窗口的 document.write 属性
* @param {[BOM]} window [浏览器window对象]
* @return {[type]} [description]
*/
function resetDocumentWrite(window) {
var old_write = window.document.write;

window.document.write = function(string) {
if (blackListMatch(keywordBlackList, string)) {
console.log('拦截可疑模块:', string);
return;
}

// 调用原始接口
old_write.apply(document, arguments);
}
}

/**
* [黑名单匹配]
* @param {[Array]} blackList [黑名单]
* @param {[String]} value [需要验证的字符串]
* @return {[Boolean]} [false -- 验证不通过,true -- 验证通过]
*/
function blackListMatch(blackList, value) {
var length = blackList.length,
i = 0;

for (; i < length; i++) {
// 建立黑名单正则
var reg = new RegExp(whiteList[i], 'i');

// 存在黑名单中,拦截
if (reg.test(value)) {
return true;
}
}
return false;
}
  • 我们可以把 resetDocumentWrite 放入上文的 installHook 方法中,就能对当前 window 及所有生成的 iframe 环境内的 document.write 进行重写了。

锁死 apply 和 call

接下来要介绍的这个是锁住原生的 Function.prototype.apply 和 Function.prototype.call 方法,锁住的意思就是使之无法被重写。

这里要用到 Object.defineProperty ,用于锁死 apply 和 call。

Object.defineProperty

Object.defineProperty() 方法直接在一个对象上定义一个新属性,或者修改一个已经存在的属性, 并返回这个对象。

Object.defineProperty(obj, prop, descriptor)

  • obj – 需要定义属性的对象
  • prop – 需被定义或修改的属性名
  • descriptor – 需被定义或修改的属性的描述符

我们可以使用如下的代码,让 call 和 apply 无法被重写。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
// 锁住 call
Object.defineProperty(Function.prototype, 'call', {
value: Function.prototype.call,
// 当且仅当仅当该属性的 writable 为 true 时,该属性才能被赋值运算符改变
writable: false,
// 当且仅当该属性的 configurable 为 true 时,该属性才能够被改变,也能够被删除
configurable: false,
enumerable: true
});
// 锁住 apply
Object.defineProperty(Function.prototype, 'apply', {
value: Function.prototype.apply,
writable: false,
configurable: false,
enumerable: true
});
  • 为啥要这样写呢?其实还是与上文的 重写 setAttribute 有关。

虽然我们将原始 Element.prototype.setAttribute 保存在了一个闭包当中,但是还有奇技淫巧可以把它从闭包中给“偷出来”。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
(function() {})(
// 保存原有接口
var old_setAttribute = Element.prototype.setAttribute;
// 重写 setAttribute 接口
Element.prototype.setAttribute = function(name, value) {
// 具体细节
if (this.tagName == 'SCRIPT' && /^src$/i.test(name)) {}
// 调用原始接口
old_setAttribute.apply(this, arguments);
};
)();
// 重写 apply
Function.prototype.apply = function(){
console.log(this);
}
// 调用 setAttribute
document.getElementsByTagName('body')[0].setAttribute('data-test','123');

猜猜上面一段会输出什么?看看:
image

居然返回了原生 setAttribute 方法!

这是因为我们在重写 Element.prototype.setAttribute 时最后有 old_setAttribute.apply(this, arguments);这一句,使用到了 apply 方法,所以我们再重写 apply ,输出 this ,当调用被重写后的 setAttribute 就可以从中反向拿到原生的被保存起来的 old_setAttribute 了。

这样我们上面所做的嵌套 iframe 重写 setAttribute 就毫无意义了。

使用上面的 Object.defineProperty 可以锁死 apply 和 类似用法的 call 。使之无法被重写,那么也就无法从闭包中将我们的原生接口偷出来。这个时候才算真正意义上的成功重写了我们想重写的属性。

建立拦截上报

防御的手段也有一些了,接下来我们要建立一个上报系统,替换上文中的 console.log() 日志。

上报系统有什么用呢?因为我们用到了白名单,关键字黑名单,这些数据都需要不断的丰富,靠的就是上报系统,将每次拦截的信息传到服务器,不仅可以让我们程序员第一时间得知攻击的发生,更可以让我们不断收集这类相关信息以便更好的应对。

  • 这里的示例我用 nodejs 搭一个十分简易的服务器接受 http 上报请求。

先定义一个上报函数:

1
2
3
4
5
6
7
8
9
10
11
12
13
/**
* 自定义上报 -- 替换页面中的 console.log()
* @param {[String]} name [拦截类型]
* @param {[String]} value [拦截值]
*/
function hijackReport(name, value) {
var img = document.createElement('img'),
hijackName = name,
hijackValue = value.toString(),
curDate = new Date().getTime();

// 上报
img.src = 'http://www.reportServer.com/report/?msg=' + hijackName + '&value=' + hijackValue + '&time=' + curDate;

假定我们的服务器地址是 www.reportServer.com 这里,我们运用 img.src 发送一个 http 请求到服务器http://www.reportServer.com/report/ ,每次会带上我们自定义的拦截类型,拦截内容以及上报时间。

用 Express 搭 nodejs 服务器并写一个简单的接收路由:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
var express = require('express');
var app = express();

app.get('/report/', function(req, res) {
var queryMsg = req.query.msg,
queryValue = req.query.value,
queryTime = new Date(parseInt(req.query.time));

if (queryMsg) {
console.log('拦截类型:' + queryMsg);
}

if (queryValue) {
console.log('拦截值:' + queryValue);
}

if (queryTime) {
console.log('拦截时间:' + req.query.time);
}
});

app.listen(3002, function() {
console.log('HttpHijack Server listening on port 3002!');
});

运行服务器,当有上报发生,我们将会接收到如下数据:

image

好接下来就是数据入库,分析,添加黑名单,使用 nodejs 当然拦截发生时发送邮件通知程序员等等,这些就不再做展开。

JavaScript DDoS攻击防御方法

为了解决由于引入第三方恶意脚本导致的DDoS攻击的问题,W3C提出了一个叫做“子资源完整性”( Subresource Integrity)的新特性,简称SRI。这个新特性允许一个网站告诉浏览器只有在引入的脚本满足需求时才会进行解析并运行。

举个例子:

1
<script src="http://xxx/jquery-1.10.2.min.js"></script>

上面的这个