在 Node.js 中有一個模組叫做 VM,它提供了幾個 API,允許程式碼在 V8 虛擬機器上下文中執行,如:
1 2 3 4 5 |
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
的替代品:
1 |
fn = new Function(...args, 'functionBody'); |
返回的 fn
是一個定義好的函式,最後一個引數為函式體。它和 eval
不太一樣:
fn
是一段編譯好的程式碼,可以直接執行,而eval
需要編譯一次fn
沒有對所在閉包的作用域訪問許可權,不過它依然能夠訪問全域性作用域
如何阻止它訪問全域性作用域呢?
with
關鍵詞
with
是阻止程式訪問上一級作用域的一道防火牆:
1 2 3 4 |
function compileCode(code) { code = 'with (sandbox) {' + code + '}'; return new Function('sandbox', code); } |
如上程式碼,code
被執行時,首先會尋找 sandbox
中的變數,如果不存在,會往上追溯global
物件,雖然有一道防火牆,但是依然不能阻止 fn 訪問全域性作用域。
似乎在 ECMAScript 5 中掌握的知識已經不足以解決 code
逃逸沙箱的問題了,此時我們可以把焦點放在 ES6 提供的新特性上。
ES6 Proxy
ES6 中提供了一個 Proxy 函式,它是訪問物件前的一個攔截器,下面舉一個簡單的栗子:
1 2 3 4 5 6 7 8 9 10 |
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
本不存在的屬性會追溯到全域性變數上訪問,此時我們可以欺騙程式,告訴它這個「不存在的屬性」是存在的,於是有了下面的程式碼:
1 2 3 4 5 6 7 8 9 10 11 12 |
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
的行為。
1 2 3 4 5 6 7 8 9 10 11 12 |
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
中逃逸。對此,我們需要對它做一層加固:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 |
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
直接逃逸
對於第一個問題,我們可以通過堆疊深度檢測:
1 2 3 4 5 6 7 8 9 10 11 12 |
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
,它可以通過很多方式構造出來:
1 2 |
(function(){}).constructor("alert(this)")(); /2/.constructor.constructor("alert(this)")(); |
最後
靈活是 Javascript 這門語言的特性,也是它難以被掌控的主要原因,這點可以從文中各種沙箱逃逸方式就能看出。ES6 提供了很多新的特性,本文以沙箱為切入點,帶著大家學習了幾個函式和屬性,希望讀者有些收穫。
本文沒有得到一個完美的答案,但是這個問題依然值得思考和研究。
有一個比較不錯的思路是,通過 iframe 執行程式碼,執行的結果通過 postMessage
函式通訊傳輸給操作者。並且 iframe 還提供了很多可供設定的安全引數,如 allow-scripts
,allow-forms
, allow-same-origin
, allow-top-navigation
等等,方便我們對沙箱做安全控制。