XSS 前端防火牆 —— 內聯事件攔截

關於 XSS 怎樣形成、如何注入、能做什麼、如何防範,前人已有無數的探討,這裏就不再累述了。

幾乎每篇談論 XSS 的文章,結尾多少都會提到如何防止,然而大多萬變不離其宗。要轉義什麼,要過濾什麼,不要忘了什麼之類的。儘管都是衆所周知的道理,但 XSS 漏洞十幾年來幾乎從未中斷過,不乏一些大網站也時常爆出,小網站更是家常便飯。

而本文介紹的則是另一種預防思路 —— 通過前端監控腳本,讓每一個用戶都參與漏洞的監控和上報。

預警系統

事實上,至今仍未有一勞永逸的解決方案,要避免它依舊使用最古老的土辦法,逐個的過濾。然而人總有疏忽的時候,每當產品迭代更新時,難免會遺漏一些新字段,導致漏洞被引入。

即使聖人千慮也有一失,程序出 BUG 完全可以理解,及時修復就行。但令人費解的是,問題出現到被發現,卻要經過相當長的時間。例如不久前貼吧 XSS 蠕蟲腳本,直到大規模爆發後經用戶舉報,最終才得知。其他網站大多也類似,直到白帽子們挖掘出漏洞,提交到安全平臺上,最終廠商才被告知。若遇到黑客私下留着這些漏洞慢慢利用,那隻能聽天由命了。

因此,要是能有一套實時的預警系統,那就更好了。即使無法阻止漏洞的發生,但能在漏洞觸發的第一時間裏,通知開發人員,即可在最短的時間裏修復,將損失降到最低。各式各樣的應用層防火牆,也由此產生。

不過,和傳統的系統漏洞不同,XSS 最終是在用戶頁面中觸發的。因此,我們不妨嘗試使用前端的思路,進行在線防禦。

XSS 內聯事件

先來假設一個有 BUG 的後臺,沒有很好處理用戶輸入的數據,導致 XSS 能被注入到頁面:

<img src="{路徑}" />

<img src="{路徑" οnlοad="alert(/xss/)}" />

只轉義尖括號,卻忘了引號,是 XSS 裏最爲常見的。攻擊者們可以提前關閉屬性,並添加一個極易觸發的內聯事件,跨站腳本就這樣被輕易執行了。

那麼,我們能否使用前端腳本來捕獲,甚至攔截呢?

被動掃描

最簡單的辦法,就是把頁面裏所有元素都掃描一遍,檢測那些 on 開頭的內聯屬性,看看是不是存在異常:

例如字符數非常多,正常情況下這是很少出現的,但 XSS 爲了躲避轉義有時會編碼的很長;例如出現一些 XSS 經常使用的關鍵字,但在實際產品裏幾乎不會用到的。這些都可以作爲漏洞出現的徵兆,通知給開發人員。

不過,土辦法終究存在很大的侷限性。在如今清一色的 AJAX 時代,頁面元素從來都不是固定的。伴隨着用戶各種交互,新內容隨時都可能動態添加進來。即使換成定期掃描一次,XSS 也可能在定時器的間隔中觸發,並銷燬自己,那樣永遠都無法跟蹤到了。況且,頻繁的掃描對性能影響也是巨大的。

如同早期的安全軟件一樣,每隔幾秒掃描一次註冊表啓動項,不僅費性能,而且對惡意軟件幾乎不起作用;但之後的主動防禦系統就不同了,只有在真正調用 API 時才進行分析,不通過則直接攔截,完全避免了定時器的間隔遺漏。

因此,我們需要這種類似的延時策略 —— 僅在 XSS 即將觸發時對其分析,對不符合策略的元素,進行攔截或者放行,同時發送報警到後臺日誌。

主動防禦

『主動防禦』,這概念放在前端腳本里似乎有些玄乎。但不難發現,這僅僅是執行優先級的事而已 —— 只要防禦程序能運行在其他程序之前,我們就有了可進可退的主動權。對於無比強大的 HTML5 和靈活多變的 JavaScript,這些概念都可以被玩轉出來。

繼續回到剛纔討論的內聯事件 XSS 上來。瀏覽器雖然沒提供可操控內聯事件的接口,但內聯事件的本質仍是一個事件,無論怎樣變化都離不開 DOM 事件模型。

扯到模型上面,一切即將迎刃而解。模型是解決問題的最靠譜的辦法,尤其是像 DOM-3-Event 這種早已制定的模型,其穩定性毋庸置疑。

即便沒仔細閱讀官方文檔,但凡做過網頁的都知道,有個 addEventListener 的接口,並取代了曾經一個古老的叫 attachEvent 的東西。儘管只是新增了一個參數而已,但正是這個差別成了人們津津樂道的話題。每當面試談到事件時,總少不了考察下這個新參數的用途。儘管在日常開發中很少用到它。

