OWASP & AppSec

Injection de code serveur Node.js : eval, new Function, vm

Injection de code côté serveur en Node.js : pourquoi eval et new Function exécutent l'entrée, et pourquoi le module vm n'est pas un bac à sable. Mécanisme et défense.

Naïm Aouaichia
6 min de lecture
  • Node.js
  • Injection de code
  • RCE
  • eval
  • Sandbox
  • OWASP

Après la faille de runtime de Node2Shell et la chaîne détournée de la prototype pollution, voici la voie la plus directe vers l'exécution de code : la lui donner. L'injection de code côté serveur, c'est le moment où une entrée utilisateur cesse d'être une donnée et devient une instruction. En Node.js, le piège a une variante particulièrement répandue : croire que le module vm protège, alors qu'il n'a jamais été conçu pour cela. Cet article décrit le mécanisme avec le code, démonte le faux bac à sable, et montre comment isoler vraiment quand c'est inévitable.

Quand une donnée devient du code

Certaines primitives de JavaScript prennent une chaîne de caractères et l'exécutent comme du code. eval est la plus connue, mais new Function, le Function constructor, et setTimeout/setInterval appelés avec une chaîne font la même chose :

// Toutes ces formes exécutent la chaîne reçue
eval(entreeUtilisateur);
new Function(entreeUtilisateur)();
Function(entreeUtilisateur)();
setTimeout(entreeUtilisateur, 0);   // la chaîne est évaluée comme du code
setInterval(entreeUtilisateur, 1000);

Dès qu'une de ces primitives reçoit une entrée que l'attaquant contrôle, même partiellement, le serveur exécute le code de l'attaquant dans son propre processus, avec les droits de ce processus. C'est la classe CWE-94. Il n'y a pas de détournement subtil comme en injection SQL : l'exécution est immédiate et totale.

// Cas réel typique : un "moteur de règles" configurable
app.post("/calcul", (req, res) => {
  const formule = req.body.formule;        // "prix * quantite"
  const resultat = eval(formule);          // ... ou "require('child_process').execSync('id')"
  res.json({ resultat });
});

Le cas se cache souvent derrière une bonne intention : un champ « formule » dans un tableur applicatif, un moteur de règles « configurable », une fonctionnalité qui laisse l'utilisateur écrire une petite expression. Chaque fois que cette expression est confiée au moteur JavaScript complet, la porte est ouverte.

Le faux bac à sable : le module vm

Devant ce risque, le réflexe est de chercher un bac à sable, et le module vm de Node.js en a l'apparence : il exécute du code dans un contexte séparé. Beaucoup en concluent qu'il isole. C'est faux, et la documentation de Node.js le dit sans détour : le module vm n'est pas un mécanisme de sécurité et ne doit pas servir à exécuter du code non fiable.

La raison est structurelle. Le code lancé dans un contexte vm tourne dans le même processus, et il peut en sortir. Le chemin d'évasion classique remonte la chaîne de prototypes d'un objet partagé jusqu'à son constructor, puis au Function constructor, pour reconstruire un accès au contexte global de l'hôte :

const vm = require("node:vm");
 
// Ce qu'un développeur croit être un bac à sable :
const bacASable = { entree: userData };
vm.createContext(bacASable);
 
// Charge typique d'évasion exécutée DANS le contexte vm :
const evasion = `
  this.constructor.constructor("return process")()
      .mainModule.require("child_process")
      .execSync("id").toString()
`;
vm.runInContext(evasion, bacASable); // atteint process, puis le système

this.constructor donne le constructeur d'un objet du contexte ; .constructor à nouveau donne le Function constructor ; l'appeler reconstruit une fonction qui s'exécute dans la portée de l'hôte, d'où l'accès à process et à require. Les bibliothèques qui promettaient un vrai isolement, vm2 en tête, ont connu des évasions à répétition, jusqu'à ce que leur maintenance cesse faute de pouvoir fermer la classe entière. La leçon est nette : il n'existe pas de bac à sable fiable en pur JavaScript dans le même processus.

Isoler vraiment, quand c'est inévitable

La bonne réponse est presque toujours : ne pas exécuter de code utilisateur du tout. Quand une fonctionnalité semble l'exiger, on remet d'abord en cause le besoin, car il cache souvent une mauvaise conception. Souvent, un interpréteur à grammaire restreinte suffit et supprime le risque :

// Pour évaluer une expression arithmétique utilisateur, PAS le moteur JS complet
import { evaluate } from "mathjs"; // grammaire limitée, pas d'accès à require/process
const resultat = evaluate("prix * quantite", { prix: 10, quantite: 3 });

Quand l'exécution de vrai code est réellement nécessaire, l'isolement doit être au niveau du système, pas du langage :

  • un processus séparé sans privilèges, avec des limites mémoire et CPU imposées par l'OS ;
  • un conteneur jetable, sans accès réseau ni montage sensible ;
  • un isolat dédié (isolated-vm, ou les isolats des runtimes edge) qui crée une vraie frontière V8.
// Si isolation en processus : isolated-vm, avec limites explicites
import ivm from "isolated-vm";
const isolate = new ivm.Isolate({ memoryLimit: 16 }); // Mo
const context = await isolate.createContext();
// Exécuter avec un timeout, sans jamais exposer de référence à l'hôte
await context.eval(codeUtilisateur, { timeout: 100 });

L'isolement vaut par la frontière, jamais par une barrière interne au même processus qui partage le même tas.

