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); // trueL'é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é serveurLa 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é inconnue2. 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.prototype3. 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ésormaisEt, 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__ouconstructorn'a aucune raison légitime d'exister. - Utiliser
Object.create(null)pour les objets de données, ouMappour les dictionnaires. - Geler
Object.prototypeen 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
- OWASP, « Prototype Pollution Prevention Cheat Sheet » : https://cheatsheetseries.owasp.org/cheatsheets/Prototype_Pollution_Prevention_Cheat_Sheet.html
- MITRE, CWE-1321 « Improperly Controlled Modification of Object Prototype Attributes (Prototype Pollution) » : https://cwe.mitre.org/data/definitions/1321.html
- MITRE, CWE-94 « Improper Control of Generation of Code (Code Injection) » : https://cwe.mitre.org/data/definitions/94.html




