Injection NoSQL : principes, exploitations et bonnes pratiques sécurité

Les injections SQL sont des vulnérabilités bien connues et largement documentées. Elles exploitent les failles des bases de données relationnelles pour manipuler ou extraire des données sensibles.

Avec l’essor des applications web modernes, les bases NoSQL ont gagné en popularité, offrant plus de flexibilité et une meilleure adaptabilité que leurs homologues SQL.

Toutefois, cette souplesse ne vient pas sans risques : les injections NoSQL, bien que moins médiatisées, constituent une menace réelle pour la sécurité des applications utilisant des bases de données NoSQL.

Dans cet article, nous allons explorer les principes des bases NoSQL et comprendre comment fonctionnent les injections NoSQL. Nous analyserons également des scénarios d’exploitation concrets et les bonnes pratiques permettant de se protéger efficacement.

Guide complet sur les injections NoSQL

Qu’est-ce qu’une injection NoSQL ?

Une injection NoSQL est une attaque qui cible les bases de données NoSQL en exploitant des vulnérabilités dans la manière dont les requêtes sont formulées. L’objectif pour un attaquant est donc de manipuler ces requêtes mal sécurisées pour contourner l’authentification ou voler des données.

Contrairement aux bases de données SQL, où les injections se basent sur des requêtes SQL (telles que SELECT ou INSERT), les bases NoSQL utilisent des langages de requête spécifiques à chaque type de base de données. Une injection NoSQL consiste donc à insérer du code malveillant dans ces requêtes, modifiant ainsi leur comportement et permettant à l’attaquant d’effectuer des actions non autorisées.

Avant de plonger dans les différents scénarios d’exploitation des injections NoSQL, il est essentiel de comprendre le fonctionnement des bases de données NoSQL.

Principes et fonctionnement des bases de données NoSQL

Les bases de données NoSQL sont conçues pour répondre aux besoins des applications modernes, qui nécessitent une grande flexibilité et des performances optimales face à des volumes massifs de données.

Contrairement aux bases relationnelles traditionnelles (SQL), elles ne reposent pas sur un schéma rigide et structuré en tables avec des relations définies.

Un des principes fondamentaux des bases NoSQL est leur capacité à évoluer horizontalement. Là où une base relationnelle nécessite généralement d’augmenter la puissance d’un serveur unique, une base NoSQL peut être distribuée sur plusieurs machines, garantissant ainsi une meilleure répartition de la charge et une tolérance aux pannes accrue. Cette approche permet aux systèmes de gérer efficacement la montée en charge sans compromettre les performances.

Les bases NoSQL sont également conçues pour offrir des performances optimisées, notamment en lecture et en écriture. Elles sont souvent utilisées pour les applications nécessitant des temps de réponse très rapides. De plus, leur fonctionnement repose souvent sur des mécanismes de réplication et de distribution des données, garantissant ainsi une haute disponibilité et une résilience face aux pannes.

Il existe plusieurs types de bases NoSQL, chacun étant adapté à des cas d’usage spécifiques.

  • Les bases orientées documents, comme MongoDB, stockent les informations sous forme de documents JSON ou XML.
  • Les bases clé-valeur, comme Redis ou DynamoDB, sont optimisées pour des accès rapides et sont couramment utilisées pour la gestion de sessions ou le caching.
  • D’autres, comme Cassandra ou HBase, adoptent un modèle orienté colonnes, permettant d’optimiser les performances sur de très grands ensembles de données.
  • Enfin, les bases orientées graphes, telles que Neo4j, sont conçues pour gérer des relations complexes entre les données.

Le choix d’une base NoSQL dépend donc des besoins spécifiques de l’application. Si la flexibilité et la rapidité d’accès aux données sont primordiales, une base orientée documents ou clé-valeur sera souvent privilégiée. En revanche, si les données sont fortement relationnelles ou nécessitent des analyses complexes, une base orientée graphes ou colonnes pourra être plus adaptée.

Nous allons maintenant explorer plus en détail le fonctionnement de MongoDB. C’est la base NoSQL que nous rencontrons le plus souvent lors de nos audits.

Voici un exemple de document stocké sur MongoDB :

{
  "_id": ObjectId(
 "5f4f7fef2d4b45b7f11b6d7a"),
  "user_id": "Kevin",
  "age": 29,
  "Status": "A"
}

Le champ « _id » est réservé à MongoDB. Il sert de clé primaire et identifie chaque document de façon unique dans une collection.

MongoDB offre une grande flexibilité dans le stockage des données. Un même type d’objet peut contenir des champs différents d’un document à l’autre. Par exemple, un document peut inclure un champ « Country » tandis qu’un autre ne l’aura pas. Cela permet aux utilisateurs de renseigner uniquement les informations pertinentes, évitant ainsi le stockage de champs vides.

Pour interagir avec une base MongoDB, l’outil en ligne de commande mongosh est particulièrement utile.

Utilisation de mongosh
Utilisation de mongosh

Il est ensuite possible de parcourir les collections pour étudier les données. Les premières commandes sont use (choix de la collection) et show (visualisation de la collection).

L’ajout de données se fait avec insertOne :

