構建一個安全的 JavaScript 沙箱
在 Node.js 中有一個模塊叫做 VM,它提供了幾個 API,允許代碼在 V8 虛擬機上下文中運行,如:
const vm = require('vm');
const sandbox = { a: 1, b: 2 };
const script = new vm.Script('a + b');
const context = new vm.createContext(sandbox);
script.runInContext(context);
vm.Script 中的代碼是預編譯好的,通過 vm.createContext 將代碼加載到一個上下文環境中,置入沙箱(sandbox),然后通過 script.runInContext 執行代碼,整個操作都在封閉的 VM 中進行。這是 Node.js 提供給我們的便捷功能,那么,在瀏覽器環境中呢?是否也能做到將代碼運行在沙箱中?本文帶著大家來探索一番。
代碼編譯工具
邪惡的 eval
eval 函數可以將一個 Javascript 字符串視作代碼片段執行,不過它存在諸多問題,如調試困難、性能問題等,并且它在運行時可以訪問閉包環境和全局作用域,存在代碼注入的安全風險,作為沙箱,這也是我們不期望看到的。 eval 雖然好用,但是經常被濫用,在這里我們不多討論它。
new Function
Function 構造函數會創建一個新的函數對象,它可以作為 eval 的替代品:
fn = new Function(...args, 'functionBody');
返回的 fn 是一個定義好的函數,最后一個參數為函數體。它和 eval 不太一樣:
- fn 是一段編譯好的代碼,可以直接執行,而 eval 需要編譯一次
- fn 沒有對所在閉包的作用域訪問權限,不過它依然能夠訪問全局作用域
如何阻止它訪問全局作用域呢?
with 關鍵詞
with 是阻止程序訪問上一級作用域的一道防火墻:
function compileCode(code) {
code = 'with (sandbox) {' + code + '}';
return new Function('sandbox', code);
}
如上代碼, code 被執行時,首先會尋找 sandbox 中的變量,如果不存在,會往上追溯 global 對象,雖然有一道防火墻,但是依然不能阻止 fn 訪問全局作用域。
似乎在 ECMAScript 5 中掌握的知識已經不足以解決 code 逃逸沙箱的問題了,此時我們可以把焦點放在 ES6 提供的新特性上。
ES6 Proxy
ES6 中提供了一個 Proxy 函數,它是訪問對象前的一個攔截器,下面舉一個簡單的栗子:
const p = new Proxy({}, {
get(target, key) {
if(key === 'a') {
return 1;
}
Reflect.get(target, key);
}
});
p.a // 1
p.s // undefined
代碼中, Proxy 給 {} 設置了屬性訪問攔截器,倘若訪問的屬性為 a 則返回 1,否則走正常程序。
這里我們可以使用 proxy 對訪問做攔截處理, sandbox 本不存在的屬性會追溯到全局變量上訪問,此時我們可以欺騙程序,告訴它這個「不存在的屬性」是存在的,于是有了下面的代碼:
function compileCode(code) {
code = 'with (sandbox) {' + code + '}';
const fn = new Function('sandbox', code);
return (sandbox) => {
const proxy = new Proxy(sandbox, {
has(target, key) {
return true; // 欺騙,告知屬性存在
}
});
return fn(proxy);
}
}
似乎這么做就可以了,但既然用到了 ES6 的特性,我們便不能忽略 ES6 中一個可以控制 with 關鍵詞行為的變量。
Symbol.unscopables
Symbol 是 JS 的第七種數據類型,它能夠產生一個唯一的值,同時也具備一些內建屬性,這些屬性可以用來進行元編程(meta programming),即對語言本身編程,影響語言行為。其中一個內建屬性 Symbol.unscopables ,通過它可以影響 with 的行為。
const foo = () => 'global';
class A {
foo() { return 'clourse'; }
get [Symbol.unscopables]() {
return {
foo: true // 不允許訪問對象的 foo,直接到上層
}
}
}
with(A.prototype) {
foo(); // 'global'
}
上面對 A 設置做了 Symbol.unscopables 的設定,聲明 foo 屬性在 A 上是不存在的,從而使得代碼從 with 中逃逸。對此,我們需要對它做一層加固:
function compileCode(code) {
code = 'with (sandbox) {' + code + '}';
const fn = new Function('sandbox', code);
return (sandbox) => {
const proxy = new Proxy(sandbox, {
has(target, key) {
return true; // 欺騙,告知屬性存在
}
get(target, key, receiver) {
// 加固,防止逃逸
if (key === Symbol.unscopables) {
return undefined;
}
Reflect.get(target, key, receiver);
}
});
return fn(proxy);
}
}
存在的漏洞
不過,這里還存在兩個邏輯漏洞:
- code 中可以提前關閉 sandbox 的 with 語境,如 '} alert(this); {' ;
- code 中可以使用 eval 和 new Function 直接逃逸
對于第一個問題,我們可以通過堆棧深度檢測:
let stack = 0;
for (let char of code) {
if (char === '{') {
stack++;
} else if (char === '}') {
if (stack === 0) {
throw new Error('Syntax Error.');
} else {
stack--;
}
}
}
事實上,這樣做依然不嚴謹,比如代碼注釋中出現花括號問題,如 /*{*/'} alert(this); {'/*}*/ ;而對于第二個問題,暫時還沒有什么好的辦法,尤其是 Function ,它可以通過很多方式構造出來:
(function(){}).constructor("alert(this)")();
/2/.constructor.constructor("alert(this)")();
最后
靈活是 Javascript 這門語言的特性,也是它難以被掌控的主要原因,這點可以從文中各種沙箱逃逸方式就能看出。ES6 提供了很多新的特性,本文以沙箱為切入點,帶著大家學習了幾個函數和屬性,希望讀者有些收獲。
本文沒有得到一個完美的答案,但是這個問題依然值得思考和研究。
有一個比較不錯的思路是,通過 iframe 執行代碼,執行的結果通過 postMessage 函數通訊傳輸給操作者。并且 iframe 還提供了很多可供設置的安全參數,如 allow-scripts , allow-forms , allow-same-origin , allow-top-navigation 等等,方便我們對沙箱做安全控制。
更多閱讀
來自:http://www.barretlee.com/blog/2016/08/23/javascript-sandbox/
Save