關於事件捕獲和冒泡的細節,就不多討論了。下面的這段代碼,或許能激發你對『主動防禦』的遐想。

<button οnclick="console.log('target')">CLICK ME</button>
<script>
    document.addEventListener('click', function(e) {
        console.log('bubble');
    });

    document.addEventListener('click', function(e) {
        console.log('capture');
        //e.stopImmediatePropagation();
    }, true);
</script>

Run

儘管按鈕上直接綁了一個內聯的事件,但事件模型並不買賬,仍然得按標準的流程走一遍。capture,target,bubble,模型就是那樣固執。

不過,把那行註釋的代碼恢復,結果就只剩 capture 了。這個簡單的道理大家都明白,也沒什麼好解釋的。

但仔細揣摩下,這不就是『主動防禦』的概念嗎?捕獲程序運行在內聯事件觸發之前,並且完全有能力攔截之後的調用。

上面的 Demo 只是不假思索攔截了所有的事件。如果我們再加一些策略判斷,或許就更明朗了:

<button οnclick="console.log('xss')">CLICK ME</button>
<script>
    document.addEventListener('click', function(e) {
        console.log('bubble');
    });

    document.addEventListener('click', function(e) {
        var element = e.target;
        var code = element.getAttribute('onclick');

        if (/xss/.test(code)) {
            e.stopImmediatePropagation();
            alert('攔截可疑事件: ' + code);
        }
    }, true);
</script>

Run

我們先在捕獲階段掃描內聯事件字符,若是出現了『xss』這個關鍵字,後續的事件就被攔截了;換成其他字符,仍然繼續執行。同理,我們還可以判斷字符長度是否過多,以及更詳細的黑白名單正則。

怎麼樣,一個主動防禦的原型誕生了吧。

不過,上面的片段還有個小問題,就是把事件的冒泡過程也給屏蔽了,而我們僅僅想攔截內聯事件而已。解決辦法也很簡單,把 e.stopImmediatePropagation() 換成 element.onclick = null 就可以了。

當然,目前這隻能防護 onclick,而現實中有太多的內聯事件。鼠標、鍵盤、觸屏、網絡狀態等等,不同瀏覽器支持的事件也不一樣,甚至還有私有事件,難道都要事先逐一列出並且都捕獲嗎?是的,可以都捕獲,但不必事先都列出來。

因爲我們監聽的是 document 對象,瀏覽器所有內聯事件都對應着 document.on*** 這類屬性,因此只需運行時遍歷一下 document 對象,即可獲得所有的事件名。

<img src="*" οnerrοr="alert('xss')" />
<script>
    function hookEvent(eventName) {
        document.addEventListener(eventName.substr(2), function(e) {
            var element = e.target;
            if (element.nodeType != Node.ELEMENT_NODE) {
                return;
            }
            var code = element.getAttribute(eventName);
            if (code && /xss/.test(code)) {
                element[eventName] = null;
                alert('攔截可疑事件:', code);
            }
        }, true);
    }

    console.time('耗時');
    for (var k in document) {
        if (/^on./.test(k)) {
            //console.log('監控:', k);
            hookEvent(k);
        }
    }
    console.timeEnd('耗時');
</script>

Run

現在,無論頁面中哪個元素觸發哪個內聯事件,都能預先被我們捕獲,並根據策略可進可退了。(不過在 OSX 10.9+ 的 Safari 瀏覽器無法枚舉出 on 開頭的屬性,可能是個 BUG 吧~)

其他內聯形式

現實中,除了以 on 開頭這種內聯外,還存在一些特殊形式。最常見的就是 javascript: 的屬性,在一些歷史遺留的非標準瀏覽器中,飽受詬病。

例如曾經的 IE 瀏覽器就支持這樣的 URL:

<img src="javascript:alert('hello')">
<img src='vbscript:msgbox "hello"'>

這種畫蛇添足的設計,曾導致過去無數的論壇深受其害。

如今這種毫無意義的過度設計,早已被標準拋棄。除了某些小衆瀏覽器或許仍有遺留,可以參考這裏

不過,有一個使用特別廣泛,以至如今標準仍有保留,那就是:

<a href="javascript:">

對於這類情況,我們就得單獨對待,做其特殊處理:

<a href="javascript:alert(/xss/)">Click Me</a>
<script>
    function hookEvent(eventName) {
        var isClick = (eventName == 'onclick');

        document.addEventListener(eventName.substr(2), function(e) {
            var el = e.target;
            if (el.nodeType != Node.ELEMENT_NODE) {
                return;
            }

            // ...

            // 掃描 <a href="javascript:"> 的腳本
            if (isClick && el.tagName == 'A' && el.protocol == 'javascript:') {
                var code = el.href.substr(11);
                if (/xss/.test(code)) {
                    el.href = 'javascript:void(0)';
                    alert('攔截可疑事件:', code);
                }
            }
        }, true);
    }

    for (var k in document) {
        if (/^on./.test(k)) {
            hookEvent(k);
        }
    }
