OWASP & AppSec

Prototype pollution vers RCE : la chaîne lodash et EJS

Comment une prototype pollution en Node.js devient une RCE : la chaîne lodash.merge plus le gadget EJS outputFunctionName. Mécanisme et défense.

Naïm Aouaichia
6 min de lecture
  • Node.js
  • Prototype pollution
  • RCE
  • lodash
  • EJS
  • OWASP

Toutes les RCE ne viennent pas d'une faille mémoire du runtime comme Node2Shell. Certaines naissent d'une propriété du langage lui-même, détournée pas à pas. La prototype pollution est la plus élégante de cette famille en JavaScript : elle ne casse rien frontalement, elle modifie discrètement l'objet dont tous les autres héritent, puis laisse un composant innocent faire le reste. Cet article décrit la chaîne la plus documentée, de la fusion d'objets imprudente jusqu'à l'exécution via un moteur de template, avec le code à chaque étape, et montre où la couper.

Le modèle d'héritage de JavaScript, et sa faille

En JavaScript, presque tout objet hérite de Object.prototype. Lire une propriété absente d'un objet remonte la chaîne de prototypes jusqu'à la trouver. C'est pratique, et c'est le problème : si un attaquant parvient à écrire sur Object.prototype, il ajoute une propriété que tous les objets du programme vont soudain sembler posséder.

const a = {};
const b = { role: "user" };
 
// Démonstration de l'effet, à ne jamais laisser arriver en vrai :
Object.prototype.polluee = "présente partout";
console.log(a.polluee); // "présente partout"  ← a n'a rien défini
console.log(b.polluee); // "présente partout"  ← b non plus
console.log("polluee" in a); // true

L'écriture se fait par les clés spéciales __proto__, constructor et prototype. Une fonction qui recopie récursivement un objet fourni par l'utilisateur, sans écarter ces clés, offre le chemin. C'est la classe CWE-1321.

La fusion imprudente : le point d'entrée

Le point d'entrée classique est une fusion profonde d'objets. Historiquement, lodash.merge, mergeWith et defaultsDeep recopiaient les clés contrôlées sans filtrer __proto__. Le même défaut existe dans toute fusion « maison » écrite sans y penser :

// Fusion naïve : recopie __proto__ comme n'importe quelle clé
function merge(cible, source) {
  for (const cle in source) {
    if (source[cle] && typeof source[cle] === "object") {
      cible[cle] = merge(cible[cle] || {}, source[cle]);
    } else {
      cible[cle] = source[cle];
    }
  }
  return cible;
}
 
// Une entrée qui vient d'une requête JSON :
const entree = JSON.parse('{"__proto__": {"isAdmin": true}}');
merge({}, entree);
 
// Conséquence immédiate, partout dans le programme :
console.log(({}).isAdmin); // true

À partir de là, chaque objet du programme répond true à isAdmin. Un contrôle d'accès qui fait confiance à une propriété héritée vient de basculer. La pollution seule, déjà, altère la logique de l'application : droits, options de configuration, drapeaux de fonctionnalité.

Le gadget : d'une propriété héritée à l'exécution

La pollution seule ne donne pas l'exécution. Il lui faut un gadget : un endroit du code qui lit une propriété héritée en lui faisant confiance. Le gadget le plus connu est le moteur de template EJS et sa propriété outputFunctionName.

EJS construit, pour rendre un template, du code JavaScript qu'il compile ensuite avec new Function. La valeur de outputFunctionName est insérée dans ce code sans échappement, parce qu'EJS la suppose définie par le développeur, jamais par l'utilisateur. Si la prototype pollution a placé une charge dans cette propriété, EJS la lit comme si elle venait de la configuration, l'injecte dans le code compilé, et l'exécute.

// Schéma de la chaîne (conceptuel) :
// 1. L'attaquant pollue outputFunctionName via une fusion non protégée
JSON.parse('{"__proto__": {"outputFunctionName": "x;CHARGE//"}}');
// 2. EJS lit cette propriété héritée en construisant sa fonction de rendu
//    et insère "x;CHARGE//" dans le code qu'il passe à new Function(...)
// 3. new Function compile et exécute : la charge tourne côté serveur

La pollution fournit la donnée ; EJS fournit le moteur d'exécution. C'est le schéma général d'une chaîne de gadgets : la pollution ne crée pas la vulnérabilité d'exécution, elle active un comportement qui existait déjà et qui faisait confiance à sa configuration. D'autres gadgets existent dans l'écosystème (options de child_process, chemins de modules, configurations de sérialiseurs) ; EJS est le plus documenté parce qu'il mène directement à new Function.

Corriger : casser la chaîne à chaque maillon

La chaîne compte trois maillons, et chacun se coupe indépendamment. On les combine, parce qu'aucun pris seul ne garantit la sécurité.

1. Valider l'entrée contre un schéma strict. Une clé __proto__ ou constructor dans un payload légitime n'existe pas.

import { z } from "zod";
 
// Schéma fermé : toute clé non déclarée est rejetée
const Entree = z.object({
  nom: z.string(),
  options: z.object({ theme: z.enum(["clair", "sombre"]) }).strict(),
}).strict();
 
const data = Entree.parse(req.body); // jette si __proto__ ou clé inconnue

2. Priver les objets de données de prototype. Pas de prototype, rien à polluer.

// Un dictionnaire sans prototype
const config = Object.create(null);
config.theme = entree.theme; // config.__proto__ n'existe pas comme piège
 