Ajout de données
Ajout de données

Les autres opérations sur la base de données s’effectuent à l’aide de fonctions comme insertMany, find, updateOne, replaceOne, ou remove, entre autres.

Scénarios d’exploitation de failles d’injection NoSQL

Injection d’opérateur

Une injection NoSQL survient pour la même raison qu’une injection SQL : l’insertion directe de données utilisateur dans une requête envoyée à la base de données. Contrairement au SQL, il n’existe pas de langage NoSQL universel, ce qui rend chaque injection spécifique à l’implémentation utilisée (MongoDB, Neo4j, Cassandra, etc.).

Un exemple simple d’injection NoSQL serait le suivant :

$manager = new MongoDB\Driver\Manager("mongodb://127.0.0.1:27017");
$query = new MongoDB\Driver\Query(array("email" => $_POST['email'], "password" => $_POST['password']));
$cursor = $manager->executeQuery('db.users', $query);

Lors de l’authentification, l’application doit vérifier si le nom d’utilisateur et le mot de passe fournis existent dans la base de données et sont bien associés.

La requête envoyée à la base ressemblera à ceci :

db.users.find({
    email: "[email protected]",
    password: "superPassword"
});

Comme les paramètres sont contrôlés par l’utilisateur, celui-ci peut modifier la requête en y ajoutant des opérateurs. Il en existe plusieurs, parmi lesquels :

  • $eq : vérifie qu’une valeur est égale à celle d’un champ.
  • $ne : s’assure qu’une valeur est différente de celle d’un champ.
  • $gt : teste si une valeur est supérieure ou égale à celle d’un champ.
  • $and : ajoute une condition supplémentaire à la requête.

L’opérateur $ne permet de contourner le processus d’authentification. Avec la requête suivante, l’utilisateur pourra accéder au premier compte de la base de données, sans avoir besoin de connaître des identifiants valides :

db.users.find({
    email: {$ne: " [email protected] "},
    password: {$ne: "invalidPassword"}
});

La réponse de la base de données renverra tous les comptes dont l’email n’est pas « [email protected] » et le mot de passe n’est pas « invalidPassword ». D’autres opérateurs, comme $regex, pourraient aussi être utilisés pour obtenir un résultat similaire, en appliquant une expression régulière qui matche tous les emails et mots de passe.

Pour corriger cette vulnérabilité, la première mesure consiste à vérifier que les clés reçues figurent bien dans une liste blanche (ici, « email » et « password »). De manière générale, il est recommandé d’utiliser des requêtes paramétrées et de valider les entrées des utilisateurs (par exemple, s’assurer que l’email respecte le bon format).

Analyse de la CVE-2024-48573

Le CMS Aquila a été affecté par une vulnérabilité d’injection NoSQL, similaire à celle décrite précédemment. Cette faille touchait la fonctionnalité de réinitialisation de mot de passe, permettant à un utilisateur non authentifié de modifier le mot de passe d’un compte existant.

Voici le code vulnérable :

const resetPassword = async (token, password) => {
    const user = await Users.findOne({resetPassToken: token});
    if (password === undefined) {
        if (user) {
            return {message: 'Token valide'};
        }
        return {message: 'Token invalide'};
    }

    if (user) {
        try {
            user.password = password;
            user.needHash = true;
            await user.save();
            await Users.updateOne({_id: user._id}, {$unset: {resetPassToken: 1}});
            return {message: 'Mot de passe réinitialisé.'};
        } catch (err) {
            if (err.errors && err.errors.password && err.errors.password.message === 'FORMAT_PASSWORD') {
                throw NSErrors.LoginSubscribePasswordInvalid;
            }
            if (err.errors && err.errors.email && err.errors.email.message === 'BAD_EMAIL_FORMAT') {
                throw NSErrors.LoginSubscribeEmailInvalid;
            }
            throw err;
        }
    }
    return {message: 'Utilisateur introuvable, impossible de réinitialiser le mot de passe.', status: 500};
};

La fonction findOne est utilisée avec un paramètre provenant de l’utilisateur (le token). Aucun contrôle n’est effectué sur ce paramètre, car, par défaut, la fonction de validation (sanitizeFilter) n’est pas appliquée à findOne.

Pour exploiter cette vulnérabilité, il suffit d’injecter l’opérateur $ne dans le paramètre token. Cela permet de modifier le mot de passe d’un compte qui ne correspond pas à la valeur du token.

Pour corriger ce problème, il est nécessaire d’appeler explicitement la fonction sanitizeFilter, afin d’empêcher l’injection de tout opérateur malveillant.

L’exploitation d’une vulnérabilité NoSQL a généralement moins d’impact qu’une injection SQL, car la récupération des données est souvent limitée à une collection spécifique (bien que cela dépende du contexte, comme nous le verrons plus loin). Par exemple, une attaque pourrait permettre d’extraire tous les commentaires d’un blog, mais pas les mots de passe des utilisateurs, contrairement à une injection SQL où il est possible d’extraire l’intégralité de la base de données.

Cependant, dans la suite, nous allons explorer comment il est possible de récupérer des données d’une base NoSQL, même sans connaissance préalable des structures ou données.

