JavaScript防http劫持與XSS




作爲前端,一直以來都知道HTTP劫持XSS跨站腳本(Cross-site scripting)、CSRF跨站請求僞造(Cross-site request forgery)。但是一直都沒有深入研究過,前些日子同事的分享會偶然提及,我也對這一塊很感興趣,便深入研究了一番。

最近用 JavaScript 寫了一個組件,可以在前端層面防禦部分 HTTP 劫持與 XSS。

當然,防禦這些劫持最好的方法還是從後端入手,前端能做的實在太少。而且由於源碼的暴露,攻擊者很容易繞過我們的防禦手段。但是這不代表我們去了解這塊的相關知識是沒意義的,本文的許多方法,用在其他方面也是大有作用。

已上傳到 Github – httphijack.js ,歡迎感興趣看看順手點個 star ,本文示例代碼,防範方法在組件源碼中皆可找到。

接下來進入正文。

 

HTTP劫持、DNS劫持與XSS

先簡單講講什麼是 HTTP 劫持與 DNS 劫持。

HTTP劫持

什麼是HTTP劫持呢,大多數情況是運營商HTTP劫持,當我們使用HTTP請求請求一個網站頁面的時候,網絡運營商會在正常的數據流中插入精心設計的網絡數據報文,讓客戶端(通常是瀏覽器)展示「錯誤」的數據,通常是一些彈窗,宣傳性廣告或者直接顯示某網站的內容,大家應該都有遇到過。

DNS劫持

DNS劫持就是通過劫持了DNS服務器,通過某些手段取得某域名的解析記錄控制權,進而修改此域名的解析結果,導致對該域名的訪問由原IP地址轉入到修改後的指定IP,其結果就是對特定的網址不能訪問或訪問的是假網址,從而實現竊取資料或者破壞原有正常服務的目的。

DNS 劫持就更過分了,簡單說就是我們請求的是 http://www.a.com/index.html ,直接被重定向了 http://www.b.com/index.html ,本文不會過多討論這種情況。

XSS跨站腳本

XSS指的是攻擊者漏洞,向 Web 頁面中注入惡意代碼,當用戶瀏覽該頁之時,注入的代碼會被執行,從而達到攻擊的特殊目的。

關於這些攻擊如何生成,攻擊者如何注入惡意代碼到頁面中本文不做討論,只要知道如 HTTP 劫持 和 XSS 最終都是惡意代碼在客戶端,通常也就是用戶瀏覽器端執行,本文將討論的就是假設注入已經存在,如何利用 Javascript 進行行之有效的前端防護。

 

頁面被嵌入 iframe 中,重定向 iframe

先來說說我們的頁面被嵌入了 iframe 的情況。也就是,網絡運營商爲了儘可能地減少植入廣告對原有網站頁面的影響,通常會通過把原有網站頁面放置到一個和原頁面相同大小的 iframe 裏面去,那麼就可以通過這個 iframe 來隔離廣告代碼對原有頁面的影響。

這種情況還比較好處理,我們只需要知道我們的頁面是否被嵌套在 iframe 中,如果是,則重定向外層頁面到我們的正常頁面即可。

那麼有沒有方法知道我們的頁面當前存在於 iframe 中呢?有的,就是 window.self 與 window.top 。

window.self

返回一個指向當前 window 對象的引用。

window.top

返回窗口體系中的最頂層窗口的引用。

對於非同源的域名,iframe 子頁面無法通過 parent.location 或者 top.location 拿到具體的頁面地址,但是可以寫入 top.location ,也就是可以控制父頁面的跳轉。

兩個屬性分別可以又簡寫爲 self 與 top,所以當發現我們的頁面被嵌套在 iframe 時,可以重定向父級頁面:

1
2
3
4
5
6
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
25
26
27
28
// 建立白名單
var  whiteList = [
   'www.aaa.com' ,
   'res.bbb.com'
];
 
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;
   // 父級頁面重定向
   top.location = url;
}

 

更改 URL 參數繞過運營商標記

這樣就完了嗎?沒有,我們雖然重定向了父頁面,但是在重定向的過程中,既然第一次可以嵌套,那麼這一次重定向的過程中頁面也許又被 iframe 嵌套了,真尼瑪蛋疼。