// Ou, mieux encore pour un dictionnaire, une Map
const cache = new Map();
cache.set(cle, valeur); // les clés ne touchent jamais Object.prototype

3. Geler le prototype en production, derrière un test de non-régression.

// Au tout début du démarrage, après chargement des dépendances
Object.freeze(Object.prototype);
// Toute tentative d'écriture sur le prototype échouera désormais

Et, transversal : tenir à jour les bibliothèques de fusion. lodash récent écarte les clés dangereuses, mais mettre lodash à jour ferme une porte connue, pas la classe entière. Votre propre code de fusion reste à auditer.

Se protéger, en synthèse

  • Valider les entrées contre un schéma strict et fermé : une clé __proto__ ou constructor n'a aucune raison légitime d'exister.
  • Utiliser Object.create(null) pour les objets de données, ou Map pour les dictionnaires.
  • Geler Object.prototype en production, après avoir vérifié que la suite de tests passe.
  • Tenir à jour les bibliothèques de fusion, sans croire que cela couvre la classe entière.
  • Traiter les gadgets comme la désérialisation : un composant qui fait confiance à sa configuration devient dangereux dès que l'attaquant peut l'écrire. Voir aussi la désérialisation non sécurisée.

On remonte à la cause plutôt que de filtrer le symptôme, exactement comme face à une faille de runtime. La prototype pollution récompense la rigueur sur les entrées : c'est la même discipline que celle enseignée dans les bonnes pratiques de validation d'entrées.

S'entraîner sur un lab réel

La prototype pollution se comprend vraiment en la rejouant. Le parcours PHP → shell de Zeroday Cyber Academy applique la même pédagogie sur le runtime PHP : des cibles réelles, exploitation pas à pas, remédiation avant/après. La logique « une faille du langage ou du moteur, pas de l'application » y est centrale, et c'est exactement celle de cette page.

Sources

Questions fréquentes

  • Qu'est-ce que la prototype pollution ?
    La prototype pollution est une faille propre à JavaScript : en écrivant dans une clé spéciale comme __proto__, constructor ou prototype à travers une fusion d'objets non protégée, l'attaquant modifie le prototype partagé par tous les objets du programme. Ce n'est pas une faille d'une bibliothèque en particulier, c'est une conséquence du modèle d'héritage de JavaScript. Seule, elle altère le comportement de l'application. Chaînée à un gadget, elle peut mener jusqu'à l'exécution de code. C'est la classe CWE-1321.
  • Comment une pollution de prototype devient-elle une exécution de code ?
    La pollution ne donne pas directement l'exécution : elle a besoin d'un gadget, c'est-à-dire d'un endroit du code qui lit une propriété héritée du prototype en lui faisant confiance. Le cas d'école est le moteur de template EJS : si l'attaquant pollue la propriété outputFunctionName avec une charge, EJS l'injecte dans le code JavaScript qu'il compile pour rendre le template, et la charge s'exécute. La pollution fournit la donnée, le gadget fournit l'exécution. Sans gadget, pas de RCE ; sans pollution, pas de contrôle du gadget.
  • lodash est-il vulnérable aujourd'hui ?
    Les versions récentes de lodash ont corrigé les fonctions historiquement vulnérables comme merge, mergeWith et defaultsDeep. Mais le problème ne se limite pas à lodash : toute fonction de fusion récursive qui recopie des clés contrôlées par l'utilisateur sans écarter __proto__ est vulnérable, qu'elle vienne d'une bibliothèque ou de votre propre code. Mettre lodash à jour ferme une porte connue, pas la classe entière. La défense durable est de neutraliser les clés dangereuses à la frontière d'entrée.
  • Comment se protéger de la prototype pollution ?
    Trois leviers combinés. D'abord, valider les entrées contre un schéma strict qui n'accepte que les clés attendues : une clé __proto__ n'a aucune raison d'exister dans un payload légitime. Ensuite, créer les objets de données avec Object.create(null), qui n'ont pas de prototype à polluer, ou utiliser Map plutôt que des objets ordinaires pour les dictionnaires. Enfin, geler Object.prototype en production. Aucun de ces leviers n'est suffisant seul, mais ensemble ils cassent la chaîne à plusieurs endroits.
  • Comment détecter une prototype pollution dans mon code ?
    Cherchez les fusions récursives d'objets qui recopient des clés contrôlées par l'utilisateur sans écarter __proto__, constructor et prototype : les appels à lodash.merge, mergeWith et defaultsDeep sur des données de requête, mais aussi toute fonction de fusion maison. Côté exécution, un test simple révèle la faille : après avoir traité une entrée hostile, vérifiez que ({}).polluee est toujours undefined. Des outils comme l'analyse statique (CodeQL, Semgrep) et l'audit de dépendances (npm audit) remontent les bibliothèques vulnérables connues, mais ne couvrent pas votre propre code de fusion : la revue manuelle reste nécessaire.
  • Object.freeze(Object.prototype) casse-t-il mon application ?
    Rarement, mais le test est indispensable avant la production. Geler Object.prototype empêche toute écriture dessus, donc neutralise la pollution à la racine, mais certaines bibliothèques anciennes modifient le prototype au chargement et échoueront. La bonne démarche est de geler tôt dans le démarrage, derrière un drapeau, puis de faire tourner la suite de tests complète. Si une dépendance proteste, c'est souvent le signe qu'elle fait quelque chose qu'il vaut mieux savoir. Le gel n'est qu'un des leviers : il se combine avec la validation d'entrée stricte et Object.create(null), il ne les remplace pas.

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.