Injection de JavaScript

Un type d’injection spécifique aux bases de données NoSQL est l’injection de JavaScript côté serveur (SSJI, pour Server-side JavaScript Injection). Dans ce cas, le statement $where nécessite l’exécution de code JavaScript.

Prenons l’exemple d’une requête d’authentification :

db.users.find({
    $where: 'this.username === "<username>" && this.password === "<password>"'
});

Si, avec les paramètres « username » ou « password », nous réussissons à faire en sorte que l’évaluation de l’expression retourne True, la requête renverra un utilisateur et l’authentification sera contournée.

L’utilisation de la condition OR (||) nous permet d’y parvenir :

this.username === "" || true || ""=="" && this.password === "<password>"

On peut tester cette expression dans la console JavaScript d’un navigateur pour constater qu’elle retournera toujours True, sans avoir à connaître le nom d’utilisateur ni le mot de passe.

L’objectif intéressant ici est d’extraire des données, notamment le nom d’utilisateur et le mot de passe d’un des comptes. Pour cela, au lieu de renvoyer toujours True, l’expression doit renvoyer True uniquement si une condition spécifique est remplie, par exemple sur le champ « password ».

La fonction match sera nécessaire et prendra une expression régulière (regex) en paramètre :

(this.password.match('^a.*'))

Cette expression ne retournera True que si le mot de passe de l’utilisateur ciblé commence par un « a ». La réponse de l’application changera dès que le bon caractère sera passé en paramètre. Il faudra alors répéter ce processus pour chaque caractère alphanumérique. L’extraction des données se fera caractère par caractère.

Il est important de noter que cette attaque sera moins efficace si le mot de passe est stocké sous forme de hash dans la base de données, plutôt qu’en clair. Cependant, l’attaquant pourra tenter de casser le hash hors ligne en utilisant un outil comme Hashcat.

Cypher injection

Nous allons maintenant aborder un autre type de base de données : Neo4j, une base de données orientée graphe. Le langage de requête associé à cette technologie s’appelle le Cypher Query Language, ce qui donne lieu à ce qu’on appelle des injections Cypher. Le principe reste le même que pour les injections NoSQL abordées précédemment, la différence réside uniquement dans la charge utile, qui doit être adaptée à ce langage spécifique.

Prenons l’exemple d’un catalogue de films où l’utilisateur entre le nom du film qu’il souhaite regarder. L’application lui répond en affichant tous les films disponibles avec ce nom sur la plateforme.

La requête est la suivante :

query = f"MATCH (m:Movie) WHERE toLower(m.title) CONTAINS toLower('{name}') RETURN m.title AS title"

Dans cet exemple, le mot-clé MATCH est l’équivalent d’un SELECT en SQL. L’utilisateur a le contrôle sur le paramètre name.

Pour démontrer la vulnérabilité, nous allons tenter d’extraire le nom de tous les films présents sur la plateforme. Pour cela, il suffit d’injecter une condition qui sera toujours évaluée à True. Comme pour contourner l’authentification dans une injection SQL, l’expression 1=1 pourra être utilisée pour cette manipulation.

La requête suivante va correspondre à tous les films :

query = f"MATCH (m:Movie) WHERE toLower(m.title) CONTAINS toLower('test') or 1=1 return m.title AS title// RETURN m.title AS title"

Comme on peut le constater, le principe reste classique : on sort de la condition en cours (ici CONTAINS toLower()) pour en créer une nouvelle qui sera toujours vraie, permettant ainsi de récupérer toutes les valeurs d’un nœud. Dans notre cas, il est également possible d’extraire d’autres données de la base en ajoutant une nouvelle requête dans l’injection.

Un exemple est montré ci-dessous :

query = f"MATCH (m:Movie) WHERE toLower(m.title) CONTAINS toLower('test’) MATCH (s:Credentials) WITH s.password as pwd return pwd as title// RETURN m.title AS title"

L’injection permet ici d’extraire les mots de passe de la base de données. Le statement final (return pwd as title) est crucial, car il permet à l’application de recevoir un champ title dans la réponse à la requête, évitant ainsi toute erreur.

Comment se protéger contre les injections NoSQL ?

Pour se protéger contre les injections NoSQL, il est essentiel de suivre quelques bonnes pratiques de développement et de sécurité.

  • Tout d’abord, il est recommandé d’utiliser des requêtes paramétrées. Cela permet de séparer les données des commandes, empêchant ainsi toute tentative d’injection. De plus, chaque paramètre doit être soigneusement contrôlé pour s’assurer qu’il ne contient pas de caractères ou d’opérateurs dangereux.
  • Ensuite, il est crucial de valider systématiquement les entrées utilisateur. Ne jamais faire confiance aux données provenant de l’utilisateur et toujours vérifier qu’elles sont conformes aux attentes avant de les utiliser dans une requête.
  • Une autre mesure importante est la mise en place de listes blanches pour les champs de données autorisés. Cela réduit le risque de manipulation malveillante de la base de données en n’acceptant que les données validées.

Auteurs : Julien BRACON – Pentester & Amin TRAORÉ – CMO @Vaadata