有哪些動態執行指令碼的場景?
在一些應用中,我們希望給使用者提供插入自定義邏輯的能力,比如 Microsoft 的 Office 中的 VBA
,比如一些遊戲中的 lua
指令碼,FireFox 的「油猴指令碼」,能夠讓使用者發在可控的範圍和許可權內發揮想象做一些好玩、有用的事情,擴充套件了能力,滿足使用者的個性化需求。
大多數都是一些客戶端程式,在一些線上的系統和產品中也常常也有類似的需求,事實上,線上的應用中也有不少提供了自定義指令碼的能力,比如 Google Docs 中的 Apps Script
,它可以讓你使用 JavaScript
做一些非常有用的事情,比如執行程式碼來響應文件開啟事件或單元格更改事件,為公式製作自定義電子表格函式等等。
與執行在「使用者電腦中」的客戶端應用不同,使用者的自定義指令碼通常只能影響使用者自已,而對於線上的應用或服務來講,有一些情況就變得更為重要,比如「安全」,使用者的「自定義指令碼」必須嚴格受到限制和隔離,即不能影響到宿主程式,也不能影響到其它使用者。
而 Safeify 就是一個針對 Nodejs 應用,用於安全執行使用者自定義的非信任指令碼的模組。
怎樣安全的執行動態指令碼?
我們先看看通常都能如何在 JavaScript 程式中動態執行一段程式碼?比如大名頂頂的 eval
eval('1+2')
複製程式碼
上述程式碼沒有問題順利執行了,eval
是全域性物件的一個函式屬性,執行的程式碼擁有著和應用中其它正常程式碼一樣的的許可權,它能訪問「執行上下文」中的區域性變數,也能訪問所有「全域性變數」,在這個場景下,它是一個非常危險的函式。
再來看看 Functon
,通過 Function
構造器,我們可以動態的建立一個函式,然後執行它
const sum = new Function('m', 'n', 'return m + n');
console.log(sum(1, 2));
複製程式碼
它也一樣的順利執行了,使用 Function 構造器生成的函式,並不會在建立它的上下文中建立閉包,一般在全域性作用域中被建立。當執行函式的時候,只能訪問自己的本地變數和全域性變數,不能訪問 Function 構造器被呼叫生成的上下文的作用域。如同一個站在地上、一個站在一張薄薄的紙上一樣,在這個場景下,幾乎沒有高下之分。
結合 ES6 的新特性 Proxy
便能更安全一些
function evalute(code,sandbox) {
sandbox = sandbox || Object.create(null);
const fn = new Function('sandbox', `with(sandbox){return (${code})}`);
const proxy = new Proxy(sandbox, {
has(target, key) {
// 讓動態執行的程式碼認為屬性已存在
return true;
}
});
return fn(proxy);
}
evalute('1+2') // 3
evalute('console.log(1)') // Cannot read property 'log' of undefined
複製程式碼
我們知道無論 eval
還是 function
,執行時都會把作用域一層一層向上查詢,如果找不到會一直到 global
,那麼利用 Proxy
的原理就是,讓執行了程式碼在 sandobx
中找的到,以達到「防逃逸」的目的。
在瀏覽器中,還可以利用 iframe,建立一個再多安全一些的隔離環境,本文著眼於 Node.js,在這裡不做過多討論。
在 Node.js 中呢,有沒有其它選擇
或許沒看到這兒之前你就已經想到了 VM
,它是 Node.js 預設就提供的一個內建模組,VM
模組提供了一系列 API 用於在 V8 虛擬機器環境中編譯和執行程式碼。JavaScript 程式碼可以被編譯並立即執行,或編譯、儲存然後再執行。
const vm = require('vm');
const script = new vm.Script('m + n');
const sandbox = { m: 1, n: 2 };
const context = new vm.createContext(sandbox);
script.runInContext(context);
複製程式碼
執行上這的程式碼就能拿到結果 3
,同時,通過 vm.Script
還能指定程式碼執行了「最大毫秒數」,超過指定的時長將終止執行並丟擲一個異常
try {
const script = new vm.Script('while(true){}',{ timeout: 50 });
....
} catch (err){
//列印超時的 log
console.log(err.message);
}
複製程式碼
上面的指令碼執行將會失敗,被檢測到超時並丟擲異常,然後被 Try Cache
捕獲到並打出 log,但同時需要注意的是 vm.Script
的 timeout
選項「只針對同步代有效」,而不包括是非同步呼叫的時間,比如
const script = new vm.Script('setTimeout(()=>{},2000)',{ timeout: 50 });
....
複製程式碼
上述程式碼,並不是會在 50ms 後丟擲異常,因為 50ms 上邊的程式碼同步執行肯定完了,而 setTimeout
所用的時間並不算在內,也就是說 vm
模組沒有辦法對非同步程式碼直接限制執行時間。我們也不能額外通過一個 timer
去檢查超時,因為檢查了執行中的 vm 也沒有方法去中止掉。
另外,在 Node.js 通過 vm.runInContext
看起來似乎隔離了程式碼執行環境,但實際上卻很容易「逃逸」出去。
const vm = require('vm');
const sandbox = {};
const script = new vm.Script('this.constructor.constructor("return process")().exit()');
const context = vm.createContext(sandbox);
script.runInContext(context);
複製程式碼
執行上邊的程式碼,宿主程式立即就會「退出」,sandbox
是在 VM
之外的環境建立的,需 VM
中的程式碼的 this
指向的也是 sandbox
,那麼
//this.constructor 就是外所的 Object 構建函式
const ObjConstructor = this.constructor;
//ObjConstructor 的 constructor 就是外包的 Function
const Function = ObjConstructor.constructor;
//建立一個函式,並執行它,返回全域性 process 全域性物件
const process = (new Function('return process'))();
//退出當前程式
process.exit();
複製程式碼
沒有人願意使用者一段指令碼就能讓應用掛掉吧。除了退出程式序之外,實際上還能幹更多的事情。
有個簡單的方法就能避免通過 this.constructor
拿到 process
,如下:
const vm = require('vm');
//建立一外無 proto 的空白物件作為 sandbox
const sandbox = Object.create(null);
const script = new vm.Script('...');
const context = vm.createContext(sandbox);
script.runInContext(context);
複製程式碼
但還是有風險的,由於 JavaScript 本身的動態的特點,各種黑魔法防不勝防。事實 Node.js 的官方文件中也提到「 不要把 VM
當做一個安全的沙箱,去執行任意非信任的程式碼」。
有哪些做了進一步工作的社群模組?
在社群中有一些開源的模組用於執行不信任程式碼,例如 sandbox
、vm2
、jailed
等。相比較而言 vm2
對各方面做了更多的安全工作,相對安全些。
從 vm2
的官方 README
中可以看到,它基於 Node.js 內建的 VM 模組,來建立基礎的沙箱環境,然後同時使用上了文介紹過的 ES6 的 Proxy
技術來防止沙箱指令碼逃逸。
用同樣的測試程式碼來試試 vm2
const { VM } = require('vm2');
new VM().run('this.constructor.constructor("return process")().exit()');
複製程式碼
如上程式碼,並沒有成功結束掉宿主程式,vm2 官方 REAME 中說「vm2 是一個沙盒,可以在 Node.js 中按全的執行不受信任的程式碼」。
然而,事實上我們還是可以幹一些「壞」事情,比如:
const { VM } = require('vm2');
const vm = new VM({ timeout: 1000, sandbox: {}});
vm.run('new Promise(()=>{})');
複製程式碼
上邊的程式碼將永遠不會執行結束,如同 Node.js 內建模組一樣 vm2 的 timeout
對非同步操作是無效的。同時,vm2
也不能額外通過一個 timer
去檢查超時,因為它也沒有辦法將執行中的 vm 終止掉。這會一點點耗費完伺服器的資源,讓你的應用掛掉。
那麼或許你會想,我們能不能在上邊的 sandbox
中放一個假的 Promise
從而禁掉 Promise 呢?答案是能提供一個「假」的 Promise
,但卻沒有辦法完成禁掉 Promise
,比如
const { VM } = require('vm2');
const vm = new VM({
timeout: 1000, sandbox: { Promise: function(){}}
});
vm.run('Promise = (async function(){})().constructor;new Promise(()=>{});');
複製程式碼
可以看到通過一行 Promise = (async function(){})().constructor
就可以輕鬆再次拿到 Promise
了。從另一個層面來看,況且或許有時我們還想讓自定義指令碼支援非同步處理呢。
如何建立一個更安全一些的沙箱?
通過上文的探究,我們並沒有找到一個完美的方案在 Node.js 建立安全的隔離的沙箱。其中 vm2 做了不少處理,相對來講算是較安全的方案了,但問題也很明顯,比如非同步不能檢查超時的問題、和宿主程式在相同程式的問題。
沒有程式隔離時,通過 VM 建立的 sanbox 大體是這樣的
那麼,我們是不是可以嘗試,將非受信程式碼,通過 vm2 這個模組隔離在一個獨立的程式中執行呢?然後,執行超時時,直接將隔離的程式幹掉,但這裡我們需要考慮如下幾個問題
通過程式池統一排程管理沙箱程式
如果來一個執行任務,建立一個程式,用完銷燬,僅處理程式的開銷就已經稍大了,並且也不能不設限的開新程式和宿主應用搶資源,那麼,需要建一個程式池,所有任務到來會建立一個 Script
例項,先進入一個 pending
佇列,然後直接將 script
例項的 defer
物件返回,呼叫處就能 await
執行結果了,然後由 sandbox master
根據工程程式的空閒程式來排程執行,master 會將 script
的執行資訊,包括重要的 ScriptId
,傳送給空閒的 worker,worker 執行完成後會將「結果 + script 資訊」回傳給 master,master 通過 ScriptId 識別是哪個指令碼執行完畢了,就是結果進行 resolve
或 reject 處理。
這樣,通過「程式池」即能降低「程式來回建立和銷燬的開銷」,也能確保不過度搶佔宿主資源,同時,在非同步操作超時,還能將工程程式直接殺掉,同時,master 將發現一個工程程式掛掉,會立即建立替補程式。
處理的資料和結果,還有公開給沙箱的方法
程式間如何通訊,需要「動態程式碼」處理資料可以直接序列化後通過 IPC 傳送給隔離 Sandbox 程式,執行結果一樣經過序列化通過 IPC 傳輸。
其中,如果想法公開一個方法給 sandbox,因為不在一個程式,並不能方便的將一個方案的引用傳遞給 sandbox。我們可以將宿主的方法,在傳遞給 sandbox worker 之類做一下處理,轉換為一個「描述物件」,包括了允許 sandbox 呼叫的方法資訊,然後將資訊,如同其它資料一樣傳送給 worker 程式,worker 收到資料後,識出來所「方法描述物件」,然後在 worker 程式中的 sandbox 物件上建立代理方法,代理方法同樣通過 IPC 和 master 通訊。
針對沙箱程式進行 CPU 和記憶體配額限制
在 Linux 平臺,通過 CGoups 對沙箱程式進行整體的 CPU 和記憶體等資源的配額限制,Cgroups 是 Control Groups 的縮寫,是 Linux 核心提供的一種可以限制、記錄、隔離程式組(Process Groups)所使用的物理資源(如:CPU、Memory,IO 等等)的機制。最初由 Google 的工程師提出,後來被整合進 Linux 核心。Cgroups 也是 LXC 為實現虛擬化所使用的資源管理手段,可以說沒有 CGroups 就沒有 LXC。
最終,我們建立了一個大約這樣的「沙箱環境
如此這般處理起來是不是感覺很麻煩?但我們就有了一個更加安全一些的沙箱環境了,這些處理。筆者已經基於 TypeScript 編寫,並封裝為一個獨立的模組 Safeify
。
相較於內建的 VM 及常見的幾個沙箱模組, Safeify 具有如下特點:
- 為將要執行的動態程式碼建立專門的程式池,與宿主應用程式分離在不同的程式中執行
- 支援配置沙箱程式池的最大程式數量
- 支援限定同步程式碼的最大執行時間,同時也支援限定包括非同步程式碼在內的執行時間
- 支援限定沙箱程式池的整體的 CPU 資源配額(小數)
- 支援限定沙箱程式池的整體的最大的記憶體限制(單位 m)
GitHub: https://github.com/Houfeng/safeify ,歡迎 Star & Issues
最後,簡單介紹一下 Safeify 如何使用,通過如下命令安裝
npm i safeify --save
複製程式碼
在應用中使用,還是比較簡單的,如下程式碼(TypeScript 中類似)
import { Safeify } from './Safeify';
const safeVm = new Safeify({
timeout: 50, //超時時間,預設 50ms
asyncTimeout: 500, //包含非同步操作的超時時間,預設 500ms
quantity: 4, //沙箱程式數量,預設同 CPU 核數
memoryQuota: 500, //沙箱最大能使用的記憶體(單位 m),預設 500m
cpuQuota: 0.5, //沙箱的 cpu 資源配額(百分比),預設 50%
});
const context = {
a: 1,
b: 2,
add(a, b) {
return a + b;
}
};
const rs = await safeVm.run(`return add(a,b)`, context);
console.log('result',rs);
複製程式碼
關於安全的問題,沒有最安全,只有更安全,Safeify 已在一個專案中使用,但自定義指令碼的功能是僅針對內網使用者,有不少動態執行程式碼的場景其實是可以避免的,繞不開或實在需要提供這個功能時,希望本文或 Safeify 能對大家有所幫助就行了。
-- end --