Se protéger, en synthèse

  • Ne jamais passer une entrée non fiable à eval, new Function, au Function constructor, ni à setTimeout/setInterval sous forme de chaîne.
  • Ne pas considérer le module vm comme une barrière de sécurité : ce n'en est pas une, de l'aveu même de Node.js.
  • Remettre en cause le besoin d'exécuter du code utilisateur avant de chercher à le sécuriser.
  • Pour évaluer une expression légitime, utiliser un interpréteur à grammaire restreinte, jamais le moteur JavaScript complet.
  • Quand l'exécution est inévitable, isoler au niveau du système (processus sans privilèges, conteneur jetable, isolat dédié), avec limites mémoire, CPU et réseau.
  • Valider les entrées en amont reste la première ligne : voir les bonnes pratiques de validation d'entrées.

La règle tient en une phrase : aucune donnée issue d'une requête ne doit devenir du code. C'est le principe le plus simple de cette série sur les voies vers la RCE en Node.js, et le plus souvent enfreint par confort.

S'entraîner sur un lab réel

L'injection de code se saisit mieux en voyant une évasion de vm fonctionner sous ses propres yeux. Le parcours PHP → shell de Zeroday Cyber Academy applique cette pédagogie côté PHP : exploiter pour comprendre, puis défendre, sur des cibles réelles avec remédiation avant/après.

Sources

Questions fréquentes

  • Qu'est-ce que l'injection de code côté serveur en Node.js ?
    C'est l'exécution, par le serveur, de code JavaScript fabriqué par l'attaquant. Elle survient quand une entrée non fiable atteint une primitive qui compile ou évalue du code : eval, new Function, setTimeout avec une chaîne, ou le module vm mal employé. Contrairement à une injection SQL qui détourne une requête, l'injection de code donne directement l'exécution dans le processus Node, avec tous ses droits. C'est la classe CWE-94, et c'est l'une des voies les plus directes vers une RCE complète.
  • Le module vm de Node.js est-il un bac à sable de sécurité ?
    Non, et c'est le malentendu le plus coûteux. La documentation de Node.js est explicite : le module vm n'est pas un mécanisme de sécurité et ne doit pas servir à exécuter du code non fiable. Le code lancé dans un contexte vm peut s'en échapper et atteindre le processus hôte, notamment en remontant jusqu'au constructor d'un objet partagé. Les bibliothèques tierces qui promettaient un vrai isolement, comme vm2, ont connu des évasions répétées jusqu'à l'arrêt de leur maintenance. Il n'existe pas de bac à sable fiable en pur JavaScript dans le même processus.
  • Comment exécuter du code non fiable sans risque, alors ?
    La bonne réponse est presque toujours : ne pas le faire. Si une fonctionnalité semble exiger l'exécution de code utilisateur, on remet d'abord en cause le besoin, car il cache souvent une mauvaise conception. Quand c'est réellement nécessaire, l'isolement doit être au niveau du système, pas du langage : un processus séparé sans privilèges, un conteneur jetable, un isolat dédié comme ceux des runtimes edge, avec limites mémoire, CPU et réseau. L'isolement vaut par la frontière de l'OS, jamais par une barrière interne au même processus.
  • Quelles primitives Node.js faut-il éviter avec une entrée non fiable ?
    eval, new Function et le Function constructor, setTimeout, setInterval et setImmediate appelés avec une chaîne, le module vm employé comme barrière de sécurité, et toute bibliothèque de template ou d'expression qui compile une chaîne fournie par l'utilisateur. La règle est simple : aucune donnée issue d'une requête ne doit jamais devenir du code. Si vous avez besoin d'évaluer une expression utilisateur contrôlée, utilisez un interpréteur restreint conçu pour cela, avec une grammaire limitée, pas le moteur JavaScript complet.
  • isolated-vm est-il une alternative sûre au module vm ?
    isolated-vm est sérieusement plus robuste que le module vm natif, parce qu'il crée un véritable isolat V8 séparé, avec sa propre mémoire, et non un simple contexte dans le même tas. Il est conçu, lui, pour exécuter du code peu fiable, là où la documentation de Node.js dit explicitement que vm ne l'est pas. Mais même un isolat n'est pas une garantie absolue : il faut limiter la mémoire et le temps CPU, ne jamais exposer de références à des objets de l'hôte, et se tenir à jour des évasions V8. La règle reste qu'un isolement fiable passe par une frontière forte ; isolated-vm en fournit une bien meilleure que vm, pas une infaillible.
  • Comment repérer une injection de code dans une revue ?
    Cherchez les primitives qui transforment une chaîne en code : eval, new Function, le Function constructor, setTimeout, setInterval et setImmediate appelés avec une chaîne plutôt qu'une fonction, et le module vm. Pour chacune, remontez la provenance de l'argument : s'il contient, même partiellement, une donnée issue d'une requête, d'un paramètre, d'un en-tête ou d'une réponse tierce, c'est une injection de code en puissance. Les règles Semgrep et les détecteurs CodeQL couvrent ces motifs ; un grep ciblé sur ces mots-clés, suivi d'une lecture de la provenance, est un premier filet efficace en revue.

Trouver ces failles dans votre code, puis les refermer.

Nous reprenons les catégories OWASP sur des applications qui tournent : on exploite, on mesure ce que ça donne, puis on corrige et on vérifie que le correctif tient.

Écrit par

Naïm Aouaichia

Fondateur et directeur pédagogique de Zeroday Cyber Academy

Ingénieur cybersécurité avec un parcours hybride : développement, DevOps en ESN française, DevSecOps IN Groupe (sécurité des documents d'identité régaliens), audits CAC 40. Fondateur de Hash24Security et Zeroday Cyber Academy.