使用 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 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 不匹配報錯 | 不執行、不報錯 |
上面的測試結果表明:
- 沒有 SRI 策略時,這兩種方式都可以把字串轉為外鍊形式載入並執行;
- Firefox 中,啟用 SRI 後,data URIs 和 Blob URL 兩種形式的外鏈都不執行;
- Chrome 中,啟用 SRI 後,data URIs 形式的外鏈始終會報 CORS 跨域錯誤;
- 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--
發表於 2015-09-26 17:00:48 ,並被新增「 SRI 、 Web安全 、 LocalStorage 」標籤 ,最後修改於 2017-11-20 15:09:08 。檢視本文 Markdown 版本 »
提醒:本文最後更新於 366 天前,文中所描述的資訊可能已發生改變,請謹慎使用。