使用 SRI 增強 localStorage 代碼安全

在上篇介紹 Subresource Integrity(SRI)的文章最后,我提出一個問題:現在廣泛被大家使用的「將 JS 代碼緩存在本地 localStorage」方案有很大的安全隱患。網站出現任何 XSS,都有可能被用來篡改緩存在 localStorage 中的代碼。之后即使 XSS 被修復,localStorage 中的代碼依然是被篡改過的,持續發揮作用。本文接著討論這個話題。

將 JS/CSS 代碼緩存在本地的用途,本博客反復講過,這里不再啰嗦。這個安全隱患的根源在于:大部分 Web 應用從 localStorage 中獲取緩存代碼后,沒有任何檢測機制,直接執行。而 localStorage 是跨頁面的,同域下任何頁面有 XSS 漏洞,就可以被攻擊者用來往 localStorage 寫入惡意代碼。

以下幾段示意代碼,可以幫大家更清楚地看出問題所在:


<!-- 首次訪問 -->

    <script id="code">/*一大段正常代碼*/</script>

    <script>html2ls('my_code', document.getElementById('code').innerHTML)</script>

    <script>
    function html2ls(ls_name, code) {
        localStorage[ls_name] = code;
    }
    </script>

<!-- 第二次訪問 -->

    <script>ls2html('my_code')</script>

    <script>
    function ls2html(ls_name) {
        var script = document.createElement('script');
        script.innerHTML = localStorage[ls_name]; // 取到:/*一大段正常代碼*/
        document.head.appendChild(script);
    }
    </script>

<!-- 訪問有 XSS 的頁面 -->

    <img src="" onerror="localStorage['my_code']+=';alert(0);'" />

<!-- 第 N 次訪問 -->

    <script>ls2html('my_code')</script>

    <script>
    function ls2html(ls_name) {
        var script = document.createElement('script');
        script.innerHTML = localStorage[ls_name]; // 取到:/*一大段正常代碼*/;alert(0)
        document.head.appendChild(script);
    }
    </script>

很多 Web 應用會使用 loader 來加載資源,如果 loader 里有從 localStorage 讀取并執行代碼的邏輯,也有相同的安全隱患,原理都一樣。

要解決這個問題,很容易想到的方案是:在頁面中輸出緩存資源的摘要簽名,并在 ls2html 函數中校驗。但在瀏覽器中計算簽名,需要額外引入一大段 JS。而為了讓 ls2html 盡快可用(因為從 localStorage 中讀取 CSS 也依賴于它),這段 JS 必須在頁面最開頭引入,這對頁面性能影響很大。另外,自己實現的摘要算法,在處理大段文本時效率也不會太高。

在上篇文章中,我們知道了利用 SRI 策略,可以讓瀏覽器自動計算外鏈資源的簽名與內容是否匹配。不需要額外引入新的代碼,瀏覽器內置的算法也會有更高的效率。

由于 SRI 只能作用于外鏈資源,還需要將從 localStorage 獲取到的代碼轉為外鏈形式。有兩個方案可以實現這一需求:data URIs 和 Blob URL。

將代碼轉為 data URIs 形式的外鏈并啟用 SRI:


var code = 'alert("hello world!");';

    var script = document.createElement('script');
    script.crossOrigin = 'anonymous';
    script.integrity = 'sha256-0URT8NZXh/hI7oaypQXNjC07bwnLB52GAjvNiCaN7Gc=';
    script.src = 'data:application/x-javascript,' + encodeURIComponent(code);

    document.head.appendChild(script);

將代碼轉為 Blob URL 形式的外鏈并啟用 SRI:


var code = 'alert("hello world!");';

    var blob = new Blob([code], {type: "application/x-javascript"});
    var blobUrl = URL.createObjectURL(blob);

    var script = document.createElement('script');
    script.crossOrigin = 'anonymous';
    script.integrity = 'sha256-0URT8NZXh/hI7oaypQXNjC07bwnLB52GAjvNiCaN7Gc=';
    script.src = blobUrl;

    document.head.appendChild(script);

分別在支持 SRI 的 Chrome 和 Firefox 中測試,結果如下:

測試用例 Chrome 46.0.2490.33 beta Firefox 44.0a1 (2015-09-23)

data URIs(無 SRI)

正常執行

正常執行

Blob URL(無 SRI)

正常執行

正常執行

data URIs(SRI + 正確摘要)

CORS 報錯

不執行、不報錯

Blob URL(SRI + 正確摘要)

