構建一個安全的 JavaScript 沙箱

ligengpu 8年前發布 | 9K 次閱讀 JavaScript開發 JavaScript ECMAScript

在 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

 本文由用戶 ligengpu 自行上傳分享,僅供網友學習交流。所有權歸原作者,若您的權利被侵害,請聯系管理員。
 轉載本站原創文章,請注明出處,并保留原始鏈接、圖片水印。
 本站是一個以用戶分享為主的開源技術平臺,歡迎各類分享!