动态执行脚本的场景有哪些?
在一些应用中,我们希望为用户提供插入自定义逻辑的能力,比如微软Office中的VBA,一些游戏中的lua脚本,FireFox中的“油猴脚本”,可以让用户在可控的范围和权限内,想象和做一些有趣有用的事情,从而扩展自己的能力,满足个人需求。
大部分都是客户端程序,一些在线系统和产品往往也有类似的要求。事实上,许多在线应用程序还提供了自定义脚本的能力,例如谷歌文档中的Apps Script,它允许您使用JavaScript来做一些非常有用的事情,例如运行代码来响应文档打开事件或单元格更改事件,以及为公式制作自定义电子表格函数。
与“用户电脑”中运行的客户端应用不同,用户的定制脚本通常只能影响用户本人,但对于在线应用或服务来说,有些情况变得更加重要,比如“安全”,用户的定制脚本必须严格限制和隔离,即不能影响主机程序或其他用户。
Safeify是Nodejs应用程序的一个模块,用于安全地执行用户定义的不可信脚本。
如何安全执行动态脚本?
让我们首先看看一段代码如何在JavaScript程序中动态执行。例如,著名的评估
评估(' 1 2 ')
以上代码已经成功执行,没有出现任何问题。eval是全局对象的一个函数属性,执行的代码与进程中的其他普通代码具有相同的权限。它可以访问“执行上下文”中的局部变量和所有“全局变量”,这在这个场景中是一个非常危险的函数。
让我们看看Functon。通过函数构造器,我们可以动态地创建一个函数,然后执行它
const sum=新函数(' m ',' n ','返回m ^ n ');console.log(sum(1,2));它也运行平稳。由函数构造函数生成的函数不会在其创建的上下文中创建闭包,而是通常在全局范围内创建。运行函数时,只能访问自己的局部变量和全局变量,而不能访问调用函数构造函数生成的上下文的范围。就像一个站在地上,一个站在薄薄的一张纸上,这一幕几乎没有竞争。
结合ES6的新功能,代理可以更安全
函数evalute(代码,沙盒){沙盒=沙盒| | object . create(null);const fn=new Function('sandbox ',` with(sandbox){ return($[code]))} `);Const proxy=newproxy(沙箱,{has (target,key){//让动态执行的代码认为属性已经存在返回true} });返回fn(代理);}评估(' 1 2')//3评估('控制台。log(1)')//无法读取未定义的属性“log”。我们知道eval和function在执行时都会逐层查找范围。如果找不到,就会一路走到全局,所以使用Proxy的原则就是让执行的代码在sandobx中找到。
在浏览器中,iframe还可以用来创建一些可重新安全的隔离环境。本文也关注Node.js,这里就不多讨论了。在Node.js中,还有其他选择吗?
也许你在看到虚拟机之前就想到了它。默认情况下,它是由Node.js提供的内置模块。VM模块提供了一系列API,用于在V8虚拟机环境中编译和运行代码。JavaScript代码可以立即编译运行,也可以编译保存后再运行。
const VM=require(' VM ');const脚本=新虚拟机。脚本(' m n ');const sandbox={ m: 1,n : 2 };const context=new VM . create context(沙盒);script.runInContext(上下文);通过执行上面的代码,可以得到结果3。同时,您可以指定通过vm执行代码时的“最大毫秒数”。脚本如果超过指定的时间,将终止执行并引发异常
尝试{ const script=new vm。脚本(' while(true){} ',{ time out : 50 });} catch(err){//print log console . log(err . message);}上述脚本的执行会失败,会检测到超时并抛出异常,然后由Try Cache捕获并记录,但同时需要注意的是,vm的超时选项。脚本“仅对同步生成有效”,不包括异步调用的时间,例如
const脚本=新虚拟机。脚本(' setTimeout(()={},2000)',{ timeout : 50 });上面的代码在50ms后没有抛出异常,因为50ms以上的代码同步执行肯定是完成了,setTimeout所花费的时间不算,也就是说vm模块不能直接限制异步代码的执行时间。我们不能通过一个额外的计时器来检查超时,因为在检查正在运行的虚拟机后,没有办法中止。
另外,看起来代码执行环境是通过vm.runInContext隔离在Node.js的,但实际上很容易“逃逸”。
const VM=require(' VM ');const sandbox={ };const脚本=新虚拟机。脚本(' this.constructor.constructor('返回进程')))。exit()');const context=vm.createContext(沙盒);script.runInContext(上下文);当执行上述代码时,主机程序将立即“退出”。沙箱是在虚拟机之外的环境中创建的,那么虚拟机中的这段代码也指向沙箱
//this.constructor是对象构造函数constobjconstructor=this。建造师;//ObjConstructor的构造函数是外包函数const function=ObjconStructor . constructor;//创建一个函数,执行它,返回全局进程全局对象const process=(新函数(' return process '))();//退出当前进程process . exit();没有人希望用户用脚本挂起应用程序。除了退出程序,你还可以做更多的事情。
有一种简单的方法可以避免通过这个. constructor获取进程,如下所示:
const VM=require(' VM ');//创建一个没有proto的空白对象作为sandbox const sandbox=object . create(null);const脚本=新虚拟机。脚本('.');const context=vm.createContext(沙盒);script.runInContext(上下文);然而,也有风险。由于JavaScript本身的动态特性,各种黑魔法都无法防范。事实上,Node.js的官方文档也提到,VM充当了一个安全的沙箱,可以执行任何不可信的代码。
哪些社区模块做了进一步的工作?
在社区中,有一些运行不可信代码的开源模块,比如沙盒、vm2、锒铛等等。相比之下,vm2在各方面做了更多的安全工作,相对安全。
从vm2的官方READM可以看出,它是基于Node.js的内置vm模块构建基本的沙盒环境,然后使用上面介绍的ES6的Proxy技术来防止沙盒脚本逃逸。
使用相同的测试代码尝试vm2
const { VM }=require(' vm2 ');新虚拟机()。运行(' this . constructor . constructor(' return process ')))。exit()');根据上面的代码,主机程序没有成功终止。根据vm2官方的REAME,“vm2是一个沙盒,不可信的代码完全可以在Node.js中执行”。
然而,事实上,我们仍然可以做一些“坏事”,比如:
const { VM }=require(' vm2 ');const vm=新VM({ timeout: 1000,沙盒: } });VM . run(' new Promise(()={ })');上面的代码永远不会完成执行。就像Node.js的内置模块一样,vm2的超时对异步操作无效。同时,vm2不能通过额外的计时器检查超时,因为它不能终止正在运行的vm。这会稍微消耗服务器的资源,并使您的应用程序挂起。
然后也许你会想,我们能不能在上面的沙盒里放一个假Promise,禁止它?答案是提供一个“假”承诺,但是没有办法完成对承诺的禁止,比如
const { VM }=require(' vm2 ');const vm=新VM({ timeout: 1000,shadow : { Promise : function(){ } } });VM . run(' Promise=(async function(){ }))()。建造师;新承诺(()={ });”);可以看到,只要一行promise=(asyncfunction () {})(),就可以很容易地再次得到Promise。构造函数。另一方面,也许有时我们希望我们的定制脚本支持异步处理。
如何建立更安全的沙盒?
通过以上的研究,我们还没有找到一个完美的解决方案,在Node.js中构建一个安全隔离的沙盒,其中,vm2做了大量的处理,相对安全,但是问题也很明显,比如异步无法检查超时的问题,主机程序在同一个进程中的问题。
没有流程隔离,通过VM创建的sanbox一般是这样的
那么,我们能否尝试隔离不可信的代码,并通过vm2在一个独立的进程中执行它呢?然后,当执行超时时,隔离的进程将被直接杀死,但是这里我们需要考虑以下问题
通过进程池调度管理沙盒进程
如果你来执行一个任务,创建一个进程,用完就销毁,处理这个进程的成本稍微高一点,不能无限制地打开一个新的进程和宿主应用程序去抓取资源,那么你需要建立一个进程池,当所有任务都到达后,你会创建一个script实例,先进入一个挂起队列,然后直接返回Script实例的defender对象。调用的地方可以注意到执行结果,然后沙盒主控根据工程进程的空闲程序调度执行。主节点会将脚本执行信息,包括重要的ScriptId发送给空闲的工作节点,工作节点在执行完成后会将“结果脚本信息”返回给主节点。Master通过ScriptId标识执行哪个脚本,即结果是解析还是拒绝。
这样,“进程池”既能减少“来回创建和销毁进程的开销”,又能保证主机资源不被过度抢占。同时,当异步操作超时时,可以直接杀死工程过程。同时,师傅会找一个工程流程挂掉,马上创建一个替代流程。
处理的数据和结果,以及向沙箱公开的方法
至于进程之间如何通信,需要“动态代码”处理的数据可以直接序列化,通过IPC发送到隔离Sandbox进程,执行结果通过IPC序列化传输。
其中,如果要把一个方法公开给沙盒,因为它不在一个进程中,所以不方便把一个方案的引用传递给沙盒。我们可以将宿主方法转换成一个“描述对象”,包括沙盒允许调用的方法信息,然后像其他数据一样将信息发送给工作进程。工作器收到数据后,识别“方法描述对象”,然后在工作器进程中对沙盒对象建立代理方法。代理方法还通过IPC与主机通信。
最后,我们建立了一个“沙盒环境”
这样处理感觉麻烦吗?但是我们有一个更安全的沙盒环境,这些治疗方法。我基于TypeScript编写了它,并将其封装为一个独立的模块Safeify。
github : https://github.com/Houfeng/safeify,欢迎来到《明星问题》
最后,简要介绍如何使用Safeify,并通过以下命令进行安装
在应用程序中使用NPM I safify-save相对简单,如以下代码所示(类似于TypeScript)
从“”导入{ Safeify }。/Safeify ';const safe VM=newsafify({ time out : 50,//超时,默认50ms异步timeout3360 500,//包括异步操作超时,默认500ms Quantity 3360 4//沙盒进程号,默认与CPU核心号相同});const context={ a: 1,b: 2,add(a,b){ return a b;}};const RS=wait Safevm . run(` return add(a,b)`,context);console.log('result ',RS);关于安全,没有最安全,只有更安全。Safeify已经在一个项目中使用,但是自定义脚本的功能只针对内网用户,可以避免很多动态执行代码的场景。如果这个功能无法避免或者需要提供,希望这篇文章或者Safeify能对大家有所帮助。