當然運營商這種劫持通常也是有跡可循,最常規的手段是在頁面 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
var  flag =  'iframe_hijack_redirected' ;
// 當前頁面存在於一個 iframe 中
// 此處需要建立一個白名單匹配規則,白名單默認放行
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. <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) ,這種需要用戶點擊或者執行某種事件之後才執行的腳本,我們是有辦法進行防禦的。

瀏覽器事件模型

這裏說能夠攔截,涉及到了事件模型相關的原理。

我們都知道,標準瀏覽器事件模型存在三個階段:

  • 捕獲階段
  • 目標階段
  • 冒泡階段

對於一個這樣 <a href="javascript:alert(222)" ></a> 的 a 標籤而言,真正觸發元素 alert(222) 是處於點擊事件的目標階段。

點擊上面的 click me ,先彈出 111 ,後彈出 222。

那麼,我們只需要在點擊事件模型的捕獲階段對標籤內 javascript:... 的內容建立關鍵字黑名單,進行過濾審查,就可以做到我們想要的攔截效果。

對於 on* 類內聯事件也是同理,只是對於這類事件太多,我們沒辦法手動枚舉,可以利用代碼自動枚舉,完成對內聯事件及內聯腳本的攔截。

以攔截 a 標籤內的 href="javascript:... 爲例,我們可以這樣寫:

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
// 建立關鍵詞黑名單
var  keywordBlackList = [
   'xss' ,
   'BAIDU_SSP__wrapper' ,
   'BAIDU_DSPUI_FLOWBAR'
];
   
document.addEventListener( 'click' function (e) {
   var  code =  "" ;
 
   // 掃描 <a href="javascript:"> 的腳本
   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);
     }
   }
},  true );
 
