您的位置:首頁>正文

為 Node.js 應用建立一個更安全的沙箱環境

有哪些動態執行腳本的場景?

在一些應用中, 我們希望給使用者提供插入自訂邏輯的能力, 比如 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便能更安全一些

我們知道無論 eval還是 function,

執行時都會把作用域一層一層向上查找, 如果找不到會一直到 global, 那麼利用 Proxy的原理就是, 讓執行了代碼在 sandobx中找的到, 以達到「防逃逸」的目的。

在流覽器中, 還可以利用 iframe, 創建一個再發安全的一些隔離環境, 本文也著眼於 Node.js, 在這裡不做過多討論。

在 Node.js 中呢, 有沒有其它選擇?

或許沒看到這兒之前你就已經想到了 VM, 它是 Node.js 預設就提供的一個內建模組, VM模組提供了一系列 API 用於在 V8 虛擬機器環境中編譯和運行代碼。 JavaScript 代碼可以被編譯並立即運行, 或編譯、保存然後再運行。

執行上這的代碼就能拿到結果 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看起來似乎隔離了代碼執行環境, 但實際上卻很容易「逃逸」出去。

執行上邊的代碼, 宿主程序立即就會「退出」, sandbox是在 VM之外的環境創建的, 需 VM中的代碼的 this
指向的也是 sandbox, 那麼

沒有人願意用戶一段腳本就能讓應用掛掉吧。 除了退出進程式之外, 實際上還能幹更多的事情。

有個簡單的方法就能避免通過 this.constructor拿到 process,如下:

但還是有風險的,由於 JavaScript 本身的動態的特點,各種黑魔法防不勝防。事實 Node.js 的官方文檔中也提到 VM當做一個安全的沙箱去執行任意非信任的代碼。

有哪些做了進一步工作的社區模組?

在社區中有一些開源的模組用於運行不信任代碼,例如 sandbox、vm2、jailed等。相比較而言 vm2對各方面做了更多的安全工作,相對安全些。

從 vm2的官方 READM中可以看到,它基於 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 通訊。

最終,我們建立了一個大約這樣的「沙箱環境」

如此這般處理起來是不是感覺很麻煩?但我們就有了一個更加安全一些的沙箱環境了,這些處理。筆者已經基於 TypeScript 編寫,並封裝為一個獨立的模組 Safeify。

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 核數});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 能對大家有所幫助就行了。

作者:houfeng

實際上還能幹更多的事情。

有個簡單的方法就能避免通過 this.constructor拿到 process,如下:

但還是有風險的,由於 JavaScript 本身的動態的特點,各種黑魔法防不勝防。事實 Node.js 的官方文檔中也提到 VM當做一個安全的沙箱去執行任意非信任的代碼。

有哪些做了進一步工作的社區模組?

在社區中有一些開源的模組用於運行不信任代碼,例如 sandbox、vm2、jailed等。相比較而言 vm2對各方面做了更多的安全工作,相對安全些。

從 vm2的官方 READM中可以看到,它基於 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 通訊。

最終,我們建立了一個大約這樣的「沙箱環境」

如此這般處理起來是不是感覺很麻煩?但我們就有了一個更加安全一些的沙箱環境了,這些處理。筆者已經基於 TypeScript 編寫,並封裝為一個獨立的模組 Safeify。

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 核數});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 能對大家有所幫助就行了。

作者:houfeng

同類文章
Next Article
喜欢就按个赞吧!!!
点击关闭提示