</script>

Run

性能優化

或許有些事件沒有必要捕獲,例如視頻播放、音量調節等,但就算全都捕捉也耗不了多少時間,基本都在 1ms 左右。

當然,註冊事件本來就花不了多少時間,真正的耗費都算在回調上了。儘管大多數事件觸發都不頻繁,額外的掃描可以忽律不計。但和鼠標移動相關的事件那就不容忽視了,因此得考慮性能優化。

顯然,內聯事件代碼在運行過程中幾乎不可能發生變化。使用內聯事件大多爲了簡單,如果還要在運行時 setAttribute 去改變內聯代碼,完全就是不可理喻的。因此,我們只需對某個元素的特定事件,掃描一次就可以了。之後根據標誌,即可直接跳過。

<div style="width:100%; height:100%; position:absolute; background: red" οnmοusemοve="alert('xss')">
    <a href="javascript:alert(/xss/)">Click Me</a>
</div>
<script>
    var mCheckMap = {};
    var mCheckID = 0;

    function hookEvent(eventName, eventID) {
        var isClick = (eventName == 'onclick');

        function scanElement(el) {
            //
            // 跳過已掃描的事件
            //
            var flag = el['_k'];
            if (!flag) {
                flag = el['_k'] = ++mCheckID;
            }

            var hash = (flag << 8) | eventID;
            if (hash in mCheckMap) {
                return;
            }
            mCheckMap[hash] = true;

            // 非元素節點
            if (el.nodeType != Node.ELEMENT_NODE) {
                return;
            }

            // 掃描內聯代碼
            var code;
            if (el[eventName]) {
                code = el.getAttribute(eventName);
                if (code && /xss/.test(code)) {
                    el[eventName] = null;
                    alert('攔截可疑事件:' + code);
                }
            }

            // 掃描 <a href="javascript:"> 的腳本
            if (isClick && el.tagName == 'A' && el.protocol == 'javascript:') {
                var code = el.href.substr(11);
                if (/xss/.test(code)) {
                    el.href = 'javascript:void(0)';
                    alert('攔截可疑事件:' + code);
                }
            }

            // 掃描上級元素
            scanElement(el.parentNode);
        }

        document.addEventListener(eventName.substr(2), function(e) {
            scanElement(e.target);
        }, true);
    }

    var i = 0;
    for (var k in document) {
        if (/^on./.test(k)) {
            hookEvent(k, i++);
        }
    }
</script>

Run

這樣,之後的掃描僅僅是檢測一下,目標對象是否存在標記而已。即使瘋狂晃動鼠標,CPU 使用率也都忽略不計了。

與之前不同的是,這裏我們增加了一個叫 scanElement 的函數,並遞歸掃描上級元素。之所以這麼做,還是因爲和冒泡有關。即使當前元素不存在內聯事件,但並不代表上級容器也沒有。因此,我們將元素自身及所有上級 DOM 都掃描一遍,以防萬一。由於掃描過的會有標記,所以並不會增加性能消耗。

到此,在 XSS 內聯事件這塊,我們已實現主動防禦。

對於有着大量字符,或者出現類似 String.fromCharCode,$.getScript 這類典型 XSS 代碼的,完全可以將其攔截;發現有 alert(/xss/),alert(123) 這些測試代碼,可以暫時放行,並將日誌發送到後臺,確定是否能夠復現。

如果復現,說明已有人發現 XSS 併成功注入了,但還沒大規模開始利用。程序猿們趕緊第一時間修 BUG 吧,讓黑客忙活一陣子後發現漏洞已經修復了:)

字符策略的缺陷

但是,光靠代碼字符串來判斷,還是會有疏漏的。尤其是黑客們知道有這麼個玩意存在,會更加小心了。把代碼轉義用以躲避關鍵字,並將字符存儲在其他地方,以躲過長度檢測,即可完全繞過我們的監控了:

<img src="*" οnerrοr="window['ev'+'al'](this.align)" align="alert('a mass of code...')">

因此,我們不僅需要分析關鍵字。在回調執行時,還需監控 eval、setTimeout('...') 等這類能解析代碼的函數被調用。

不過,通常不會注入太多的代碼,而是直接引入一個外部腳本,既簡單又靠譜,並且能實時修改攻擊內容:

<img src="*" οnerrοr="$['get'+'Script'](...)">

下一篇將討論,如何攔截可疑的外部模塊

轉載:http://fex.baidu.com/blog/2014/06/xss-frontend-firewall-1/

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章