/**
  * [黑名單匹配]
  * @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 ;
}

可以戳我查看DEMO。(打開頁面後打開控制檯查看 console.log) 

點擊圖中這幾個按鈕,可以看到如下:

這裏我們用到了黑名單匹配,下文還會細說。

 

靜態腳本攔截

XSS 跨站腳本的精髓不在於「跨站」,在於「腳本」。

通常而言,攻擊者或者運營商會向頁面中注入一個<script>腳本,具體操作都在腳本中實現,這種劫持方式只需要注入一次,有改動的話不需要每次都重新注入。

我們假定現在頁面上被注入了一個 <script src="http://attack.com/xss.js"> 腳本,我們的目標就是攔截這個腳本的執行。

聽起來很困難啊,什麼意思呢。就是在腳本執行前發現這個可疑腳本,並且銷燬它使之不能執行內部代碼。

所以我們需要用到一些高級 API ,能夠在頁面加載時對生成的節點進行檢測。

 

MutationObserver

MutationObserver 是 HTML5 新增的 API,功能很強大,給開發者們提供了一種能在某個範圍內的 DOM 樹發生變化時作出適當反應的能力。

說的很玄乎,大概的意思就是能夠監測到頁面 DOM 樹的變換,並作出反應。

MutationObserver() 該構造函數用來實例化一個新的Mutation觀察者對象。

1
2
3
MutationObserver(
   function  callback
);

目瞪狗呆,這一大段又是啥?意思就是 MutationObserver 在觀測時並非發現一個新元素就立即回調,而是將一個時間片段裏出現的所有元素,一起傳過來。所以在回調中我們需要進行批量處理。而且,其中的 callback 會在指定的 DOM 節點(目標節點)發生變化時被調用。在調用時,觀察者對象會傳給該函數兩個參數,第一個參數是個包含了若干個 MutationRecord 對象的數組,第二個參數則是這個觀察者對象本身。

所以,使用 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
// 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];
       if  (/xss/i.test(node.src))) {
         try  {
           node.parentNode.removeChild(node);
           console.log( '攔截可疑靜態腳本:' , node.src);
         catch  (e) {}
       }
     }
   });
});
 
// 傳入目標節點和觀察選項
// 如果 target 爲 document 或者 document.documentElement
// 則當前文檔中所有的節點添加與刪除操作都會被觀察到
observer.observe(document, {
   subtree:  true ,
   childList:  true
});

可以看到如下:可以戳我查看DEMO。(打開頁面後打開控制檯查看 console.log)

<script type="text/javascript" src="./xss/a.js"></script> 是頁面加載一開始就存在的靜態腳本(查看頁面結構),我們使用 MutationObserver 可以在腳本加載之後,執行之前這個時間段對其內容做正則匹配,發現惡意代碼則 removeChild() 掉,使之無法執行。

 

使用白名單對 src 進行匹配過濾

上面的代碼中,我們判斷一個js腳本是否是惡意的,用的是這一句:

1
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
27
28
29
30
31
32
// 建立白名單
var  whiteList = [
   'www.aaa.com' ,
   'res.bbb.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 ;
}
 
// 只放行白名單
if  (!whileListMatch(blackList, node.src)) {
   node.parentNode.removeChild(node);
} 

這裏我們已經多次提到白名單匹配了,下文還會用到,所以可以這裏把它簡單封裝成一個方法調用。

 

動態腳本攔截

上面使用 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 標準中刪除,雖然一些瀏覽器目前仍然支持它,但也許會在未來的某個時間停止支持,請儘量不要使用該特性。

雖然不能用,也可以瞭解一下:

1
2
3
4
5
6
7
document.addEventListener( 'DOMNodeInserted' function (e) {
   var  node = e.target;
   if  (/xss/i.test(node.src) || /xss/i.test(node.innerHTML)) {
     node.parentNode.removeChild(node);
     console.log( '攔截可疑動態腳本:' , node);
   }
},  true );

然而可惜的是,使用上面的代碼攔截動態生成的腳本,可以攔截到,但是代碼也執行了:DOMNodeInserted 顧名思義,可以監聽某個 DOM 範圍內的結構變化,與 MutationObserver 相比,它的執行時機更早。

但是 DOMNodeInserted 不再建議使用,所以監聽動態腳本的任務也要交給 MutationObserver

可惜的是,在實際實踐過程中,使用 MutationObserver 的結果和 DOMNodeInserted 一樣,可以監聽攔截到動態腳本的生成,但是無法在腳本執行之前,使用 removeChild 將其移除,所以我們還需要想想其他辦法。

 

重寫 setAttribute 與 document.write

重寫原生 Element.prototype.setAttribute 方法

在動態腳本插入執行前,監聽 DOM 樹的變化攔截它行不通,腳本仍然會執行。

那麼我們需要向上尋找,在腳本插入 DOM 樹前的捕獲它,那就是創建腳本時這個時機。

假設現在有一個動態腳本是這樣創建的:

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。(打開頁面後打開控制檯查看 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 ,使用關鍵詞黑名單對內容進行匹配。

什麼比較適合當黑名單的關鍵字呢?我們可以看看一些廣告很多的頁面:

這裏在頁面最底部嵌入了一個 iframe ,裏面裝了廣告代碼,這裏的最外層的 id 名id="BAIDU_SSP__wrapper_u2444091_0" 就很適合成爲我們判斷是否是惡意代碼的一個標誌,假設我們已經根據攔截上報收集到了一批黑名單列表:

1
2
3
4
5
6
// 建立正則攔截關鍵詞
var  keywordBlackList = [
'xss' ,
'BAIDU_SSP__wrapper' ,
'BAIDU_DSPUI_FLOWBAR'
];

接下來我們只需要利用這些關鍵字,對 document.write 傳入的內容進行正則判斷,就能確定是否要攔截document.write 這段代碼。 

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
```javascript
// 建立關鍵詞黑名單
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 ;
}<span style= "font-family: verdana, geneva;" > </span>

我們可以把 resetDocumentWrite 放入上文的 installHook 方法中,就能對當前 window 及所有生成的 iframe 環境內的 document.write 進行重寫了。

 

鎖死 apply 和 call

接下來要介紹的這個是鎖住原生的 Function.prototype.apply 和 Function.prototype.call 方法,鎖住的意思就是使之無法被重寫。

這裏要用到 Object.defineProperty ,用於鎖死 apply 和 call。

 

Object.defineProperty

Object.defineProperty() 方法直接在一個對象上定義一個新屬性,或者修改一個已經存在的屬性, 並返回這個對象。

1
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' ); 

猜猜上面一段會輸出什麼?看看:

居然返回了原生 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
14
/**
  * 自定義上報 -- 替換頁面中的 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!' );
});

運行服務器,當有上報發生,我們將會接收到如下數據:

好接下來就是數據入庫,分析,添加黑名單,使用 nodejs 當然攔截髮生時發送郵件通知程序員等等,這些就不再做展開。

 

HTTPS 與 CSP

最後再簡單談談 HTTPS 與 CSP。其實防禦劫持最好的方法還是從後端入手,前端能做的實在太少。而且由於源碼的暴露,攻擊者很容易繞過我們的防禦手段。

CSP

CSP 即是 Content Security Policy,翻譯爲內容安全策略。這個規範與內容安全有關,主要是用來定義頁面可以加載哪些資源,減少 XSS 的發生。

MDN – CSP

HTTPS

能夠實施 HTTP 劫持的根本原因,是 HTTP 協議沒有辦法對通信對方的身份進行校驗以及對數據完整性進行校驗。如果能解決這個問題,則劫持將無法輕易發生。

HTTPS,是 HTTP over SSL 的意思。SSL 協議是 Netscape 在 1995 年首次提出的用於解決傳輸層安全問題的網絡協議,其核心是基於公鑰密碼學理論實現了對服務器身份認證、數據的私密性保護以及對數據完整性的校驗等功能。

因爲與本文主要內容關聯性不大,關於更多 CSP 和 HTTPS 的內容可以自行谷歌。

 

本文到此結束,我也是涉獵前端安全這個方面不久,文章必然有所紕漏及錯誤,文章的方法也是衆多防禦方法中的一小部分,許多內容參考下面文章,都是精品文章,非常值得一讀:

 

使用 Javascript 寫的一個防劫持組件,已上傳到 Github – httphijack.js,歡迎感興趣看看順手點個 star ,本文示例代碼,防範方法在組件源碼中皆可找到。

另外組件處於測試修改階段,未在生產環境使用,而且使用了很多 HTML5 才支持的 API,兼容性是個問題,僅供學習交流。

到此本文結束,如果還有什麼疑問或者建議,可以多多交流,原創文章,文筆有限,才疏學淺,文中若有不正之處,萬望告知。

JavaScript防http劫持與XSS

2016-09-10 18:02  3861人閱讀  評論(0)  收藏  舉報
  分類:
JAVASCRIPT(56)   安全方面(10) 

目錄(?)[+]

http://www.cnblogs.com/coco1s/p/5777260.html

作爲前端,一直以來都知道HTTP劫持XSS跨站腳本(Cross-site scripting)、CSRF跨站請求僞造(Cross-site request forgery)。但是一直都沒有深入研究過,前些日子同事的分享會偶然提及,我也對這一塊很感興趣,便深入研究了一番。

最近用 JavaScript 寫了一個組件,可以在前端層面防禦部分 HTTP 劫持與 XSS。

當然,防禦這些劫持最好的方法還是從後端入手,前端能做的實在太少。而且由於源碼的暴露,攻擊者很容易繞過我們的防禦手段。但是這不代表我們去了解這塊的相關知識是沒意義的,本文的許多方法,用在其他方面也是大有作用。

已上傳到 Github – httphijack.js ,歡迎感興趣看看順手點個 star ,本文示例代碼,防範方法在組件源碼中皆可找到。

接下來進入正文。

 

HTTP劫持、DNS劫持與XSS

先簡單講講什麼是 HTTP 劫持與 DNS 劫持。

HTTP劫持

什麼是HTTP劫持呢,大多數情況是運營商HTTP劫持,當我們使用HTTP請求請求一個網站頁面的時候,網絡運營商會在正常的數據流中插入精心設計的網絡數據報文,讓客戶端(通常是瀏覽器)展示「錯誤」的數據,通常是一些彈窗,宣傳性廣告或者直接顯示某網站的內容,大家應該都有遇到過。

DNS劫持

DNS劫持就是通過劫持了DNS服務器,通過某些手段取得某域名的解析記錄控制權,進而修改此域名的解析結果,導致對該域名的訪問由原IP地址轉入到修改後的指定IP,其結果就是對特定的網址不能訪問或訪問的是假網址,從而實現竊取資料或者破壞原有正常服務的目的。

DNS 劫持就更過分了,簡單說就是我們請求的是 http://www.a.com/index.html ,直接被重定向了 http://www.b.com/index.html ,本文不會過多討論這種情況。

XSS跨站腳本

XSS指的是攻擊者漏洞,向 Web 頁面中注入惡意代碼,當用戶瀏覽該頁之時,注入的代碼會被執行,從而達到攻擊的特殊目的。

關於這些攻擊如何生成,攻擊者如何注入惡意代碼到頁面中本文不做討論,只要知道如 HTTP 劫持 和 XSS 最終都是惡意代碼在客戶端,通常也就是用戶瀏覽器端執行,本文將討論的就是假設注入已經存在,如何利用 Javascript 進行行之有效的前端防護。

 

頁面被嵌入 iframe 中,重定向 iframe

先來說說我們的頁面被嵌入了 iframe 的情況。也就是,網絡運營商爲了儘可能地減少植入廣告對原有網站頁面的影響,通常會通過把原有網站頁面放置到一個和原頁面相同大小的 iframe 裏面去,那麼就可以通過這個 iframe 來隔離廣告代碼對原有頁面的影響。

這種情況還比較好處理,我們只需要知道我們的頁面是否被嵌套在 iframe 中,如果是,則重定向外層頁面到我們的正常頁面即可。

那麼有沒有方法知道我們的頁面當前存在於 iframe 中呢?有的,就是 window.self 與 window.top 。

window.self

返回一個指向當前 window 對象的引用。

window.top

返回窗口體系中的最頂層窗口的引用。

對於非同源的域名,iframe 子頁面無法通過 parent.location 或者 top.location 拿到具體的頁面地址,但是可以寫入 top.location ,也就是可以控制父頁面的跳轉。

兩個屬性分別可以又簡寫爲 self 與 top,所以當發現我們的頁面被嵌套在 iframe 時,可以重定向父級頁面:

1
2
3
4
5
6
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
25
26
27
28
// 建立白名單
var  whiteList = [
   'www.aaa.com' ,
   'res.bbb.com'
];
 
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;
   // 父級頁面重定向
   top.location = url;
}

 

更改 URL 參數繞過運營商標記

這樣就完了嗎?沒有,我們雖然重定向了父頁面,但是在重定向的過程中,既然第一次可以嵌套,那麼這一次重定向的過程中頁面也許又被 iframe 嵌套了,真尼瑪蛋疼。

當然運營商這種劫持通常也是有跡可循,最常規的手段是在頁面 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
var  flag =  'iframe_hijack_redirected' ;
// 當前頁面存在於一個 iframe 中
// 此處需要建立一個白名單匹配規則,白名單默認放行
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. <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) ,這種需要用戶點擊或者執行某種事件之後才執行的腳本,我們是有辦法進行防禦的。

瀏覽器事件模型

這裏說能夠攔截,涉及到了事件模型相關的原理。

我們都知道,標準瀏覽器事件模型存在三個階段:

  • 捕獲階段
  • 目標階段
  • 冒泡階段

對於一個這樣 <a href="javascript:alert(222)" ></a> 的 a 標籤而言,真正觸發元素 alert(222) 是處於點擊事件的目標階段。

點擊上面的 click me ,先彈出 111 ,後彈出 222。

那麼,我們只需要在點擊事件模型的捕獲階段對標籤內 javascript:... 的內容建立關鍵字黑名單,進行過濾審查,就可以做到我們想要的攔截效果。

對於 on* 類內聯事件也是同理,只是對於這類事件太多,我們沒辦法手動枚舉,可以利用代碼自動枚舉,完成對內聯事件及內聯腳本的攔截。

以攔截 a 標籤內的 href="javascript:... 爲例,我們可以這樣寫:

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
// 建立關鍵詞黑名單
var  keywordBlackList = [
   'xss' ,
   'BAIDU_SSP__wrapper' ,
   'BAIDU_DSPUI_FLOWBAR'
];
   
document.addEventListener( 'click' function (e) {
   var  code =  "" ;
 
   // 掃描 <a href="javascript:"> 的腳本
   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);
     }
   }
},  true );
 
/**
  * [黑名單匹配]
  * @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 ;
}

可以戳我查看DEMO。(打開頁面後打開控制檯查看 console.log) 

點擊圖中這幾個按鈕,可以看到如下:

這裏我們用到了黑名單匹配,下文還會細說。

 

靜態腳本攔截

XSS 跨站腳本的精髓不在於「跨站」,在於「腳本」。

通常而言,攻擊者或者運營商會向頁面中注入一個<script>腳本,具體操作都在腳本中實現,這種劫持方式只需要注入一次,有改動的話不需要每次都重新注入。

我們假定現在頁面上被注入了一個 <script src="http://attack.com/xss.js"> 腳本,我們的目標就是攔截這個腳本的執行。

聽起來很困難啊,什麼意思呢。就是在腳本執行前發現這個可疑腳本,並且銷燬它使之不能執行內部代碼。

所以我們需要用到一些高級 API ,能夠在頁面加載時對生成的節點進行檢測。

 

MutationObserver

MutationObserver 是 HTML5 新增的 API,功能很強大,給開發者們提供了一種能在某個範圍內的 DOM 樹發生變化時作出適當反應的能力。

說的很玄乎,大概的意思就是能夠監測到頁面 DOM 樹的變換,並作出反應。

MutationObserver() 該構造函數用來實例化一個新的Mutation觀察者對象。

1
2
3
MutationObserver(
   function  callback
);

目瞪狗呆,這一大段又是啥?意思就是 MutationObserver 在觀測時並非發現一個新元素就立即回調,而是將一個時間片段裏出現的所有元素,一起傳過來。所以在回調中我們需要進行批量處理。而且,其中的 callback 會在指定的 DOM 節點(目標節點)發生變化時被調用。在調用時,觀察者對象會傳給該函數兩個參數,第一個參數是個包含了若干個 MutationRecord 對象的數組,第二個參數則是這個觀察者對象本身。

所以,使用 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
// 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];
       if  (/xss/i.test(node.src))) {
         try  {
           node.parentNode.removeChild(node);
           console.log( '攔截可疑靜態腳本:' , node.src);
         catch  (e) {}
       }
     }
   });
});
 
// 傳入目標節點和觀察選項
// 如果 target 爲 document 或者 document.documentElement
// 則當前文檔中所有的節點添加與刪除操作都會被觀察到
observer.observe(document, {
   subtree:  true ,
   childList:  true
});

可以看到如下:可以戳我查看DEMO。(打開頁面後打開控制檯查看 console.log)

<script type="text/javascript" src="./xss/a.js"></script> 是頁面加載一開始就存在的靜態腳本(查看頁面結構),我們使用 MutationObserver 可以在腳本加載之後,執行之前這個時間段對其內容做正則匹配,發現惡意代碼則 removeChild() 掉,使之無法執行。

 

使用白名單對 src 進行匹配過濾

上面的代碼中,我們判斷一個js腳本是否是惡意的,用的是這一句:

1
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
27
28
29
30
31
32
// 建立白名單
var  whiteList = [
   'www.aaa.com' ,
   'res.bbb.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 ;
}
 
// 只放行白名單
if  (!whileListMatch(blackList, node.src)) {
   node.parentNode.removeChild(node);
} 

這裏我們已經多次提到白名單匹配了,下文還會用到,所以可以這裏把它簡單封裝成一個方法調用。

 

動態腳本攔截

上面使用 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 標準中刪除,雖然一些瀏覽器目前仍然支持它,但也許會在未來的某個時間停止支持,請儘量不要使用該特性。

雖然不能用,也可以瞭解一下:

1
2
3
4
5
6
7
document.addEventListener( 'DOMNodeInserted' function (e) {
   var  node = e.target;
   if  (/xss/i.test(node.src) || /xss/i.test(node.innerHTML)) {
     node.parentNode.removeChild(node);
     console.log( '攔截可疑動態腳本:' , node);
   }
},  true );

然而可惜的是,使用上面的代碼攔截動態生成的腳本,可以攔截到,但是代碼也執行了:DOMNodeInserted 顧名思義,可以監聽某個 DOM 範圍內的結構變化,與 MutationObserver 相比,它的執行時機更早。

但是 DOMNodeInserted 不再建議使用,所以監聽動態腳本的任務也要交給 MutationObserver

可惜的是,在實際實踐過程中,使用 MutationObserver 的結果和 DOMNodeInserted 一樣,可以監聽攔截到動態腳本的生成,但是無法在腳本執行之前,使用 removeChild 將其移除,所以我們還需要想想其他辦法。

 

重寫 setAttribute 與 document.write

重寫原生 Element.prototype.setAttribute 方法

在動態腳本插入執行前,監聽 DOM 樹的變化攔截它行不通,腳本仍然會執行。

那麼我們需要向上尋找,在腳本插入 DOM 樹前的捕獲它,那就是創建腳本時這個時機。

假設現在有一個動態腳本是這樣創建的:

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。(打開頁面後打開控制檯查看 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];
相關文章
相關標籤/搜索