正常執行

不執行、不報錯

data URIs(SRI + 錯誤摘要)

CORS 報錯

不執行、不報錯

Blob URL(SRI + 錯誤摘要)

Integrity 不匹配報錯

不執行、不報錯

上面的測試結果表明:

  1. 沒有 SRI 策略時,這兩種方式都可以把字符串轉為外鏈形式加載并執行;
  2. Firefox 中,啟用 SRI 后,data URIs 和 Blob URL 兩種形式的外鏈都不執行;
  3. Chrome 中,啟用 SRI 后,data URIs 形式的外鏈始終會報 CORS 跨域錯誤;
  4. Chrome 中,啟用 SRI 后,Blob URL 形式的外鏈會校驗 integrity 屬性;

可以看到,最后一種情況是我想要的。改造前面的代碼,在第二次訪問時輸出簽名,并增加校驗機制:


<!-- 第二次訪問 -->

    <script>ls2html('my_code', 'sha256-xxxx')</script>

    <script>
    function ls2html(ls_name, integrity) {
        var script = document.createElement('script');
        var code = localStorage[ls_name];

        //計算 chrome 版本號
        var chromeVersion = -1;
        var match = /chrome\/(\d+)/i.exec(navigator.userAgent);
        if(match) {
            chromeVersion = match[1] | 0;
        }

        //chrome 45 才開始支持 SRI
        if (integrity && chromeVersion > 44) {
            var blob = new Blob([code], {type: "application/x-javascript"});
            var blobUrl = URL.createObjectURL(blob);

            script.crossOrigin = 'anonymous';
            script.integrity = integrity;
            script.src = blobUrl;

            script.onerror = function() { alert('localStorage 代碼被修改!') };
        } else {
            script.innerHTML = code;
        }

        document.head.appendChild(script);
    }
    </script>

核心邏輯就是這樣,細節上還有一些地方要考慮。例如如果啟用了 CSP 策略,需要在 script-src 配置中加上 blob:;另外這樣改寫之后,之前同步加載的代碼變成了異步。我的博客已經用上了本文這個 localStorage 代碼安全增強方案,在本博客任意頁面打開瀏覽器控制臺,執行以下代碼并刷新頁面:


localStorage.all_js += ';alert(0);'

如果你的瀏覽器是 Chrome 45+,會發現 alert(0) 并不會執行。我會檢測出 localStorage 代碼被修改,從而自動修復。

關于 Chrome 和 Firefox 實現上的差異,我咨詢了 [email protected] 郵件組,得到的答復是 Chrome 符合預期。Mozilla 的 Bugzilla 中已經有關于本問題的討論。

在 NodeJS 中,計算符合 SRI 要求的 integrity 值很簡單,使用 crypto 模塊就可以:


var crypto = require('crypto');

    function getIntegrity(content, algorithm) {
        algorithm = algorithm || 'sha256';

        var result = algorithm + '-' + crypto
                .createHash(algorithm)
                .update(content)
                .digest("base64");

        return result;
    }

最后,使用 Content Security Policy Level 2(CSP2)策略,也可以校驗內聯代碼是否被修改過,支持度更好一些,但使用起來也更麻煩。這部分內容留給以后有時間再寫。

更新:新版 Firefox 中,啟用 SRI 后,Blob URL 形式的外鏈也會校驗 integrity 屬性了。也就是說,本站的 localStorage 代碼防篡改策略在最新的 Chrome 和 Firefox 下都能正常運行。

2016-08-29 更新:目前本站已經改用 CSP2 來防止 localStorage 中的代碼被修改。

專題「瀏覽器」的其他文章 ?

  • iOS 10 Safari 視頻播放新政策 (Oct 07, 2016)
  • Chrome 中 scrollingElement 的變化 (Apr 16, 2016)
  • 域名小知識:Public Suffix List (Nov 28, 2015)
  • window.opener.location 安全風險討論 (Oct 09, 2015)
  • Subresource Integrity 介紹 (Sep 23, 2015)
  • 移動 Web 與 JavaScript 定時器 (Mar 27, 2014)
  • Chrome 和 Web Fonts 二三事 (Mar 24, 2014)
  • Webkit 異步加載 CSS 的奇怪現象 (Dec 25, 2013)
  • 小成本實現部分選中的復選框 (Dec 22, 2013)
  • Chrome 滾動條凍結現象 (Dec 02, 2013)

所屬標簽

無標簽

25选5玩法中奖