JavaScript 沙箱

概述
沙箱可以簡單的理解為一個虛擬機,是一個和宿主機隔離的環境,在這個環境中去運行一些不受信任的代碼或者應用程序,防止不安全的代碼對系統造成損害。
比如我們現在知道某個應用是詐騙軟件或者病毒軟件,但是我們依舊想要運行,想逆向分析他,那么我們就可以選擇在電腦上安裝一個虛擬機,在這個虛擬機中,我們將對攝像頭的訪問引導至一張靜態圖片或者視頻,將麥克風的訪問引導至一個事先錄制好的音頻文件中,通訊錄、應用列表我們也提前做好“偽裝”提供給這個軟件。
上面所述的整個過程實際就是建立沙箱的一個過程,運行在這里面的應用所能訪問到的都是我們事先準備好的,他無法直接訪問到我們的電腦環境,從而保證了我們不受到惡意攻擊。
計算機領域版的楚門的世界
當然,電影也很好看,推薦大家看看~
沙箱機制的原理非常好理解,適用性也很廣,在計算機領域中,沙箱也存在很多種,本文僅介紹 JavaScript 中的沙箱實現。
格局打開,不要僅僅把目光放在計算機領域,沙箱本質上就是 **讓你看到我想讓你看到的東西 **,詐騙實際也是遵循這種,很刑的。
應用場景
沙箱的應用場景十分廣泛,包括操作系統、網絡瀏覽器、移動應用程序等。
本文要介紹的 JS 沙箱通常是用于 Web 瀏覽器中的,限制不受信任的代碼的訪問權限,通常認為用戶自己編寫的代碼就是不受信任的代碼。
服務器端也是可以使用 js 沙箱的,用以執行不受信任的代碼,比如在 leetcode 上做題的時候,我們提交的代碼是在服務器端執行的,這是為了防止用戶寫一些惡意的代碼突破權限,對服務器造成危害。
所以,JS 沙箱的應用主要圍繞以下兩點:
- 安全:解析不受信的 js 文件,防止 XSS 等
- 應用程序隔離:限制代碼訪問相關的對象,如彈窗廣告
詳細展開可以有更多,但是大體方向是這兩個。
實現
實現的思路上,可以大致劃分為三大類:IFrame、JavaScript 語言特性、快照
IFrame
特點 :瀏覽器支持的 HTML 元素,自帶沙箱隔離,能夠與主頁面通信。
缺點 :瀏覽器會為其單獨開啟一個子進程,有額外的性能開銷。不同瀏覽器對 sandbox 屬性的支持也有所不同
HTML元素,這個實際是瀏覽器支持的一種,實現會比較簡單,我們只需要使用對應的屬性 sandbox 即可,主要關注 allow-scripts 屬性,它允許嵌入的頁面運行腳本 [1], 不過就個人使用情況而言,在使用 iframe 的時候,這個屬性基本都處于開啟狀態,不開啟的話,在嵌入一些網站的時候可能會顯示異常。
<iframe src="https://bing.com" sandbox="allow-scripts" />
如上就是一個比較簡單的實現,我們禁用了其他的能力,僅啟用了腳本執行的能力。
allow-scripts 僅允許執行腳本,但是無法創建彈窗這類窗口。
不過尤其注意,作為一種安全機制,沙箱并不能保障絕對的安全,所以對于沙箱中的內容,我們還是需要保證加載的內容的可信度和安全性,避免惡意用戶突破或繞過沙箱造成攻擊,導致我們產生損失。
主要是存在跨站腳本攻擊(XSS)的問題,這里舉兩個例子:
- 竊取敏感信息:如登錄憑據,通過過濾相關標簽或者編碼來規避。
- 點擊劫持:釣魚網站,通常不是對網站的危害,而是對用戶的,采用禁止網站被嵌入(
X-Frame-Options或Content-Security-Policy)來規避
一般來說,我們在 iframe 中都會訪問受信的站點,即使是彈窗廣告,不過對于廣告,我們通常會限制一些操作,比如不允許運行腳本。如果不加以限制,他可能不會直接危害到我們的站點安全,但是可能有用戶信任下降及影響品牌聲譽的問題,當有更好的站點作為替代的時候,那用戶則會棄之如敝履。
目前這種彈窗廣告你已經很少能在正規的網站上看到了,各個大廠更傾向于在信息流中加入廣告。
JavaScript 語言特性
特點:各個瀏覽器表現基本一致
缺點:性能表現與代碼實現的優劣相關
前文說過,沙箱本質是一種安全機制,是為了_** 限制第三方不受信任的代碼對系統內容的訪問 **_,所以結合我們對 JavaScript 語言的了解,我們可以考慮作用域來限制訪問系統級變量。
作用域
目前市面上的編程語言,基本都有作用域的概念:在程序中定義變量的可見性和訪問范圍。 它直接決定了變量的生命周期和可以訪問變量的代碼片段,一般作用域[2]包括:
- 全局作用域(Global Scope):當前程序可以在任意位置處訪問的變量或函數,如 window。
- 模塊作用域(Module Scope):即 import 和 export,通常認為一個文件是一個模塊,在文件內定義的變量或函數都是該模塊私有的,如需在外部使用,則需要 export。
- 函數作用域(Function Scope):由函數創建的作用域,在 JavaScript 中,創建函數會為我們創建一個獨立的作用域,在 es6 之前沒有 es Module 規范時,我們使用 Function 幫助我們創建獨立的作用域來實現模塊化,即 umd 和 amd。
- 塊級作用域(Block Scope):es6 中引入,用一對花括號括起來的代碼塊,只對 let 和 const 聲明有效。var 聲明無效。
看到這里,不難看出,作用域實際就是一個天然的沙盒,我們可以這樣實現:
window; // 瀏覽器的 window 對象
window.app = 2; // 增加一個 app 字段,并將其賦值為 2
function execCode(code: string) {
const window = null;
eval(code);
}
const code = 'window.app = 1';
execCode(code); // 將 window.app 的值修改為 1,執行結果:Uncaught TypeError: Cannot set properties of null (setting 'app')
可以看到,此時執行第三方代碼的時候,這些代碼是無法訪問我們的 window 對象的,從而保證了我們的 window 對象的安全。
但實際這并不是真正的安全,我們依舊有辦法能夠繞過他,比如,當我們全局定義了一個函數 updateApp(app) 的時候,我們在這里實際可以通過調用這個函數的方式來繞過我們作用域的限制:
function updateApp(app: number) {
window.app = app;
}
execCode('updateApp(1)'); // 執行成功,window.app 此時為 1
所以我們又要重申一遍:作為一種安全機制,沙箱并不能保障絕對的安全
這里沒有考慮 with 關鍵字,因為他已經從 es5 開始的嚴格模式下就已經被禁用了,而現在由于我們使用的框架默認是以嚴格模式執行的,所以可以說 with 關鍵字其實已經處于不可用的狀態了。新項目已經不建議使用,但是老項目還在用的話,那就保持現狀吧。
不過對于這種方式實現的沙盒,我們實際可以進一步優化,引入 JavaScript 中的 new Function [3] 構造函數來執行代碼,避免一些簡單的安全問題:
function execCode(code) {
const func = new Function('window', code);
func(null);
}
此時再執行時,你會發現,updateApp(1) 執行會報錯 ReferenceError: updateApp is not defined ,代碼字符串編譯后無法訪問我們的 updateApp 函數了。
因為我們在這里構造了一個類似于 function (window) { window.app = 1 } 的函數,并在后續執行調用動作。
eval 能夠訪問本地作用域,new Function 則只能訪問全局變量和自己的局部變量,同時其構造器創建時所在的作用域的內容是無法訪問的。
Proxy 代理
看上去,Proxy 是一個比較復雜的內容,但實際上他本質上就是一個攔截器,在訪問目標內容之前,需要先經過 Proxy 幫忙去通知。
一個更貼近現實的例子,Proxy = 租房中介,你想要租房,就需要通過中介去介紹。
如果你繞過中介直接和房東交易,當然也是可行的,因為原始的交易對象是你已知的。
Proxy 的一個簡單示例:
const windowProxy = new Proxy(window, {
get(obj, propKey) {
if (propKey === 'test') {
return 'hello world';
}
return obj[propKey];
}
})
windowProxy.test; // hello world,通過中介交易
window.test; // undefined,繞過中介直接和房東交易
通過上面這個簡單的例子,我們可以很輕松的看出,我們在創建出來的 Proxy 對象中限制了他訪問 test 屬性的內容,這實際上是在做我們一開始說的,偽裝應用程序所需要的內容(變量等),提供給應用程序使用,從而實現對惡意攻擊的防范。至于運行代碼?那不好意思,Proxy 本身是無法像 eval 和 new Function 一樣去運行一段代碼的。
不過 Proxy 方式創建沙箱也需要注意:
- proxy 默認只會代理一級對象:也就是說,當訪問的對象是
{ a: { b: { c: 1 } } }這種,用戶訪問proxy.a.b.c其實操作的就是原對象。
基于快照的沙箱
快照(Snapshot),就是存儲某一時刻相關數據的副本。
基于快照的沙箱,顧名思義,就是在程序運行的某一個時刻,或者執行某一個操作時保存當前運行環境副本,再后續的某一個時刻恢復原運行環境,從而實現沙箱機制。
通常我們是將副本用于操作,操作結束時,將副本的更新寫入原始運行環境,舉個例子:
const snapshotSandbox = {
original: null,
copied: null,
beforeAction: (obj, dangerKeys) => {
// 保留原始副本
this.original = obj;
const snapshot = {};
// 記錄當前信息,做一次快照
for (const key of Object.keys(obj)) {
if (dangerKeys.includes(key)) {
// 對于敏感信息,不提供或提供加密信息
continue;
}
snapshot[key] = obj[key];
}
console.log(snapshot);
this.copied = snapshot;
return this.copied;
},
afterAction: () => {
// 將操作結果更新到原始副本中
for (const key of Object.keys(this.copied)) {
this.original[key] = this.copied[key];
}
}
}
function getUserId(user) {
const id = user.id;
const name = user.name;
console.log('id', id, 'name', name);
// ajax('用于第三方不安全平臺登記認證');
}
const code = getUserId.toString();
const base = {
id: 'xxxx',
name: '一個人',
age: '12',
phoneNo: '1333333333333'
};
// 1. 現在假設有一個危險操作被注入到頁面上會使用用戶的 ID 信息(身份證)
const userInfo = snapshotSandbox.beforeAction(base, ['id', 'phoneNo']);
// 2. 做壞事
eval('console.log(userInfo.id + \'被拿去登記\'); console.log("這個人受到了影響: " + userInfo.name); console.log("給這個人打電話爆破:" + userInfo.name); userInfo.age = 11');
// 3. 把更新的內容寫回原來的對象
snapshotSandbox.afterAction();
console.log('使用的 userInfo 信息', userInfo);
console.log('最終操作后的 userInfo 信息', base);
console.log('這兩個信息理論上不是同一個對象', userInfo !== base)
這個實現僅僅是其中一種方式,在微前端中,基于快照的實現在以前也是一種流行的版本,目的是為了隔離不同子應用,現在多數基于 Proxy 來實現了,但快照沙箱依舊是作為一種降級方案去兼容老舊的瀏覽器。
最后
我們從應用場景的角度分析,JS 沙箱聚焦于 “安全防護” 與 “應用隔離” 這兩大核心需求:
在 Web 瀏覽器端,JS 沙箱能夠限制用戶自行編寫的代碼作用范圍。例如,它可實現攔截彈窗廣告、抵御 XSS 攻擊等功能,避免不可信的 JS 文件對頁面正常運行造成干擾。而在服務器端,如 LeetCode 代碼提交、在線編輯器等,沙箱可以提供有力保障。它能夠防止用戶提交的惡意代碼突破權限限制,從而避免服務器配置被篡改以及數據被竊取的風險。
盡管 Web 瀏覽器端和服務器端的部署環境存在差異,然而它們的最終目標是一致的:即,確保代碼在可控范圍內運行,降低風險擴散的可能性。
參考文章
[1]: <iframe> - HTML(超文本標記語言) | MDN


浙公網安備 33010602011771號