KOA 文件上传包解析特性

场景

在一次测试中,遇到了一个nodejs的命令注入漏洞,但是存在一些过滤。
web所使用的框架为koa, 使用koa-body处理文件上传请求,系统为Linux, 简化后的代码如下所示。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
const Koa = require('koa')
const app = new Koa()
const koaBody = require('koa-body');
const path = require('path');
const fs = require('fs');
const child_process = require('child_process');

function checkname(value){
const re = new RegExp(/([&;|$`'"/\\])|([.]{2})/);
return !re.test(value);
}

app.use(
koaBody({
multipart: true,
formidable: {
hash: 'md5',
maxFileSize: 10 * 1024 * 1024 * 1024,
},
onError(error, ctx) {

},
}));

app.use( async ( ctx ) => {
const file = ctx.request.files.file;
if(!checkname(file.name)){
return ctx.body="invalid filename";
}

const filePath = path.join("/tmp/web/public", file.name);
const rf = fs.readFileSync(file.path);
fs.writeFileSync(filePath, rf);

child_process.exec(`psql -U tmp -h 127.0.0.1 -p 25432 -d test -f ${file.name}`);

ctx.body = `psql -U tmp -h 127.0.0.1 -p 25432 -d test -f ${file.name}`;
})
app.listen(3000)

功能为用户上传sql文件,后端将sql文件保存在本地中,然后psql命令导入该sql文件。导入的sql路径使用绝对路径,目录不可控但是文件名并没有做随机化,使用的是用户上传的文件名加了一层checkname过滤。

0x01 解决换行

因为过滤掉了 & | ; 等字符,所以首先看看能不能通过参数注入实现RCE。看了下psql的help, 发现在psql command中可以通过\! command 实现命令执行.

不过在实际的koa环境中,上传文件时文件名会截取最后一个”\“后面的字符。

1
2
const file = ctx.request.files.file;
return ctx.body="filename:" + file.name;

因为用不了”\“字符,暂时放弃psql command RCE这条路子,同时因为是低权限账户,也不能通过执行任意SQL语句实现RCE。

虽然过滤了&|;等字符,但是实际child_process.exec 也是调用的/bin/sh -c, 众所周知sh -c 可以通过换行实现注入新命令, 同时checkname方法中没有过滤换行。
观察功能代码可知,需要在文件保存成功后,才会执行命令,所以上传的文件名需要合法。 在linux下,对文件名的限制不像windows那么严格,除了”/“之外,所有的字符都合法,所以换行符也可以设置为文件名。

综上所示,只要能上传一个带有换行的文件名就可以成功RCE。

不过经过测试,给上传的文件名中敲了个enter发现会返回500.
报错 TypeError: Cannot read property 'file' of undefined

KOA-BODY实际解析multipart时,也是用的formidable库,

1
2
3
4
5
6
7
case S.HEADER_VALUE:
if (c == CR) {
dataCallback('headerValue', true);
callback('headerEnd');
state = S.HEADER_VALUE_ALMOST_DONE;
}
break;

formidable库在读取headerValue时,读到\r后就结束,直接敲回车为\r\n。所以\nb”成为了新的一行,再次解析因为不合法导致报错,所以不能直敲回车。修改\r\n为\n后提交,依旧返回500.
报错 TypeError: Cannot read property 'name' of undefined,错误与之前的不相同了。
查看formiable解析filename的方法,

1
2
3
4
5
6
7
8
9
10
11
headerField = headerField.toLowerCase();
part.headers[headerField] = headerValue;

// matches either a quoted-string or a token (RFC 2616 section 19.5.1)
var m = headerValue.match(/\bname=("([^"]*)"|([^\(\)<>@,;:\\"\/\[\]\?=\{\}\s\t/]+))/i);
if (headerField == 'content-disposition') {
if (m) {
part.name = m[2] || m[3] || '';
}

part.filename = self._fileName(headerValue);

调用_fileName方法获取filename

1
2
3
4
5
6
7
8
9
10
11
12
13
IncomingForm.prototype._fileName = function(headerValue) {
// matches either a quoted-string or a token (RFC 2616 section 19.5.1)
var m = headerValue.match(/\bfilename=("(.*?)"|([^\(\)<>@,;:\\"\/\[\]\?=\{\}\s\t/]+))($|;\s)/i);
if (!m) return;

var match = m[2] || m[3] || '';
var filename = match.substr(match.lastIndexOf('\\') + 1);
filename = filename.replace(/%22/g, '"');
filename = filename.replace(/&#([\d]{4});/g, function(m, code) {
return String.fromCharCode(code);
});
return filename;
};

在该方法中,通过正则来获取filename, 虽然正则中使用了”(.*?)”但是因为没有开启模式/s模式修正符导致.不能匹配\n,所以获取到的filename为null,最终导致了异常。
观察_fileName方法可以发现,在该方法中截取了”\“后的字符当作文件名,同时会将html实体给还原回来,所以通过在文件名中注入&#0010;就可以注入换行。
可以注入换行后,就相当于可以执行任意命令了,只需要绕过checkname的过滤就行,绕过这个比较简单。

1
2
3
4
function checkname(value){
const re = new RegExp(/([&;|$`'"/\\])|([.]{2})/);
return !re.test(value);
}

0x02 解决\

在_fileName方法中,因为var filename = match.substr(match.lastIndexOf('\\') + 1);的存在,导致文件名中不能含有”\“,如果可以含有”\“那么还可以尝试使用psql command实现rce。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
if (opts.json && ctx.is(jsonTypes)) {
bodyPromise = buddy.json(ctx, {
encoding: opts.encoding,
limit: opts.jsonLimit,
strict: opts.jsonStrict,
returnRawBody: opts.includeUnparsed
});
} else if (opts.urlencoded && ctx.is('urlencoded')) {
bodyPromise = buddy.form(ctx, {
encoding: opts.encoding,
limit: opts.formLimit,
queryString: opts.queryString,
returnRawBody: opts.includeUnparsed
});
} else if (opts.text && ctx.is('text/*')) {
bodyPromise = buddy.text(ctx, {
encoding: opts.encoding,
limit: opts.textLimit,
returnRawBody: opts.includeUnparsed
});
} else if (opts.multipart && ctx.is('multipart')) {
bodyPromise = formy(ctx, opts.formidable);
}

koa-body在处理request时,对于不同的请求类型使用了不同的方法来解析,当请求类型是multipart时,调用了formy来解析。

1
2
3
4
5
6
7
8
9
10
11
12
function normalize (type) {
if (typeof type !== 'string') {
// invalid type
return false
}

switch (type) {
case 'urlencoded':
return 'application/x-www-form-urlencoded'
case 'multipart':
return 'multipart/*'
}

koa-body验证请求类型是否为multipart时,只验证了大类是否为multipart,不验证subtype, 所以multipart/xxxx 都会使用formy方法进行处理。

1
2
3
4
5
6
7
8
9
function formy(ctx, opts) {
return new Promise(function (resolve, reject) {
var fields = {};
var files = {};
var form = new forms.IncomingForm(opts);
..............
form.parse(ctx.req);
});
}

在formy方法中,又调用了formidable库的parse方法解析request body。
formidable库中也会调用_parseContentType方法根据用户的content-type使用不同的方法解析body,

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
IncomingForm.prototype._parseContentType = function() {
if (this.bytesExpected === 0) {
this._parser = dummyParser(this);
return;
}

if (!this.headers['content-type']) {
this._error(new Error('bad content-type header, no content-type'));
return;
}

if (this.headers['content-type'].match(/octet-stream/i)) {
this._initOctetStream();
return;
}

if (this.headers['content-type'].match(/urlencoded/i)) {
this._initUrlencoded();
return;
}

if (this.headers['content-type'].match(/multipart/i)) {
var m = this.headers['content-type'].match(/boundary=(?:"([^"]+)"|([^;]+))/i);
if (m) {
this._initMultipart(m[1] || m[2]);
} else {
this._error(new Error('bad content-type header, no multipart boundary'));
}
return;
}

if (this.headers['content-type'].match(/json/i)) {
this._initJSONencoded();
return;
}

但是formidable与koa-body的类型判断存在一定的差异性,formidable的判断比较简陋,只要内容存在multipart等就会使用对应的方法进行处理,koa-body本身未对octet-stream进行特殊处理,而formidable有对octet-stream处理。
当content-type设置为 multipart/octet-stream, 因为octet-stream分支在最前,所以不会使用multipart处理而是使用octet-stream进行处理。

1
2
3
4
5
6
7
8
9
10
IncomingForm.prototype._initOctetStream = function() {
this.type = 'octet-stream';
var filename = this.headers['x-file-name'];
var mime = this.headers['content-type'];

var file = new File({
path: this._uploadPath(filename),
name: filename,
type: mime
});

当请求类型为octet-stream时,文件名是从请求头中读取,并且未进行任何处理,所以这时候可以使用”\“字符。
这里假想一个环境,在真实环境的过滤中去除单引号以及”", 新增\n。

1
2
3
4
function checkname(value){
const re = new RegExp(/([&;|$`"/\n])|([.]{2})/);
return !re.test(value);
}

通过octet-stream方法,实现利用。