1. 程式人生 > >使用 SRI 增強 localStorage 程式碼安全

使用 SRI 增強 localStorage 程式碼安全

提醒:本文最後更新於 366 天前,文中所描述的資訊可能已發生改變,請謹慎使用。

在上篇介紹 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 URIsBlob 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.blog_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 中的程式碼被修改。

--EOF--

提醒:本文最後更新於 366 天前,文中所描述的資訊可能已發生改變,請謹慎使用。