【前端安全】JavaScript防http劫持與XSS
作為前端,一直以來都知道HTTP劫持與XSS跨站腳本(Cross-site scripting)CSRF>跨站請求偽造(Cross-site request forgery)。但是一直都沒有深入研究過,前些日子同事的分享會偶然提及,我也對這一塊很感興趣,便深入研究了一番。
最近用 JavaScript 寫了一個組件,可以在前端層面防御部分 HTTP 劫持與 XSS。
當然,防御這些劫持最好的方法還是從后端入手,前端能做的實在太少。而且由於源碼的暴露,攻擊者很容易繞過我們的防御手段。但是這不代表我們去了解這塊的相關知識是沒意義的,本文的許多方法,用在其他方面也是大有作用。
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 時,可以重定向父級頁面:
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 var whiteList = [ 3 'www.aaa.com', 4 'res.bbb.com' 5 ]; 6 7 if (self != top) { 8 var 9 // 使用 document.referrer 可以拿到跨域 iframe 父頁面的 URL 10 parentUrl = document.referrer, 11 length = whiteList.length, 12 i = 0; 13 14 for(; i<length; i++){ 15 // 建立白名單正則 16 var reg = new RegExp(whiteList[i],'i'); 17 18 // 存在白名單中,放行 19 if(reg.test(parentUrl)){ 20 return; 21 } 22 } 23 24 // 我們的正常頁面 25 var url = location.href; 26 // 父級頁面重定向 27 top.location = url; 28 }
更改 URL 參數繞過運營商標記
這樣就完了嗎?沒有,我們雖然重定向了父頁面,但是在重定向的過程中,既然第一次可以嵌套,那么這一次重定向的過程中頁面也許又被 iframe 嵌套了,真尼瑪蛋疼。
當然運營商這種劫持通常也是有跡可循,最常規的手段是在頁面 URL 中設置一個參數,例如 http://www.example.com/index.html?iframe_hijack_redirected=1 ,其中iframe_hijack_redirected=1表示頁面已經被劫持過了,就不再嵌套 iframe 了。所以根據這個特性,我們可以改寫我們的 URL ,使之看上去已經被劫持了:
1 var flag = 'iframe_hijack_redirected'; 2 // 當前頁面存在於一個 iframe 中 3 // 此處需要建立一個白名單匹配規則,白名單默認放行 4 if (self != top) { 5 var 6 // 使用 document.referrer 可以拿到跨域 iframe 父頁面的 URL 7 parentUrl = document.referrer, 8 length = whiteList.length, 9 i = 0; 10 11 for(; i<length; i++){ 12 // 建立白名單正則 13 var reg = new RegExp(whiteList[i],'i'); 14 15 // 存在白名單中,放行 16 if(reg.test(parentUrl)){ 17 return; 18 } 19 } 20 21 var url = location.href; 22 var parts = url.split('#'); 23 if (location.search) { 24 parts[0] += '&' + flag + '=1'; 25 } else { 26 parts[0] += '?' + flag + '=1'; 27 } 28 try { 29 console.log('頁面被嵌入iframe中:', url); 30 top.location.href = parts.join('#'); 31 } catch (e) {} 32 }
當然,如果這個參數一改,防嵌套的代碼就失效了。所以我們還需要建立一個上報系統,當發現頁面被嵌套時,發送一個攔截上報,即便重定向失敗,也可以知道頁面嵌入 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:laert(222)"></a> 的a 標簽而言,真正觸發元素 alert(222) 是處於點擊事件的目標階段。
點擊上面的click me,先彈出 111 ,后彈出 222。
那么,我們只需要在點擊事件模型的捕獲階段對標簽內 javascript:...的內容建立關鍵字黑名單,進行過濾審查,就可以做到我們想要的攔截效果。
對於 on* 類內聯事件也是同理,只是對於這類事件太多,我們沒辦法手動枚舉,可以利用代碼自動枚舉,完成對內聯事件及內聯腳本的攔截。
以攔截 a 標簽內的href="javascript:..." 為例,我們可以這樣寫:
1 // 建立關鍵詞黑名單 2 var keywordBlackList = [ 3 'xss', 4 'BAIDU_SSP__wrapper', 5 'BAIDU_DSPUI_FLOWBAR' 6 ]; 7 8 document.addEventListener('click', function(e) { 9 var code = ""; 10 11 // 掃描 <a href="javascript:"> 的腳本 12 if (elem.tagName == 'A' && elem.protocol == 'javascript:') { 13 var code = elem.href.substr(11); 14 15 if (blackListMatch(keywordBlackList, code)) { 16 // 注銷代碼 17 elem.href = 'javascript:void(0)'; 18 console.log('攔截可疑事件:' + code); 19 } 20 } 21 }, true); 22 23 /** 24 * [黑名單匹配] 25 * @param {[Array]} blackList [黑名單] 26 * @param {[String]} value [需要驗證的字符串] 27 * @return {[Boolean]} [false -- 驗證不通過,true -- 驗證通過] 28 */ 29 function blackListMatch(blackList, value) { 30 var length = blackList.length, 31 i = 0; 32 33 for (; i < length; i++) { 34 // 建立黑名單正則 35 var reg = new RegExp(whiteList[i], 'i'); 36 37 // 存在黑名單中,攔截 38 if (reg.test(value)) { 39 return true; 40 } 41 } 42 return false; 43 }
可以戳我查看DEMO。(打開頁面后打開控制台查看 console.log)
點擊圖中這幾個按鈕,可以看到如下:
這里我們用到了黑名單匹配,下文還會細說。
靜態腳本攔截
XSS 跨站腳本的精髓不在於“跨站”,在於“腳本”。
通常而言,攻擊者或者運營商會向頁面中注入一個<script>腳本,具體操作都在腳本中實現,這種劫持方式只需要注入一次,有改動的話不需要每次都重新注入。
我們假定現在頁面上被注入了一個<script src="http://attack.com/xss.js"> 腳本,我們的目標就是攔截這個腳本的執行。
聽起來很困難啊,什么意思呢。就是在腳本執行前發現這個可疑腳本,並且銷毀它使之不能執行內部代碼。
所以我們需要用到一些高級 API ,能夠在頁面加載時對生成的節點進行檢測。
MutationObserver
MutationObserver 是 HTML5 新增的 API,功能很強大,給開發者們提供了一種能在某個范圍內的 DOM 樹發生變化時作出適當反應的能力。
說的很玄乎,大概的意思就是能夠監測到頁面 DOM 樹的變換,並作出反應。
MutationObserver()該構造函數用來實例化一個新的Mutation觀察者對象。
1 MutationObserver( 2 function callback 3 );
目瞪狗呆,這一大段又是啥?意思就是 MutationObserver 在觀測時並非發現一個新元素就立即回調,而是將一個時間片段里出現的所有元素,一起傳過來。所以在回調中我們需要進行批量處理。而且,其中的callback 會在指定的 DOM 節點(目標節點)發生變化時被調用。在調用時,觀察者對象會傳給該函數兩個參數,第一個參數是個包含了若干個 MutationRecord 對象的數組,第二個參數則是這個觀察者對象本身。
所以,使用 MutationObserver ,我們可以對頁面加載的每個靜態腳本文件,進行監控:
1 // MutationObserver 的不同兼容性寫法 2 var MutationObserver = window.MutationObserver || window.WebKitMutationObserver || 3 window.MozMutationObserver; 4 // 該構造函數用來實例化一個新的 Mutation 觀察者對象 5 // Mutation 觀察者對象能監聽在某個范圍內的 DOM 樹變化 6 var observer = new MutationObserver(function(mutations) { 7 mutations.forEach(function(mutation) { 8 // 返回被添加的節點,或者為null. 9 var nodes = mutation.addedNodes; 10 11 for (var i = 0; i < nodes.length; i++) { 12 var node = nodes[i]; 13 if (/xss/i.test(node.src))) { 14 try { 15 node.parentNode.removeChild(node); 16 console.log('攔截可疑靜態腳本:', node.src); 17 } catch (e) {} 18 } 19 } 20 }); 21 }); 22 23 // 傳入目標節點和觀察選項 24 // 如果 target 為 document 或者 document.documentElement 25 // 則當前文檔中所有的節點添加與刪除操作都會被觀察到 26 observer.observe(document, { 27 subtree: true, 28 childList: true 29 });
可以看到如下:可以戳我查看DEMO。(打開頁面后打開控制台查看 console.log)
<script type="text/javascript" src="./xss/a.js"></script> 是頁面加載一開始就存在的靜態腳本(查看頁面結構),我們使用 MutationObserver 可以在腳本加載之后,執行之前這個時間段對其內容做正則匹配,發現惡意代碼則removeChild() 掉,使之無法執行。
使用白名單對 src 進行匹配過濾
上面的代碼中,我們判斷一個js腳本是否是惡意的,用的是這一句:
if (/xss/i.test(node.src)) {}
當然實際當中,注入惡意代碼者不會那么傻,把名字改成 XSS 。所以,我們很有必要使用白名單進行過濾和建立一個攔截上報系統。
1 // 建立白名單 2 var whiteList = [ 3 'www.aaa.com', 4 'res.bbb.com' 5 ]; 6 7 /** 8 * [白名單匹配] 9 * @param {[Array]} whileList [白名單] 10 * @param {[String]} value [需要驗證的字符串] 11 * @return {[Boolean]} [false -- 驗證不通過,true -- 驗證通過] 12 */ 13 function whileListMatch(whileList, value) { 14 var length = whileList.length, 15 i = 0; 16 17 for (; i < length; i++) { 18 // 建立白名單正則 19 var reg = new RegExp(whiteList[i], 'i'); 20 21 // 存在白名單中,放行 22 if (reg.test(value)) { 23 return true; 24 } 25 } 26 return false; 27 } 28 29 // 只放行白名單 30 if (!whileListMatch(blackList, node.src)) { 31 node.parentNode.removeChild(node); 32 }
這里我們已經多次提到白名單匹配了,下文還會用到,所以可以這里把它簡單封裝成一個方法調用。
動態腳本攔截
上面使用 MutationObserver 攔截靜態腳本,除了靜態腳本,與之對應的就是動態生成的腳本。
1 var script = document.createElement('script'); 2 script.type = 'text/javascript'; 3 script.src = 'http://www.example.com/xss/b.js'; 4 5 document.getElementsByTagName('body')[0].appendChild(script);
要攔截這類動態生成的腳本,且攔截時機要在它插入 DOM 樹中,執行之前,本來是可以監聽Mutation Events中的DOMNodeInserted事件的。
Mutation Events 與 DOMNodeInserted
打開 MDN ,第一句就是:
該特性已經從 Web 標准中刪除,雖然一些瀏覽器目前仍然支持它,但也許會在未來的某個時間停止支持,請盡量不要使用該特性。
雖然不能用,也可以了解一下:
1 document.addEventListener('DOMNodeInserted', function(e) { 2 var node = e.target; 3 if (/xss/i.test(node.src) || /xss/i.test(node.innerHTML)) { 4 node.parentNode.removeChild(node); 5 console.log('攔截可疑動態腳本:', node); 6 } 7 }, true);
然而可惜的是,使用上面的代碼攔截動態生成的腳本,可以攔截到,但是代碼也執行了:DOMNodeInserted顧名思義,可以監聽某個 DOM 范圍內的結構變化,與 MutationObserver相比,它的執行時機更早。
但是DOMNodeInserted不再建議使用,所以監聽動態腳本的任務也要交給MutationObserver。
可惜的是,在實際實踐過程中,使用 MutationObserver的結果和 DOMNodeInserted一樣,可以監聽攔截到動態腳本的生成,但是無法在腳本執行之前,使用 removeChild 將其移除,所以我們還需要想想其他辦法。
重寫 setAttribute 與 document.write
重寫原生 Element.prototype.setAttribute 方法
在動態腳本插入執行前,監聽 DOM 樹的變化攔截它行不通,腳本仍然會執行。
那么我們需要向上尋找,在腳本插入 DOM 樹前的捕獲它,那就是創建腳本時這個時機。
假設現在有一個動態腳本是這樣創建的:
1 var script = document.createElement('script'); 2 script.setAttribute('type', 'text/javascript'); 3 script.setAttribute('src', 'http://www.example.com/xss/c.js'); 4 5 document.getElementsByTagName('body')[0].appendChild(script);
而重寫Element.prototype.setAttribute也是可行的:我們發現這里用到了 setAttribute 方法,如果我們能夠改寫這個原生方法,監聽設置src屬性時的值,通過黑名單或者白名單判斷它,就可以判斷該標簽的合法性了。
1 // 保存原有接口 2 var old_setAttribute = Element.prototype.setAttribute; 3 4 // 重寫 setAttribute 接口 5 Element.prototype.setAttribute = function(name, value) { 6 7 // 匹配到 <script src='xxx' > 類型 8 if (this.tagName == 'SCRIPT' && /^src$/i.test(name)) { 9 // 白名單匹配 10 if (!whileListMatch(whiteList, value)) { 11 console.log('攔截可疑模塊:', value); 12 return; 13 } 14 } 15 16 // 調用原始接口 17 old_setAttribute.apply(this, arguments); 18 }; 19 20 // 建立白名單 21 var whiteList = [ 22 'www.yy.com', 23 'res.cont.yy.com' 24 ]; 25 26 /** 27 * [白名單匹配] 28 * @param {[Array]} whileList [白名單] 29 * @param {[String]} value [需要驗證的字符串] 30 * @return {[Boolean]} [false -- 驗證不通過,true -- 驗證通過] 31 */ 32 function whileListMatch(whileList, value) { 33 var length = whileList.length, 34 i = 0; 35 36 for (; i < length; i++) { 37 // 建立白名單正則 38 var reg = new RegExp(whiteList[i], 'i'); 39 40 // 存在白名單中,放行 41 if (reg.test(value)) { 42 return true; 43 } 44 } 45 return false; 46 }
可以看到如下結果:可以戳我查看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 var newIframe = document.createElement('iframe'); 2 document.body.appendChild(newIframe); 3 4 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 * 使用 MutationObserver 對生成的 iframe 頁面進行監控, 3 * 防止調用內部原生 setAttribute 及 document.write 4 * @return {[type]} [description] 5 */ 6 function defenseIframe() { 7 // 先保護當前頁面 8 installHook(window); 9 } 10 11 /** 12 * 實現單個 window 窗口的 setAttribute保護 13 * @param {[BOM]} window [瀏覽器window對象] 14 * @return {[type]} [description] 15 */ 16 function installHook(window) { 17 // 重寫單個 window 窗口的 setAttribute 屬性 18 resetSetAttribute(window); 19 20 // MutationObserver 的不同兼容性寫法 21 var MutationObserver = window.MutationObserver || window.WebKitMutationObserver || window.MozMutationObserver; 22 23 // 該構造函數用來實例化一個新的 Mutation 觀察者對象 24 // Mutation 觀察者對象能監聽在某個范圍內的 DOM 樹變化 25 var observer = new MutationObserver(function(mutations) { 26 mutations.forEach(function(mutation) { 27 // 返回被添加的節點,或者為null. 28 var nodes = mutation.addedNodes; 29 30 // 逐個遍歷 31 for (var i = 0; i < nodes.length; i++) { 32 var node = nodes[i]; 33 34 // 給生成的 iframe 里環境也裝上重寫的鈎子 35 if (node.tagName == 'IFRAME') { 36 installHook(node.contentWindow); 37 } 38 } 39 }); 40 }); 41 42 observer.observe(document, { 43 subtree: true, 44 childList: true 45 }); 46 } 47 48 /** 49 * 重寫單個 window 窗口的 setAttribute 屬性 50 * @param {[BOM]} window [瀏覽器window對象] 51 * @return {[type]} [description] 52 */ 53 function resetSetAttribute(window) { 54 // 保存原有接口 55 var old_setAttribute = window.Element.prototype.setAttribute; 56 57 // 重寫 setAttribute 接口 58 window.Element.prototype.setAttribute = function(name, value) { 59 ... 60 }; 61 }
我們定義了一個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 var keywordBlackList = [ 3 'xss', 4 'BAIDU_SSP__wrapper', 5 'BAIDU_DSPUI_FLOWBAR' 6 ]; 7 接下來我們只需要利用這些關鍵字,對document.write 傳入的內容進行正則判斷,就能確定是否要攔截document.write這段代碼。 8 9 ```javascript 10 // 建立關鍵詞黑名單 11 var keywordBlackList = [ 12 'xss', 13 'BAIDU_SSP__wrapper', 14 'BAIDU_DSPUI_FLOWBAR' 15 ]; 16 17 /** 18 * 重寫單個 window 窗口的 document.write 屬性 19 * @param {[BOM]} window [瀏覽器window對象] 20 * @return {[type]} [description] 21 */ 22 function resetDocumentWrite(window) { 23 var old_write = window.document.write; 24 25 window.document.write = function(string) { 26 if (blackListMatch(keywordBlackList, string)) { 27 console.log('攔截可疑模塊:', string); 28 return; 29 } 30 31 // 調用原始接口 32 old_write.apply(document, arguments); 33 } 34 } 35 36 /** 37 * [黑名單匹配] 38 * @param {[Array]} blackList [黑名單] 39 * @param {[String]} value [需要驗證的字符串] 40 * @return {[Boolean]} [false -- 驗證不通過,true -- 驗證通過] 41 */ 42 function blackListMatch(blackList, value) { 43 var length = blackList.length, 44 i = 0; 45 46 for (; i < length; i++) { 47 // 建立黑名單正則 48 var reg = new RegExp(whiteList[i], 'i'); 49 50 // 存在黑名單中,攔截 51 if (reg.test(value)) { 52 return true; 53 } 54 } 55 return false; 56 }
我們可以把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 // 鎖住 call 2 Object.defineProperty(Function.prototype, 'call', { 3 value: Function.prototype.call, 4 // 當且僅當僅當該屬性的 writable 為 true 時,該屬性才能被賦值運算符改變 5 writable: false, 6 // 當且僅當該屬性的 configurable 為 true 時,該屬性才能夠被改變,也能夠被刪除 7 configurable: false, 8 enumerable: true 9 }); 10 // 鎖住 apply 11 Object.defineProperty(Function.prototype, 'apply', { 12 value: Function.prototype.apply, 13 writable: false, 14 configurable: false, 15 enumerable: true 16 });
為啥要這樣寫呢?其實還是與上文的重寫setAttribute有關。
雖然我們將原始 Element.prototype.setAttribute 保存在了一個閉包當中,但是還有奇技淫巧可以把它從閉包中給“偷出來”。
試一下:
1 (function() {})( 2 // 保存原有接口 3 var old_setAttribute = Element.prototype.setAttribute; 4 // 重寫 setAttribute 接口 5 Element.prototype.setAttribute = function(name, value) { 6 // 具體細節 7 if (this.tagName == 'SCRIPT' && /^src$/i.test(name)) {} 8 // 調用原始接口 9 old_setAttribute.apply(this, arguments); 10 }; 11 )(); 12 // 重寫 apply 13 Function.prototype.apply = function(){ 14 console.log(this); 15 } 16 // 調用 setAttribute 17 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() 日志。
上報系統有什么用呢?因為我們用到了白名單,關鍵字黑名單,這些數據都需要不斷的豐富,靠的就是上報系統,將每次攔截的信息傳到服務器,不僅可以讓我們程序員第一時間得知攻擊的發生,更可以讓我們不斷收集這類相關信息以便更好的應對。
這里的示例我用node js搭一個十分簡易的服務器接受 http 上報請求。
先定義一個上報函數:
1 /** 2 * 自定義上報 -- 替換頁面中的 console.log() 3 * @param {[String]} name [攔截類型] 4 * @param {[String]} value [攔截值] 5 */ 6 function hijackReport(name, value) { 7 var img = document.createElement('img'), 8 hijackName = name, 9 hijackValue = value.toString(), 10 curDate = new Date().getTime(); 11 12 // 上報 13 img.src = 'http://www.reportServer.com/report/?msg=' + hijackName + '&value=' + hijackValue + '&time=' + curDate; 14 }
假定我們的服務器地址是www.reportServer.com 這里,我們運用img.src 發送一個 http 請求到服務器http://www.reportServer.com/report/,每次會帶上我們自定義的攔截類型,攔截內容以及上報時間。
用 Express 搭 nodejs 服務器並寫一個簡單的接收路由:
1 var express = require('express'); 2 var app = express(); 3 4 app.get('/report/', function(req, res) { 5 var queryMsg = req.query.msg, 6 queryValue = req.query.value, 7 queryTime = new Date(parseInt(req.query.time)); 8 9 if (queryMsg) { 10 console.log('攔截類型:' + queryMsg); 11 } 12 13 if (queryValue) { 14 console.log('攔截值:' + queryValue); 15 } 16 17 if (queryTime) { 18 console.log('攔截時間:' + req.query.time); 19 } 20 }); 21 22 app.listen(3002, function() { 23 console.log('HttpHijack Server listening on port 3002!'); 24 });
運行服務器,當有上報發生,我們將會接收到如下數據:
好接下來就是數據入庫,分析,添加黑名單,使用node js 當然攔截發生時發送郵件通知程序員等等,這些就不再做展開。
HTTPS 與 CSP
最后再簡單談談 HTTPS 與 CSP。其實防御劫持最好的方法還是從后端入手,前端能做的實在太少。而且由於源碼的暴露,攻擊者很容易繞過我們的防御手段。
CSP
CSP 即是 Content Security Policy,翻譯為內容安全策略。這個規范與內容安全有關,主要是用來定義頁面可以加載哪些資源,減少 XSS 的發生。
MDN – CSP
HTTPS
能夠實施 HTTP 劫持的根本原因,是 HTTP 協議沒有辦法對通信對方的身份進行校驗以及對數據完整性進行校驗。如果能解決這個問題,則劫持將無法輕易發生。
HTTPS,是 HTTP over SSL 的意思。SSL 協議是 Netscape 在 1995 年首次提出的用於解決傳輸層安全問題的網絡協議,其核心是基於公鑰密碼學理論實現了對服務器身份認證、數據的私密性保護以及對數據完整性的校驗等功能。